merge
This commit is contained in:
@@ -14,6 +14,7 @@ from django.db import DEFAULT_DB_ALIAS
|
||||
from . import app_settings
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
|
||||
def get_instance(model, instance_or_pk, timeout=None, using=None):
|
||||
"""
|
||||
Returns the ``model`` instance with a primary key of ``instance_or_pk``.
|
||||
@@ -108,11 +109,14 @@ def instance_key(model, instance_or_pk):
|
||||
getattr(instance_or_pk, 'pk', instance_or_pk),
|
||||
)
|
||||
|
||||
|
||||
def set_cached_content(content):
|
||||
cache.set(str(content.location), content)
|
||||
|
||||
|
||||
def get_cached_content(location):
|
||||
return cache.get(str(location))
|
||||
|
||||
|
||||
def del_cached_content(location):
|
||||
cache.delete(str(location))
|
||||
|
||||
@@ -12,7 +12,7 @@ from xmodule.exceptions import NotFoundError
|
||||
class StaticContentServer(object):
|
||||
def process_request(self, request):
|
||||
# look to see if the request is prefixed with 'c4x' tag
|
||||
if request.path.startswith('/' + XASSET_LOCATION_TAG +'/'):
|
||||
if request.path.startswith('/' + XASSET_LOCATION_TAG + '/'):
|
||||
loc = StaticContent.get_location_from_path(request.path)
|
||||
# first look in our cache so we don't have to round-trip to the DB
|
||||
content = get_cached_content(loc)
|
||||
@@ -21,7 +21,9 @@ class StaticContentServer(object):
|
||||
try:
|
||||
content = contentstore().find(loc)
|
||||
except NotFoundError:
|
||||
raise Http404
|
||||
response = HttpResponse()
|
||||
response.status_code = 404
|
||||
return response
|
||||
|
||||
# since we fetched it from DB, let's cache it going forward
|
||||
set_cached_content(content)
|
||||
|
||||
@@ -13,6 +13,7 @@ from .models import CourseUserGroup
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_course_cohorted(course_id):
|
||||
"""
|
||||
Given a course id, return a boolean for whether or not the course is
|
||||
@@ -115,6 +116,7 @@ def get_course_cohorts(course_id):
|
||||
|
||||
### Helpers for cohort management views
|
||||
|
||||
|
||||
def get_cohort_by_name(course_id, name):
|
||||
"""
|
||||
Return the CourseUserGroup object for the given cohort. Raises DoesNotExist
|
||||
@@ -124,6 +126,7 @@ def get_cohort_by_name(course_id, name):
|
||||
group_type=CourseUserGroup.COHORT,
|
||||
name=name)
|
||||
|
||||
|
||||
def get_cohort_by_id(course_id, cohort_id):
|
||||
"""
|
||||
Return the CourseUserGroup object for the given cohort. Raises DoesNotExist
|
||||
@@ -133,6 +136,7 @@ def get_cohort_by_id(course_id, cohort_id):
|
||||
group_type=CourseUserGroup.COHORT,
|
||||
id=cohort_id)
|
||||
|
||||
|
||||
def add_cohort(course_id, name):
|
||||
"""
|
||||
Add a cohort to a course. Raises ValueError if a cohort of the same name already
|
||||
@@ -148,12 +152,14 @@ def add_cohort(course_id, name):
|
||||
group_type=CourseUserGroup.COHORT,
|
||||
name=name)
|
||||
|
||||
|
||||
class CohortConflict(Exception):
|
||||
"""
|
||||
Raised when user to be added is already in another cohort in same course.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def add_user_to_cohort(cohort, username_or_email):
|
||||
"""
|
||||
Look up the given user, and if successful, add them to the specified cohort.
|
||||
@@ -211,4 +217,3 @@ def delete_empty_cohort(course_id, name):
|
||||
name, course_id))
|
||||
|
||||
cohort.delete()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.db import models
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CourseUserGroup(models.Model):
|
||||
"""
|
||||
This model represents groups of users in a course. Groups may have different types,
|
||||
@@ -30,5 +31,3 @@ class CourseUserGroup(models.Model):
|
||||
COHORT = 'cohort'
|
||||
GROUP_TYPE_CHOICES = ((COHORT, 'Cohort'),)
|
||||
group_type = models.CharField(max_length=20, choices=GROUP_TYPE_CHOICES)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import django.test
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
|
||||
from override_settings import override_settings
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from course_groups.models import CourseUserGroup
|
||||
from course_groups.cohorts import (get_cohort, get_course_cohorts,
|
||||
@@ -12,7 +12,8 @@ from xmodule.modulestore.django import modulestore, _MODULESTORES
|
||||
|
||||
# NOTE: running this with the lms.envs.test config works without
|
||||
# manually overriding the modulestore. However, running with
|
||||
# cms.envs.test doesn't.
|
||||
# cms.envs.test doesn't.
|
||||
|
||||
|
||||
def xml_store_config(data_dir):
|
||||
return {
|
||||
@@ -28,6 +29,7 @@ def xml_store_config(data_dir):
|
||||
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
|
||||
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
|
||||
class TestCohorts(django.test.TestCase):
|
||||
|
||||
@@ -184,6 +186,3 @@ class TestCohorts(django.test.TestCase):
|
||||
self.assertTrue(
|
||||
is_commentable_cohorted(course.id, to_id("Feedback")),
|
||||
"Feedback was listed as cohorted. Should be.")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import track.views
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def json_http_response(data):
|
||||
"""
|
||||
Return an HttpResponse with the data json-serialized and the right content
|
||||
@@ -29,6 +30,7 @@ def json_http_response(data):
|
||||
"""
|
||||
return HttpResponse(json.dumps(data), content_type="application/json")
|
||||
|
||||
|
||||
def split_by_comma_and_whitespace(s):
|
||||
"""
|
||||
Split a string both by commas and whitespice. Returns a list.
|
||||
@@ -177,6 +179,7 @@ def add_users_to_cohort(request, course_id, cohort_id):
|
||||
'conflict': conflict,
|
||||
'unknown': unknown})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@require_POST
|
||||
def remove_user_from_cohort(request, course_id, cohort_id):
|
||||
|
||||
@@ -5,8 +5,9 @@ django admin pages for courseware model
|
||||
from external_auth.models import *
|
||||
from django.contrib import admin
|
||||
|
||||
|
||||
class ExternalAuthMapAdmin(admin.ModelAdmin):
|
||||
search_fields = ['external_id','user__username']
|
||||
search_fields = ['external_id', 'user__username']
|
||||
date_hierarchy = 'dtcreated'
|
||||
|
||||
admin.site.register(ExternalAuthMap, ExternalAuthMapAdmin)
|
||||
|
||||
@@ -12,20 +12,20 @@ file and check it in at the same time as your model changes. To do that,
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
class ExternalAuthMap(models.Model):
|
||||
class Meta:
|
||||
unique_together = (('external_id', 'external_domain'), )
|
||||
external_id = models.CharField(max_length=255, db_index=True)
|
||||
external_domain = models.CharField(max_length=255, db_index=True)
|
||||
external_credentials = models.TextField(blank=True) # JSON dictionary
|
||||
external_credentials = models.TextField(blank=True) # JSON dictionary
|
||||
external_email = models.CharField(max_length=255, db_index=True)
|
||||
external_name = models.CharField(blank=True,max_length=255, db_index=True)
|
||||
external_name = models.CharField(blank=True, max_length=255, db_index=True)
|
||||
user = models.OneToOneField(User, unique=True, db_index=True, null=True)
|
||||
internal_password = models.CharField(blank=True, max_length=31) # randomly generated
|
||||
dtcreated = models.DateTimeField('creation date',auto_now_add=True)
|
||||
dtsignup = models.DateTimeField('signup date',null=True) # set after signup
|
||||
|
||||
internal_password = models.CharField(blank=True, max_length=31) # randomly generated
|
||||
dtcreated = models.DateTimeField('creation date', auto_now_add=True)
|
||||
dtsignup = models.DateTimeField('signup date', null=True) # set after signup
|
||||
|
||||
def __unicode__(self):
|
||||
s = "[%s] = (%s / %s)" % (self.external_id, self.external_name, self.external_email)
|
||||
return s
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ from django.test import TestCase, LiveServerTestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
|
||||
class MyFetcher(HTTPFetcher):
|
||||
"""A fetcher that uses server-internal calls for performing HTTP
|
||||
requests.
|
||||
requests.
|
||||
"""
|
||||
|
||||
def __init__(self, client):
|
||||
@@ -42,7 +43,7 @@ class MyFetcher(HTTPFetcher):
|
||||
if headers and 'Accept' in headers:
|
||||
data['CONTENT_TYPE'] = headers['Accept']
|
||||
response = self.client.get(url, data)
|
||||
|
||||
|
||||
# Translate the test client response to the fetcher's HTTP response abstraction
|
||||
content = response.content
|
||||
final_url = url
|
||||
@@ -60,6 +61,7 @@ class MyFetcher(HTTPFetcher):
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
class OpenIdProviderTest(TestCase):
|
||||
|
||||
# def setUp(self):
|
||||
@@ -78,7 +80,7 @@ class OpenIdProviderTest(TestCase):
|
||||
provider_url = reverse('openid-provider-xrds')
|
||||
factory = RequestFactory()
|
||||
request = factory.request()
|
||||
abs_provider_url = request.build_absolute_uri(location = provider_url)
|
||||
abs_provider_url = request.build_absolute_uri(location=provider_url)
|
||||
|
||||
# In order for this absolute URL to work (i.e. to get xrds, then authentication)
|
||||
# in the test environment, we either need a live server that works with the default
|
||||
@@ -86,10 +88,10 @@ class OpenIdProviderTest(TestCase):
|
||||
# Here we do the latter:
|
||||
fetcher = MyFetcher(self.client)
|
||||
openid.fetchers.setDefaultFetcher(fetcher, wrap_exceptions=False)
|
||||
|
||||
|
||||
# now we can begin the login process by invoking a local openid client,
|
||||
# with a pointer to the (also-local) openid provider:
|
||||
with self.settings(OPENID_SSO_SERVER_URL = abs_provider_url):
|
||||
with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url):
|
||||
url = reverse('openid-login')
|
||||
resp = self.client.post(url)
|
||||
code = 200
|
||||
@@ -107,7 +109,7 @@ class OpenIdProviderTest(TestCase):
|
||||
provider_url = reverse('openid-provider-login')
|
||||
factory = RequestFactory()
|
||||
request = factory.request()
|
||||
abs_provider_url = request.build_absolute_uri(location = provider_url)
|
||||
abs_provider_url = request.build_absolute_uri(location=provider_url)
|
||||
|
||||
# In order for this absolute URL to work (i.e. to get xrds, then authentication)
|
||||
# in the test environment, we either need a live server that works with the default
|
||||
@@ -115,10 +117,10 @@ class OpenIdProviderTest(TestCase):
|
||||
# Here we do the latter:
|
||||
fetcher = MyFetcher(self.client)
|
||||
openid.fetchers.setDefaultFetcher(fetcher, wrap_exceptions=False)
|
||||
|
||||
|
||||
# now we can begin the login process by invoking a local openid client,
|
||||
# with a pointer to the (also-local) openid provider:
|
||||
with self.settings(OPENID_SSO_SERVER_URL = abs_provider_url):
|
||||
with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url):
|
||||
url = reverse('openid-login')
|
||||
resp = self.client.post(url)
|
||||
code = 200
|
||||
@@ -143,43 +145,43 @@ class OpenIdProviderTest(TestCase):
|
||||
self.assertContains(resp, '<input type="submit" value="Continue" />', html=True)
|
||||
# this should work on the server:
|
||||
self.assertContains(resp, '<input name="openid.realm" type="hidden" value="http://testserver/" />', html=True)
|
||||
|
||||
|
||||
# not included here are elements that will vary from run to run:
|
||||
# <input name="openid.return_to" type="hidden" value="http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H" />
|
||||
# <input name="openid.assoc_handle" type="hidden" value="{HMAC-SHA1}{50ff8120}{rh87+Q==}" />
|
||||
|
||||
|
||||
|
||||
|
||||
def testOpenIdSetup(self):
|
||||
if not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'):
|
||||
return
|
||||
url = reverse('openid-provider-login')
|
||||
post_args = {
|
||||
"openid.mode" : "checkid_setup",
|
||||
"openid.return_to" : "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H",
|
||||
"openid.assoc_handle" : "{HMAC-SHA1}{50ff8120}{rh87+Q==}",
|
||||
"openid.claimed_id" : "http://specs.openid.net/auth/2.0/identifier_select",
|
||||
"openid.ns" : "http://specs.openid.net/auth/2.0",
|
||||
"openid.realm" : "http://testserver/",
|
||||
"openid.identity" : "http://specs.openid.net/auth/2.0/identifier_select",
|
||||
"openid.ns.ax" : "http://openid.net/srv/ax/1.0",
|
||||
"openid.ax.mode" : "fetch_request",
|
||||
"openid.ax.required" : "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname",
|
||||
"openid.ax.type.fullname" : "http://axschema.org/namePerson",
|
||||
"openid.ax.type.lastname" : "http://axschema.org/namePerson/last",
|
||||
"openid.ax.type.firstname" : "http://axschema.org/namePerson/first",
|
||||
"openid.ax.type.nickname" : "http://axschema.org/namePerson/friendly",
|
||||
"openid.ax.type.email" : "http://axschema.org/contact/email",
|
||||
"openid.ax.type.old_email" : "http://schema.openid.net/contact/email",
|
||||
"openid.ax.type.old_nickname" : "http://schema.openid.net/namePerson/friendly",
|
||||
"openid.ax.type.old_fullname" : "http://schema.openid.net/namePerson",
|
||||
"openid.mode": "checkid_setup",
|
||||
"openid.return_to": "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H",
|
||||
"openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}",
|
||||
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
|
||||
"openid.ns": "http://specs.openid.net/auth/2.0",
|
||||
"openid.realm": "http://testserver/",
|
||||
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
|
||||
"openid.ns.ax": "http://openid.net/srv/ax/1.0",
|
||||
"openid.ax.mode": "fetch_request",
|
||||
"openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname",
|
||||
"openid.ax.type.fullname": "http://axschema.org/namePerson",
|
||||
"openid.ax.type.lastname": "http://axschema.org/namePerson/last",
|
||||
"openid.ax.type.firstname": "http://axschema.org/namePerson/first",
|
||||
"openid.ax.type.nickname": "http://axschema.org/namePerson/friendly",
|
||||
"openid.ax.type.email": "http://axschema.org/contact/email",
|
||||
"openid.ax.type.old_email": "http://schema.openid.net/contact/email",
|
||||
"openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly",
|
||||
"openid.ax.type.old_fullname": "http://schema.openid.net/namePerson",
|
||||
}
|
||||
resp = self.client.post(url, post_args)
|
||||
code = 200
|
||||
self.assertEqual(resp.status_code, code,
|
||||
"got code {0} for url '{1}'. Expected code {2}"
|
||||
.format(resp.status_code, url, code))
|
||||
|
||||
|
||||
|
||||
|
||||
# In order for this absolute URL to work (i.e. to get xrds, then authentication)
|
||||
# in the test environment, we either need a live server that works with the default
|
||||
# fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher.
|
||||
@@ -196,11 +198,11 @@ class OpenIdProviderLiveServerTest(LiveServerTestCase):
|
||||
provider_url = reverse('openid-provider-xrds')
|
||||
factory = RequestFactory()
|
||||
request = factory.request()
|
||||
abs_provider_url = request.build_absolute_uri(location = provider_url)
|
||||
abs_provider_url = request.build_absolute_uri(location=provider_url)
|
||||
|
||||
# now we can begin the login process by invoking a local openid client,
|
||||
# with a pointer to the (also-local) openid provider:
|
||||
with self.settings(OPENID_SSO_SERVER_URL = abs_provider_url):
|
||||
with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url):
|
||||
url = reverse('openid-login')
|
||||
resp = self.client.post(url)
|
||||
code = 200
|
||||
|
||||
@@ -215,7 +215,7 @@ def ssl_dn_extract_info(dn):
|
||||
else:
|
||||
return None
|
||||
return (user, email, fullname)
|
||||
|
||||
|
||||
|
||||
def ssl_get_cert_from_request(request):
|
||||
"""
|
||||
@@ -460,7 +460,7 @@ def provider_login(request):
|
||||
openid_request.answer(False), {})
|
||||
|
||||
# checkid_setup, so display login page
|
||||
# (by falling through to the provider_login at the
|
||||
# (by falling through to the provider_login at the
|
||||
# bottom of this method).
|
||||
elif openid_request.mode == 'checkid_setup':
|
||||
if openid_request.idSelect():
|
||||
@@ -482,7 +482,7 @@ def provider_login(request):
|
||||
|
||||
# handle login redirection: these are also sent to this view function,
|
||||
# but are distinguished by lacking the openid mode. We also know that
|
||||
# they are posts, because they come from the popup
|
||||
# they are posts, because they come from the popup
|
||||
elif request.method == 'POST' and 'openid_setup' in request.session:
|
||||
# get OpenID request from session
|
||||
openid_setup = request.session['openid_setup']
|
||||
@@ -495,7 +495,7 @@ def provider_login(request):
|
||||
return default_render_failure(request, "Invalid OpenID trust root")
|
||||
|
||||
# check if user with given email exists
|
||||
# Failure is redirected to this method (by using the original URL),
|
||||
# Failure is redirected to this method (by using the original URL),
|
||||
# which will bring up the login dialog.
|
||||
email = request.POST.get('email', None)
|
||||
try:
|
||||
@@ -542,17 +542,17 @@ def provider_login(request):
|
||||
# missing fields is up to the Consumer. The proper change
|
||||
# should only return the username, however this will likely
|
||||
# break the CS50 client. Temporarily we will be returning
|
||||
# username filling in for fullname in addition to username
|
||||
# username filling in for fullname in addition to username
|
||||
# as sreg nickname.
|
||||
|
||||
# Note too that this is hardcoded, and not really responding to
|
||||
|
||||
# Note too that this is hardcoded, and not really responding to
|
||||
# the extensions that were registered in the first place.
|
||||
results = {
|
||||
'nickname': user.username,
|
||||
'email': user.email,
|
||||
'fullname': user.username
|
||||
}
|
||||
|
||||
|
||||
# the request succeeded:
|
||||
return provider_respond(server, openid_request, response, results)
|
||||
|
||||
|
||||
@@ -12,34 +12,35 @@ import mitxmako.middleware
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MakoLoader(object):
|
||||
"""
|
||||
This is a Django loader object which will load the template as a
|
||||
Mako template if the first line is "## mako". It is based off BaseLoader
|
||||
in django.template.loader.
|
||||
"""
|
||||
|
||||
|
||||
is_usable = False
|
||||
|
||||
def __init__(self, base_loader):
|
||||
# base_loader is an instance of a BaseLoader subclass
|
||||
self.base_loader = base_loader
|
||||
|
||||
|
||||
module_directory = getattr(settings, 'MAKO_MODULE_DIR', None)
|
||||
|
||||
|
||||
if module_directory is None:
|
||||
log.warning("For more caching of mako templates, set the MAKO_MODULE_DIR in settings!")
|
||||
module_directory = tempfile.mkdtemp()
|
||||
|
||||
|
||||
self.module_directory = module_directory
|
||||
|
||||
|
||||
|
||||
|
||||
def __call__(self, template_name, template_dirs=None):
|
||||
return self.load_template(template_name, template_dirs)
|
||||
|
||||
def load_template(self, template_name, template_dirs=None):
|
||||
source, file_path = self.load_template_source(template_name, template_dirs)
|
||||
|
||||
|
||||
if source.startswith("## mako\n"):
|
||||
# This is a mako template
|
||||
template = Template(filename=file_path, module_directory=self.module_directory, uri=template_name)
|
||||
@@ -56,23 +57,24 @@ class MakoLoader(object):
|
||||
# This allows for correct identification (later) of the actual template that does
|
||||
# not exist.
|
||||
return source, file_path
|
||||
|
||||
|
||||
def load_template_source(self, template_name, template_dirs=None):
|
||||
# Just having this makes the template load as an instance, instead of a class.
|
||||
return self.base_loader.load_template_source(template_name, template_dirs)
|
||||
|
||||
def reset(self):
|
||||
self.base_loader.reset()
|
||||
|
||||
|
||||
|
||||
class MakoFilesystemLoader(MakoLoader):
|
||||
is_usable = True
|
||||
|
||||
|
||||
def __init__(self):
|
||||
MakoLoader.__init__(self, FilesystemLoader())
|
||||
|
||||
|
||||
|
||||
class MakoAppDirectoriesLoader(MakoLoader):
|
||||
is_usable = True
|
||||
|
||||
|
||||
def __init__(self):
|
||||
MakoLoader.__init__(self, AppDirectoriesLoader())
|
||||
|
||||
@@ -20,13 +20,15 @@ from mitxmako import middleware
|
||||
django_variables = ['lookup', 'output_encoding', 'encoding_errors']
|
||||
|
||||
# TODO: We should make this a Django Template subclass that simply has the MakoTemplate inside of it? (Intead of inheriting from MakoTemplate)
|
||||
|
||||
|
||||
class Template(MakoTemplate):
|
||||
"""
|
||||
This bridges the gap between a Mako template and a djano template. It can
|
||||
be rendered like it is a django template because the arguments are transformed
|
||||
in a way that MakoTemplate can understand.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Overrides base __init__ to provide django variable overrides"""
|
||||
if not kwargs.get('no_django', False):
|
||||
@@ -34,8 +36,8 @@ class Template(MakoTemplate):
|
||||
overrides['lookup'] = overrides['lookup']['main']
|
||||
kwargs.update(overrides)
|
||||
super(Template, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
|
||||
def render(self, context_instance):
|
||||
"""
|
||||
This takes a render call with a context (from Django) and translates
|
||||
@@ -43,7 +45,7 @@ class Template(MakoTemplate):
|
||||
"""
|
||||
# collapse context_instance to a single dictionary for mako
|
||||
context_dictionary = {}
|
||||
|
||||
|
||||
# In various testing contexts, there might not be a current request context.
|
||||
if middleware.requestcontext is not None:
|
||||
for d in middleware.requestcontext:
|
||||
@@ -53,5 +55,5 @@ class Template(MakoTemplate):
|
||||
context_dictionary['settings'] = settings
|
||||
context_dictionary['MITX_ROOT_URL'] = settings.MITX_ROOT_URL
|
||||
context_dictionary['django_context'] = context_instance
|
||||
|
||||
|
||||
return super(Template, self).render_unicode(**context_dictionary)
|
||||
|
||||
@@ -2,14 +2,15 @@ from django.template import loader
|
||||
from django.template.base import Template, Context
|
||||
from django.template.loader import get_template, select_template
|
||||
|
||||
|
||||
def django_template_include(file_name, mako_context):
|
||||
"""
|
||||
This can be used within a mako template to include a django template
|
||||
in the way that a django-style {% include %} does. Pass it context
|
||||
which can be the mako context ('context') or a dictionary.
|
||||
"""
|
||||
|
||||
dictionary = dict( mako_context )
|
||||
|
||||
dictionary = dict(mako_context)
|
||||
return loader.render_to_string(file_name, dictionary=dictionary)
|
||||
|
||||
|
||||
@@ -18,7 +19,7 @@ def render_inclusion(func, file_name, takes_context, django_context, *args, **kw
|
||||
This allows a mako template to call a template tag function (written
|
||||
for django templates) that is an "inclusion tag". These functions are
|
||||
decorated with @register.inclusion_tag.
|
||||
|
||||
|
||||
-func: This is the function that is registered as an inclusion tag.
|
||||
You must import it directly using a python import statement.
|
||||
-file_name: This is the filename of the template, passed into the
|
||||
@@ -29,10 +30,10 @@ def render_inclusion(func, file_name, takes_context, django_context, *args, **kw
|
||||
a copy of the django context is available as 'django_context'.
|
||||
-*args and **kwargs are the arguments to func.
|
||||
"""
|
||||
|
||||
|
||||
if takes_context:
|
||||
args = [django_context] + list(args)
|
||||
|
||||
|
||||
_dict = func(*args, **kwargs)
|
||||
if isinstance(file_name, Template):
|
||||
t = file_name
|
||||
@@ -40,14 +41,12 @@ def render_inclusion(func, file_name, takes_context, django_context, *args, **kw
|
||||
t = select_template(file_name)
|
||||
else:
|
||||
t = get_template(file_name)
|
||||
|
||||
|
||||
nodelist = t.nodelist
|
||||
|
||||
|
||||
new_context = Context(_dict)
|
||||
csrf_token = django_context.get('csrf_token', None)
|
||||
if csrf_token is not None:
|
||||
new_context['csrf_token'] = csrf_token
|
||||
|
||||
return nodelist.render(new_context)
|
||||
|
||||
|
||||
return nodelist.render(new_context)
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from staticfiles.storage import staticfiles_storage
|
||||
from staticfiles import finders
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def try_staticfiles_lookup(path):
|
||||
"""
|
||||
Try to lookup a path in staticfiles_storage. If it fails, return
|
||||
a dead link instead of raising an exception.
|
||||
"""
|
||||
try:
|
||||
url = staticfiles_storage.url(path)
|
||||
except Exception as err:
|
||||
log.warning("staticfiles_storage couldn't find path {0}: {1}".format(
|
||||
path, str(err)))
|
||||
# Just return the original path; don't kill everything.
|
||||
url = path
|
||||
return url
|
||||
|
||||
|
||||
def replace(static_url, prefix=None, course_namespace=None):
|
||||
if prefix is None:
|
||||
prefix = ''
|
||||
else:
|
||||
prefix = prefix + '/'
|
||||
|
||||
quote = static_url.group('quote')
|
||||
|
||||
servable = (
|
||||
# If in debug mode, we'll serve up anything that the finders can find
|
||||
(settings.DEBUG and finders.find(static_url.group('rest'), True)) or
|
||||
# Otherwise, we'll only serve up stuff that the storages can find
|
||||
staticfiles_storage.exists(static_url.group('rest'))
|
||||
)
|
||||
|
||||
if servable:
|
||||
return static_url.group(0)
|
||||
else:
|
||||
# don't error if file can't be found
|
||||
# cdodge: to support the change over to Mongo backed content stores, lets
|
||||
# use the utility functions in StaticContent.py
|
||||
if static_url.group('prefix') == '/static/' and not isinstance(modulestore(), XMLModuleStore):
|
||||
if course_namespace is None:
|
||||
raise BaseException('You must pass in course_namespace when remapping static content urls with MongoDB stores')
|
||||
url = StaticContent.convert_legacy_static_url(static_url.group('rest'), course_namespace)
|
||||
else:
|
||||
url = try_staticfiles_lookup(prefix + static_url.group('rest'))
|
||||
|
||||
new_link = "".join([quote, url, quote])
|
||||
return new_link
|
||||
|
||||
|
||||
|
||||
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None):
|
||||
|
||||
def replace_url(static_url):
|
||||
return replace(static_url, staticfiles_prefix, course_namespace = course_namespace)
|
||||
|
||||
return re.sub(r"""
|
||||
(?x) # flags=re.VERBOSE
|
||||
(?P<quote>\\?['"]) # the opening quotes
|
||||
(?P<prefix>{prefix}) # the prefix
|
||||
(?P<rest>.*?) # everything else in the url
|
||||
(?P=quote) # the first matching closing quote
|
||||
""".format(prefix=replace_prefix), replace_url, text)
|
||||
114
common/djangoapps/static_replace/__init__.py
Normal file
114
common/djangoapps/static_replace/__init__.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from staticfiles.storage import staticfiles_storage
|
||||
from staticfiles import finders
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _url_replace_regex(prefix):
|
||||
"""
|
||||
Match static urls in quotes that don't end in '?raw'.
|
||||
|
||||
To anyone contemplating making this more complicated:
|
||||
http://xkcd.com/1171/
|
||||
"""
|
||||
return r"""
|
||||
(?x) # flags=re.VERBOSE
|
||||
(?P<quote>\\?['"]) # the opening quotes
|
||||
(?P<prefix>{prefix}) # the prefix
|
||||
(?P<rest>.*?) # everything else in the url
|
||||
(?P=quote) # the first matching closing quote
|
||||
""".format(prefix=prefix)
|
||||
|
||||
|
||||
def try_staticfiles_lookup(path):
|
||||
"""
|
||||
Try to lookup a path in staticfiles_storage. If it fails, return
|
||||
a dead link instead of raising an exception.
|
||||
"""
|
||||
try:
|
||||
url = staticfiles_storage.url(path)
|
||||
except Exception as err:
|
||||
log.warning("staticfiles_storage couldn't find path {0}: {1}".format(
|
||||
path, str(err)))
|
||||
# Just return the original path; don't kill everything.
|
||||
url = path
|
||||
return url
|
||||
|
||||
|
||||
def replace_course_urls(text, course_id):
|
||||
"""
|
||||
Replace /course/$stuff urls with /courses/$course_id/$stuff urls
|
||||
|
||||
text: The text to replace
|
||||
course_module: A CourseDescriptor
|
||||
|
||||
returns: text with the links replaced
|
||||
"""
|
||||
|
||||
|
||||
def replace_course_url(match):
|
||||
quote = match.group('quote')
|
||||
rest = match.group('rest')
|
||||
return "".join([quote, '/courses/' + course_id + '/', rest, quote])
|
||||
|
||||
return re.sub(_url_replace_regex('/course/'), replace_course_url, text)
|
||||
|
||||
|
||||
def replace_static_urls(text, data_directory, course_namespace=None):
|
||||
"""
|
||||
Replace /static/$stuff urls either with their correct url as generated by collectstatic,
|
||||
(/static/$md5_hashed_stuff) or by the course-specific content static url
|
||||
/static/$course_data_dir/$stuff, or, if course_namespace is not None, by the
|
||||
correct url in the contentstore (c4x://)
|
||||
|
||||
text: The source text to do the substitution in
|
||||
data_directory: The directory in which course data is stored
|
||||
course_namespace: The course identifier used to distinguish static content for this course in studio
|
||||
"""
|
||||
|
||||
def replace_static_url(match):
|
||||
original = match.group(0)
|
||||
prefix = match.group('prefix')
|
||||
quote = match.group('quote')
|
||||
rest = match.group('rest')
|
||||
|
||||
# Don't mess with things that end in '?raw'
|
||||
if rest.endswith('?raw'):
|
||||
return original
|
||||
|
||||
# course_namespace is not None, then use studio style urls
|
||||
if course_namespace is not None and not isinstance(modulestore(), XMLModuleStore):
|
||||
url = StaticContent.convert_legacy_static_url(rest, course_namespace)
|
||||
# In debug mode, if we can find the url as is,
|
||||
elif settings.DEBUG and finders.find(rest, True):
|
||||
return original
|
||||
# Otherwise, look the file up in staticfiles_storage, and append the data directory if needed
|
||||
else:
|
||||
course_path = "/".join((data_directory, rest))
|
||||
|
||||
try:
|
||||
if staticfiles_storage.exists(rest):
|
||||
url = staticfiles_storage.url(rest)
|
||||
else:
|
||||
url = staticfiles_storage.url(course_path)
|
||||
# And if that fails, assume that it's course content, and add manually data directory
|
||||
except Exception as err:
|
||||
log.warning("staticfiles_storage couldn't find path {0}: {1}".format(
|
||||
rest, str(err)))
|
||||
url = "".join([prefix, course_path])
|
||||
|
||||
return "".join([quote, url, quote])
|
||||
|
||||
return re.sub(
|
||||
_url_replace_regex('/static/(?!{data_dir})'.format(data_dir=data_directory)),
|
||||
replace_static_url,
|
||||
text
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
###
|
||||
### Script for importing courseware from XML format
|
||||
###
|
||||
|
||||
from django.core.management.base import NoArgsCommand
|
||||
from django.core.cache import get_cache
|
||||
|
||||
|
||||
class Command(NoArgsCommand):
|
||||
help = \
|
||||
'''Import the specified data directory into the default ModuleStore'''
|
||||
|
||||
def handle_noargs(self, **options):
|
||||
staticfiles_cache = get_cache('staticfiles')
|
||||
staticfiles_cache.clear()
|
||||
111
common/djangoapps/static_replace/test/test_static_replace.py
Normal file
111
common/djangoapps/static_replace/test/test_static_replace.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import re
|
||||
|
||||
from nose.tools import assert_equals, assert_true, assert_false
|
||||
from static_replace import (replace_static_urls, replace_course_urls,
|
||||
_url_replace_regex)
|
||||
from mock import patch, Mock
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.mongo import MongoModuleStore
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
|
||||
DATA_DIRECTORY = 'data_dir'
|
||||
COURSE_ID = 'org/course/run'
|
||||
NAMESPACE = Location('org', 'course', 'run', None, None)
|
||||
STATIC_SOURCE = '"/static/file.png"'
|
||||
|
||||
|
||||
def test_multi_replace():
|
||||
course_source = '"/course/file.png"'
|
||||
|
||||
assert_equals(
|
||||
replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY),
|
||||
replace_static_urls(replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY), DATA_DIRECTORY)
|
||||
)
|
||||
assert_equals(
|
||||
replace_course_urls(course_source, COURSE_ID),
|
||||
replace_course_urls(replace_course_urls(course_source, COURSE_ID), COURSE_ID)
|
||||
)
|
||||
|
||||
|
||||
@patch('static_replace.staticfiles_storage')
|
||||
def test_storage_url_exists(mock_storage):
|
||||
mock_storage.exists.return_value = True
|
||||
mock_storage.url.return_value = '/static/file.png'
|
||||
|
||||
assert_equals('"/static/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY))
|
||||
mock_storage.exists.called_once_with('file.png')
|
||||
mock_storage.url.called_once_with('data_dir/file.png')
|
||||
|
||||
|
||||
@patch('static_replace.staticfiles_storage')
|
||||
def test_storage_url_not_exists(mock_storage):
|
||||
mock_storage.exists.return_value = False
|
||||
mock_storage.url.return_value = '/static/data_dir/file.png'
|
||||
|
||||
assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY))
|
||||
mock_storage.exists.called_once_with('file.png')
|
||||
mock_storage.url.called_once_with('file.png')
|
||||
|
||||
|
||||
@patch('static_replace.StaticContent')
|
||||
@patch('static_replace.modulestore')
|
||||
def test_mongo_filestore(mock_modulestore, mock_static_content):
|
||||
|
||||
mock_modulestore.return_value = Mock(MongoModuleStore)
|
||||
mock_static_content.convert_legacy_static_url.return_value = "c4x://mock_url"
|
||||
|
||||
# No namespace => no change to path
|
||||
assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY))
|
||||
|
||||
# Namespace => content url
|
||||
assert_equals(
|
||||
'"' + mock_static_content.convert_legacy_static_url.return_value + '"',
|
||||
replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY, NAMESPACE)
|
||||
)
|
||||
|
||||
mock_static_content.convert_legacy_static_url.assert_called_once_with('file.png', NAMESPACE)
|
||||
|
||||
|
||||
@patch('static_replace.settings')
|
||||
@patch('static_replace.modulestore')
|
||||
@patch('static_replace.staticfiles_storage')
|
||||
def test_data_dir_fallback(mock_storage, mock_modulestore, mock_settings):
|
||||
mock_modulestore.return_value = Mock(XMLModuleStore)
|
||||
mock_storage.url.side_effect = Exception
|
||||
|
||||
mock_storage.exists.return_value = True
|
||||
assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY))
|
||||
|
||||
mock_storage.exists.return_value = False
|
||||
assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY))
|
||||
|
||||
|
||||
def test_raw_static_check():
|
||||
"""
|
||||
Make sure replace_static_urls leaves alone things that end in '.raw'
|
||||
"""
|
||||
path = '"/static/foo.png?raw"'
|
||||
assert_equals(path, replace_static_urls(path, DATA_DIRECTORY))
|
||||
|
||||
text = 'text <tag a="/static/js/capa/protex/protex.nocache.js?raw"/><div class="'
|
||||
assert_equals(path, replace_static_urls(path, text))
|
||||
|
||||
|
||||
def test_regex():
|
||||
yes = ('"/static/foo.png"',
|
||||
'"/static/foo.png"',
|
||||
"'/static/foo.png'")
|
||||
|
||||
no = ('"/not-static/foo.png"',
|
||||
'"/static/foo', # no matching quote
|
||||
)
|
||||
|
||||
regex = _url_replace_regex('/static/')
|
||||
|
||||
for s in yes:
|
||||
print 'Should match: {0!r}'.format(s)
|
||||
assert_true(re.match(regex, s))
|
||||
|
||||
for s in no:
|
||||
print 'Should not match: {0!r}'.format(s)
|
||||
assert_false(re.match(regex, s))
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import sys
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_site_status_msg(course_id):
|
||||
"""
|
||||
Look for a file settings.STATUS_MESSAGE_PATH. If found, read it,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
import os
|
||||
from override_settings import override_settings
|
||||
from django.test.utils import override_settings
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
from status import get_site_status_msg
|
||||
|
||||
@@ -57,7 +57,7 @@ from student.userprofile. '''
|
||||
d[key] = item
|
||||
return d
|
||||
|
||||
extracted = [{'up':extract_dict(up_keys, t[0]), 'u':extract_dict(user_keys, t[1])} for t in user_tuples]
|
||||
extracted = [{'up': extract_dict(up_keys, t[0]), 'u':extract_dict(user_keys, t[1])} for t in user_tuples]
|
||||
fp = open('transfer_users.txt', 'w')
|
||||
json.dump(extracted, fp)
|
||||
fp.close()
|
||||
|
||||
@@ -3,6 +3,7 @@ from optparse import make_option
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.contrib.auth.models import User, Group
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--list',
|
||||
|
||||
@@ -8,6 +8,7 @@ from student.models import UserProfile, CourseEnrollment
|
||||
|
||||
from student.views import _do_create_account, get_random_post_override
|
||||
|
||||
|
||||
def create(n, course_id):
|
||||
"""Create n users, enrolling them in course_id if it's not None"""
|
||||
for i in range(n):
|
||||
@@ -15,6 +16,7 @@ def create(n, course_id):
|
||||
if course_id is not None:
|
||||
CourseEnrollment.objects.create(user=user, course_id=course_id)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Create N new users, with random parameters.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class Command(BaseCommand):
|
||||
outputfile = datetime.utcnow().strftime("pearson-dump-%Y%m%d-%H%M%S.json")
|
||||
else:
|
||||
outputfile = args[0]
|
||||
|
||||
|
||||
# construct the query object to dump:
|
||||
registrations = TestCenterRegistration.objects.all()
|
||||
if 'course_id' in options and options['course_id']:
|
||||
@@ -44,24 +44,24 @@ class Command(BaseCommand):
|
||||
if 'exam_series_code' in options and options['exam_series_code']:
|
||||
registrations = registrations.filter(exam_series_code=options['exam_series_code'])
|
||||
|
||||
# collect output:
|
||||
# collect output:
|
||||
output = []
|
||||
for registration in registrations:
|
||||
if 'accommodation_pending' in options and options['accommodation_pending'] and not registration.accommodation_is_pending:
|
||||
continue
|
||||
record = {'username' : registration.testcenter_user.user.username,
|
||||
'email' : registration.testcenter_user.email,
|
||||
'first_name' : registration.testcenter_user.first_name,
|
||||
'last_name' : registration.testcenter_user.last_name,
|
||||
'client_candidate_id' : registration.client_candidate_id,
|
||||
'client_authorization_id' : registration.client_authorization_id,
|
||||
'course_id' : registration.course_id,
|
||||
'exam_series_code' : registration.exam_series_code,
|
||||
'accommodation_request' : registration.accommodation_request,
|
||||
'accommodation_code' : registration.accommodation_code,
|
||||
'registration_status' : registration.registration_status(),
|
||||
'demographics_status' : registration.demographics_status(),
|
||||
'accommodation_status' : registration.accommodation_status(),
|
||||
record = {'username': registration.testcenter_user.user.username,
|
||||
'email': registration.testcenter_user.email,
|
||||
'first_name': registration.testcenter_user.first_name,
|
||||
'last_name': registration.testcenter_user.last_name,
|
||||
'client_candidate_id': registration.client_candidate_id,
|
||||
'client_authorization_id': registration.client_authorization_id,
|
||||
'course_id': registration.course_id,
|
||||
'exam_series_code': registration.exam_series_code,
|
||||
'accommodation_request': registration.accommodation_request,
|
||||
'accommodation_code': registration.accommodation_code,
|
||||
'registration_status': registration.registration_status(),
|
||||
'demographics_status': registration.demographics_status(),
|
||||
'accommodation_status': registration.accommodation_status(),
|
||||
}
|
||||
if len(registration.upload_error_message) > 0:
|
||||
record['registration_error'] = registration.upload_error_message
|
||||
@@ -71,8 +71,7 @@ class Command(BaseCommand):
|
||||
record['needs_uploading'] = True
|
||||
|
||||
output.append(record)
|
||||
|
||||
|
||||
# dump output:
|
||||
with open(outputfile, 'w') as outfile:
|
||||
dump(output, outfile, indent=2)
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ class Command(BaseCommand):
|
||||
("LastUpdate", "user_updated_at"), # in UTC, so same as what we store
|
||||
])
|
||||
|
||||
# define defaults, even thought 'store_true' shouldn't need them.
|
||||
# define defaults, even thought 'store_true' shouldn't need them.
|
||||
# (call_command will set None as default value for all options that don't have one,
|
||||
# so one cannot rely on presence/absence of flags in that world.)
|
||||
option_list = BaseCommand.option_list + (
|
||||
@@ -56,7 +56,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
# update time should use UTC in order to be comparable to the user_updated_at
|
||||
# update time should use UTC in order to be comparable to the user_updated_at
|
||||
# field
|
||||
uploaded_at = datetime.utcnow()
|
||||
|
||||
@@ -100,7 +100,7 @@ class Command(BaseCommand):
|
||||
extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
for tcu in TestCenterUser.objects.order_by('id'):
|
||||
if tcu.needs_uploading: # or dump_all
|
||||
if tcu.needs_uploading: # or dump_all
|
||||
record = dict((csv_field, ensure_encoding(getattr(tcu, model_field)))
|
||||
for csv_field, model_field
|
||||
in Command.CSV_TO_MODEL_FIELDS.items())
|
||||
|
||||
@@ -116,4 +116,3 @@ class Command(BaseCommand):
|
||||
tcuser.save()
|
||||
except TestCenterUser.DoesNotExist:
|
||||
Command.datadog_error(" Failed to find record for client_candidate_id {}".format(client_candidate_id), vcdcfile.name)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from student.views import course_from_id
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
option_list = BaseCommand.option_list + (
|
||||
# registration info:
|
||||
@@ -16,23 +17,23 @@ class Command(BaseCommand):
|
||||
'--accommodation_request',
|
||||
action='store',
|
||||
dest='accommodation_request',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--accommodation_code',
|
||||
action='store',
|
||||
dest='accommodation_code',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--client_authorization_id',
|
||||
action='store',
|
||||
dest='client_authorization_id',
|
||||
),
|
||||
# exam info:
|
||||
),
|
||||
# exam info:
|
||||
make_option(
|
||||
'--exam_series_code',
|
||||
action='store',
|
||||
dest='exam_series_code',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--eligibility_appointment_date_first',
|
||||
action='store',
|
||||
@@ -51,32 +52,32 @@ class Command(BaseCommand):
|
||||
action='store',
|
||||
dest='authorization_id',
|
||||
help='ID we receive from Pearson for a particular authorization'
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--upload_status',
|
||||
action='store',
|
||||
dest='upload_status',
|
||||
help='status value assigned by Pearson'
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--upload_error_message',
|
||||
action='store',
|
||||
dest='upload_error_message',
|
||||
help='error message provided by Pearson on a failure.'
|
||||
),
|
||||
),
|
||||
# control values:
|
||||
make_option(
|
||||
'--ignore_registration_dates',
|
||||
action='store_true',
|
||||
dest='ignore_registration_dates',
|
||||
help='find exam info for course based on exam_series_code, even if the exam is not active.'
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--create_dummy_exam',
|
||||
action='store_true',
|
||||
dest='create_dummy_exam',
|
||||
help='create dummy exam info for course, even if course exists'
|
||||
),
|
||||
),
|
||||
)
|
||||
args = "<student_username course_id>"
|
||||
help = "Create or modify a TestCenterRegistration entry for a given Student"
|
||||
@@ -103,7 +104,7 @@ class Command(BaseCommand):
|
||||
testcenter_user = TestCenterUser.objects.get(user=student)
|
||||
except TestCenterUser.DoesNotExist:
|
||||
raise CommandError("User \"{}\" does not have an existing demographics record".format(username))
|
||||
|
||||
|
||||
# get an "exam" object. Check to see if a course_id was specified, and use information from that:
|
||||
exam = None
|
||||
create_dummy_exam = 'create_dummy_exam' in our_options and our_options['create_dummy_exam']
|
||||
@@ -115,14 +116,14 @@ class Command(BaseCommand):
|
||||
exam = examlist[0] if len(examlist) > 0 else None
|
||||
else:
|
||||
exam = course.current_test_center_exam
|
||||
except ItemNotFoundError:
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
else:
|
||||
# otherwise use explicit values (so we don't have to define a course):
|
||||
# otherwise use explicit values (so we don't have to define a course):
|
||||
exam_name = "Dummy Placeholder Name"
|
||||
exam_info = { 'Exam_Series_Code': our_options['exam_series_code'],
|
||||
'First_Eligible_Appointment_Date' : our_options['eligibility_appointment_date_first'],
|
||||
'Last_Eligible_Appointment_Date' : our_options['eligibility_appointment_date_last'],
|
||||
exam_info = {'Exam_Series_Code': our_options['exam_series_code'],
|
||||
'First_Eligible_Appointment_Date': our_options['eligibility_appointment_date_first'],
|
||||
'Last_Eligible_Appointment_Date': our_options['eligibility_appointment_date_last'],
|
||||
}
|
||||
exam = CourseDescriptor.TestCenterExam(course_id, exam_name, exam_info)
|
||||
# update option values for date_first and date_last to use YYYY-MM-DD format
|
||||
@@ -134,15 +135,15 @@ class Command(BaseCommand):
|
||||
raise CommandError("Exam for course_id {} does not exist".format(course_id))
|
||||
|
||||
exam_code = exam.exam_series_code
|
||||
|
||||
UPDATE_FIELDS = ( 'accommodation_request',
|
||||
|
||||
UPDATE_FIELDS = ('accommodation_request',
|
||||
'accommodation_code',
|
||||
'client_authorization_id',
|
||||
'exam_series_code',
|
||||
'eligibility_appointment_date_first',
|
||||
'eligibility_appointment_date_last',
|
||||
)
|
||||
|
||||
|
||||
# create and save the registration:
|
||||
needs_updating = False
|
||||
registrations = get_testcenter_registration(student, course_id, exam_code)
|
||||
@@ -152,29 +153,29 @@ class Command(BaseCommand):
|
||||
if fieldname in our_options and registration.__getattribute__(fieldname) != our_options[fieldname]:
|
||||
needs_updating = True;
|
||||
else:
|
||||
accommodation_request = our_options.get('accommodation_request','')
|
||||
accommodation_request = our_options.get('accommodation_request', '')
|
||||
registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request)
|
||||
needs_updating = True
|
||||
|
||||
|
||||
|
||||
if needs_updating:
|
||||
# first update the record with the new values, if any:
|
||||
for fieldname in UPDATE_FIELDS:
|
||||
if fieldname in our_options and fieldname not in TestCenterRegistrationForm.Meta.fields:
|
||||
if fieldname in our_options and fieldname not in TestCenterRegistrationForm.Meta.fields:
|
||||
registration.__setattr__(fieldname, our_options[fieldname])
|
||||
|
||||
# the registration form normally populates the data dict with
|
||||
|
||||
# the registration form normally populates the data dict with
|
||||
# the accommodation request (if any). But here we want to
|
||||
# specify only those values that might change, so update the dict with existing
|
||||
# values.
|
||||
form_options = dict(our_options)
|
||||
for propname in TestCenterRegistrationForm.Meta.fields:
|
||||
if propname not in form_options:
|
||||
if propname not in form_options:
|
||||
form_options[propname] = registration.__getattribute__(propname)
|
||||
form = TestCenterRegistrationForm(instance=registration, data=form_options)
|
||||
if form.is_valid():
|
||||
form.update_and_save()
|
||||
print "Updated registration information for user's registration: username \"{}\" course \"{}\", examcode \"{}\"".format(student.username, course_id, exam_code)
|
||||
print "Updated registration information for user's registration: username \"{}\" course \"{}\", examcode \"{}\"".format(student.username, course_id, exam_code)
|
||||
else:
|
||||
if (len(form.errors) > 0):
|
||||
print "Field Form errors encountered:"
|
||||
@@ -185,24 +186,22 @@ class Command(BaseCommand):
|
||||
print "Non-field Form errors encountered:"
|
||||
for nonfielderror in form.non_field_errors:
|
||||
print "Non-field Form Error: %s" % nonfielderror
|
||||
|
||||
|
||||
else:
|
||||
print "No changes necessary to make to existing user's registration."
|
||||
|
||||
|
||||
# override internal values:
|
||||
change_internal = False
|
||||
if 'exam_series_code' in our_options:
|
||||
exam_code = our_options['exam_series_code']
|
||||
registration = get_testcenter_registration(student, course_id, exam_code)[0]
|
||||
for internal_field in [ 'upload_error_message', 'upload_status', 'authorization_id']:
|
||||
for internal_field in ['upload_error_message', 'upload_status', 'authorization_id']:
|
||||
if internal_field in our_options:
|
||||
registration.__setattr__(internal_field, our_options[internal_field])
|
||||
change_internal = True
|
||||
|
||||
|
||||
if change_internal:
|
||||
print "Updated confirmation information in existing user's registration."
|
||||
registration.save()
|
||||
else:
|
||||
print "No changes necessary to make to confirmation information in existing user's registration."
|
||||
|
||||
|
||||
|
||||
@@ -5,60 +5,61 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterUser, TestCenterUserForm
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
option_list = BaseCommand.option_list + (
|
||||
# demographics:
|
||||
# demographics:
|
||||
make_option(
|
||||
'--first_name',
|
||||
action='store',
|
||||
dest='first_name',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--middle_name',
|
||||
action='store',
|
||||
dest='middle_name',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--last_name',
|
||||
action='store',
|
||||
dest='last_name',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--suffix',
|
||||
action='store',
|
||||
dest='suffix',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--salutation',
|
||||
action='store',
|
||||
dest='salutation',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--address_1',
|
||||
action='store',
|
||||
dest='address_1',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--address_2',
|
||||
action='store',
|
||||
dest='address_2',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--address_3',
|
||||
action='store',
|
||||
dest='address_3',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--city',
|
||||
action='store',
|
||||
dest='city',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--state',
|
||||
action='store',
|
||||
dest='state',
|
||||
help='Two letter code (e.g. MA)'
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--postal_code',
|
||||
action='store',
|
||||
@@ -75,12 +76,12 @@ class Command(BaseCommand):
|
||||
action='store',
|
||||
dest='phone',
|
||||
help='Pretty free-form (parens, spaces, dashes), but no country code'
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--extension',
|
||||
action='store',
|
||||
dest='extension',
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--phone_country_code',
|
||||
action='store',
|
||||
@@ -92,7 +93,7 @@ class Command(BaseCommand):
|
||||
action='store',
|
||||
dest='fax',
|
||||
help='Pretty free-form (parens, spaces, dashes), but no country code'
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--fax_country_code',
|
||||
action='store',
|
||||
@@ -103,26 +104,26 @@ class Command(BaseCommand):
|
||||
'--company_name',
|
||||
action='store',
|
||||
dest='company_name',
|
||||
),
|
||||
),
|
||||
# internal values:
|
||||
make_option(
|
||||
'--client_candidate_id',
|
||||
action='store',
|
||||
dest='client_candidate_id',
|
||||
help='ID we assign a user to identify them to Pearson'
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--upload_status',
|
||||
action='store',
|
||||
dest='upload_status',
|
||||
help='status value assigned by Pearson'
|
||||
),
|
||||
),
|
||||
make_option(
|
||||
'--upload_error_message',
|
||||
action='store',
|
||||
dest='upload_error_message',
|
||||
help='error message provided by Pearson on a failure.'
|
||||
),
|
||||
),
|
||||
)
|
||||
args = "<student_username>"
|
||||
help = "Create or modify a TestCenterUser entry for a given Student"
|
||||
@@ -142,20 +143,20 @@ class Command(BaseCommand):
|
||||
student = User.objects.get(username=username)
|
||||
try:
|
||||
testcenter_user = TestCenterUser.objects.get(user=student)
|
||||
needs_updating = testcenter_user.needs_update(our_options)
|
||||
needs_updating = testcenter_user.needs_update(our_options)
|
||||
except TestCenterUser.DoesNotExist:
|
||||
# do additional initialization here:
|
||||
testcenter_user = TestCenterUser.create(student)
|
||||
needs_updating = True
|
||||
|
||||
|
||||
if needs_updating:
|
||||
# the registration form normally populates the data dict with
|
||||
# the registration form normally populates the data dict with
|
||||
# all values from the testcenter_user. But here we only want to
|
||||
# specify those values that change, so update the dict with existing
|
||||
# values.
|
||||
form_options = dict(our_options)
|
||||
for propname in TestCenterUser.user_provided_fields():
|
||||
if propname not in form_options:
|
||||
if propname not in form_options:
|
||||
form_options[propname] = testcenter_user.__getattribute__(propname)
|
||||
form = TestCenterUserForm(instance=testcenter_user, data=form_options)
|
||||
if form.is_valid():
|
||||
@@ -170,21 +171,20 @@ class Command(BaseCommand):
|
||||
errorlist.append("Non-field Form errors encountered:")
|
||||
for nonfielderror in form.non_field_errors:
|
||||
errorlist.append("Non-field Form Error: {}".format(nonfielderror))
|
||||
raise CommandError("\n".join(errorlist))
|
||||
raise CommandError("\n".join(errorlist))
|
||||
else:
|
||||
print "No changes necessary to make to existing user's demographics."
|
||||
|
||||
|
||||
# override internal values:
|
||||
change_internal = False
|
||||
testcenter_user = TestCenterUser.objects.get(user=student)
|
||||
for internal_field in [ 'upload_error_message', 'upload_status', 'client_candidate_id']:
|
||||
for internal_field in ['upload_error_message', 'upload_status', 'client_candidate_id']:
|
||||
if internal_field in our_options:
|
||||
testcenter_user.__setattr__(internal_field, our_options[internal_field])
|
||||
change_internal = True
|
||||
|
||||
|
||||
if change_internal:
|
||||
testcenter_user.save()
|
||||
print "Updated confirmation information in existing user's demographics."
|
||||
else:
|
||||
print "No changes necessary to make to confirmation information in existing user's demographics."
|
||||
|
||||
|
||||
@@ -46,10 +46,10 @@ class Command(BaseCommand):
|
||||
if not hasattr(settings, value):
|
||||
raise CommandError('No entry in the AWS settings'
|
||||
'(env/auth.json) for {0}'.format(value))
|
||||
|
||||
|
||||
# check additional required settings for import and export:
|
||||
if options['mode'] in ('export', 'both'):
|
||||
for value in ['LOCAL_EXPORT','SFTP_EXPORT']:
|
||||
for value in ['LOCAL_EXPORT', 'SFTP_EXPORT']:
|
||||
if value not in settings.PEARSON:
|
||||
raise CommandError('No entry in the PEARSON settings'
|
||||
'(env/auth.json) for {0}'.format(value))
|
||||
@@ -57,9 +57,9 @@ class Command(BaseCommand):
|
||||
source_dir = settings.PEARSON['LOCAL_EXPORT']
|
||||
if not os.path.isdir(source_dir):
|
||||
os.makedirs(source_dir)
|
||||
|
||||
|
||||
if options['mode'] in ('import', 'both'):
|
||||
for value in ['LOCAL_IMPORT','SFTP_IMPORT']:
|
||||
for value in ['LOCAL_IMPORT', 'SFTP_IMPORT']:
|
||||
if value not in settings.PEARSON:
|
||||
raise CommandError('No entry in the PEARSON settings'
|
||||
'(env/auth.json) for {0}'.format(value))
|
||||
@@ -76,7 +76,7 @@ class Command(BaseCommand):
|
||||
t.connect(username=settings.PEARSON['SFTP_USERNAME'],
|
||||
password=settings.PEARSON['SFTP_PASSWORD'])
|
||||
sftp = paramiko.SFTPClient.from_transport(t)
|
||||
|
||||
|
||||
if mode == 'export':
|
||||
try:
|
||||
sftp.chdir(files_to)
|
||||
@@ -92,7 +92,7 @@ class Command(BaseCommand):
|
||||
except IOError:
|
||||
raise CommandError('SFTP source path does not exist: {}'.format(files_from))
|
||||
for filename in sftp.listdir('.'):
|
||||
# skip subdirectories
|
||||
# skip subdirectories
|
||||
if not S_ISDIR(sftp.stat(filename).st_mode):
|
||||
sftp.get(filename, files_to + '/' + filename)
|
||||
# delete files from sftp server once they are successfully pulled off:
|
||||
@@ -112,7 +112,7 @@ class Command(BaseCommand):
|
||||
try:
|
||||
for filename in os.listdir(files_from):
|
||||
source_file = os.path.join(files_from, filename)
|
||||
# use mode as name of directory into which to write files
|
||||
# use mode as name of directory into which to write files
|
||||
dest_file = os.path.join(mode, filename)
|
||||
upload_file_to_s3(bucket, source_file, dest_file)
|
||||
if deleteAfterCopy:
|
||||
@@ -135,17 +135,17 @@ class Command(BaseCommand):
|
||||
k.set_contents_from_filename(source_file)
|
||||
|
||||
def export_pearson():
|
||||
options = { 'dest-from-settings' : True }
|
||||
options = {'dest-from-settings': True}
|
||||
call_command('pearson_export_cdd', **options)
|
||||
call_command('pearson_export_ead', **options)
|
||||
mode = 'export'
|
||||
sftp(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['SFTP_EXPORT'], mode, deleteAfterCopy = False)
|
||||
sftp(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['SFTP_EXPORT'], mode, deleteAfterCopy=False)
|
||||
s3(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=True)
|
||||
|
||||
def import_pearson():
|
||||
mode = 'import'
|
||||
try:
|
||||
sftp(settings.PEARSON['SFTP_IMPORT'], settings.PEARSON['LOCAL_IMPORT'], mode, deleteAfterCopy = True)
|
||||
sftp(settings.PEARSON['SFTP_IMPORT'], settings.PEARSON['LOCAL_IMPORT'], mode, deleteAfterCopy=True)
|
||||
s3(settings.PEARSON['LOCAL_IMPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=False)
|
||||
except Exception as e:
|
||||
dog_http_api.event('Pearson Import failure', str(e))
|
||||
|
||||
@@ -17,30 +17,31 @@ from student.models import User, TestCenterRegistration, TestCenterUser, get_tes
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_tc_user(username):
|
||||
user = User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass')
|
||||
options = {
|
||||
'first_name' : 'TestFirst',
|
||||
'last_name' : 'TestLast',
|
||||
'address_1' : 'Test Address',
|
||||
'city' : 'TestCity',
|
||||
'state' : 'Alberta',
|
||||
'postal_code' : 'A0B 1C2',
|
||||
'country' : 'CAN',
|
||||
'phone' : '252-1866',
|
||||
'phone_country_code' : '1',
|
||||
'first_name': 'TestFirst',
|
||||
'last_name': 'TestLast',
|
||||
'address_1': 'Test Address',
|
||||
'city': 'TestCity',
|
||||
'state': 'Alberta',
|
||||
'postal_code': 'A0B 1C2',
|
||||
'country': 'CAN',
|
||||
'phone': '252-1866',
|
||||
'phone_country_code': '1',
|
||||
}
|
||||
call_command('pearson_make_tc_user', username, **options)
|
||||
return TestCenterUser.objects.get(user=user)
|
||||
|
||||
|
||||
def create_tc_registration(username, course_id = 'org1/course1/term1', exam_code = 'exam1', accommodation_code = None):
|
||||
|
||||
options = { 'exam_series_code' : exam_code,
|
||||
'eligibility_appointment_date_first' : '2013-01-01T00:00',
|
||||
'eligibility_appointment_date_last' : '2013-12-31T23:59',
|
||||
'accommodation_code' : accommodation_code,
|
||||
'create_dummy_exam' : True,
|
||||
|
||||
|
||||
def create_tc_registration(username, course_id='org1/course1/term1', exam_code='exam1', accommodation_code=None):
|
||||
|
||||
options = {'exam_series_code': exam_code,
|
||||
'eligibility_appointment_date_first': '2013-01-01T00:00',
|
||||
'eligibility_appointment_date_last': '2013-12-31T23:59',
|
||||
'accommodation_code': accommodation_code,
|
||||
'create_dummy_exam': True,
|
||||
}
|
||||
|
||||
call_command('pearson_make_tc_registration', username, course_id, **options)
|
||||
@@ -48,21 +49,23 @@ def create_tc_registration(username, course_id = 'org1/course1/term1', exam_code
|
||||
registrations = get_testcenter_registration(user, course_id, exam_code)
|
||||
return registrations[0]
|
||||
|
||||
|
||||
def create_multiple_registrations(prefix='test'):
|
||||
username1 = '{}_multiple1'.format(prefix)
|
||||
create_tc_user(username1)
|
||||
create_tc_registration(username1)
|
||||
create_tc_registration(username1, course_id = 'org1/course2/term1')
|
||||
create_tc_registration(username1, exam_code = 'exam2')
|
||||
create_tc_registration(username1, course_id='org1/course2/term1')
|
||||
create_tc_registration(username1, exam_code='exam2')
|
||||
username2 = '{}_multiple2'.format(prefix)
|
||||
create_tc_user(username2)
|
||||
create_tc_registration(username2)
|
||||
username3 = '{}_multiple3'.format(prefix)
|
||||
create_tc_user(username3)
|
||||
create_tc_registration(username3, course_id = 'org1/course2/term1')
|
||||
create_tc_registration(username3, course_id='org1/course2/term1')
|
||||
username4 = '{}_multiple4'.format(prefix)
|
||||
create_tc_user(username4)
|
||||
create_tc_registration(username4, exam_code = 'exam2')
|
||||
create_tc_registration(username4, exam_code='exam2')
|
||||
|
||||
|
||||
def get_command_error_text(*args, **options):
|
||||
stderr_string = None
|
||||
@@ -75,21 +78,22 @@ def get_command_error_text(*args, **options):
|
||||
# But these are actually translated into nice messages,
|
||||
# and sys.exit(1) is then called. For testing, we
|
||||
# want to catch what sys.exit throws, and get the
|
||||
# relevant text either from stdout or stderr.
|
||||
# relevant text either from stdout or stderr.
|
||||
if (why1.message > 0):
|
||||
stderr_string = sys.stderr.getvalue()
|
||||
else:
|
||||
raise why1
|
||||
except Exception, why:
|
||||
raise why
|
||||
|
||||
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
|
||||
if stderr_string is None:
|
||||
raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0]))
|
||||
raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0]))
|
||||
return stderr_string
|
||||
|
||||
|
||||
|
||||
def get_error_string_for_management_call(*args, **options):
|
||||
stdout_string = None
|
||||
old_stdout = sys.stdout
|
||||
@@ -103,7 +107,7 @@ def get_error_string_for_management_call(*args, **options):
|
||||
# But these are actually translated into nice messages,
|
||||
# and sys.exit(1) is then called. For testing, we
|
||||
# want to catch what sys.exit throws, and get the
|
||||
# relevant text either from stdout or stderr.
|
||||
# relevant text either from stdout or stderr.
|
||||
if (why1.message == 1):
|
||||
stdout_string = sys.stdout.getvalue()
|
||||
stderr_string = sys.stderr.getvalue()
|
||||
@@ -111,15 +115,15 @@ def get_error_string_for_management_call(*args, **options):
|
||||
raise why1
|
||||
except Exception, why:
|
||||
raise why
|
||||
|
||||
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
|
||||
if stdout_string is None:
|
||||
raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0]))
|
||||
raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0]))
|
||||
return stdout_string, stderr_string
|
||||
|
||||
|
||||
|
||||
def get_file_info(dirpath):
|
||||
filelist = os.listdir(dirpath)
|
||||
@@ -132,43 +136,45 @@ def get_file_info(dirpath):
|
||||
numlines = len(filecontents)
|
||||
return filepath, numlines
|
||||
else:
|
||||
raise Exception("Expected to find a single file in {}, but found {}".format(dirpath,filelist))
|
||||
|
||||
raise Exception("Expected to find a single file in {}, but found {}".format(dirpath, filelist))
|
||||
|
||||
|
||||
class PearsonTestCase(TestCase):
|
||||
'''
|
||||
Base class for tests running Pearson-related commands
|
||||
'''
|
||||
import_dir = mkdtemp(prefix="import")
|
||||
export_dir = mkdtemp(prefix="export")
|
||||
|
||||
|
||||
def assertErrorContains(self, error_message, expected):
|
||||
self.assertTrue(error_message.find(expected) >= 0, 'error message "{}" did not contain "{}"'.format(error_message, expected))
|
||||
|
||||
|
||||
def tearDown(self):
|
||||
def delete_temp_dir(dirname):
|
||||
if os.path.exists(dirname):
|
||||
for filename in os.listdir(dirname):
|
||||
os.remove(os.path.join(dirname, filename))
|
||||
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)
|
||||
|
||||
|
||||
# and clean up the database:
|
||||
# TestCenterUser.objects.all().delete()
|
||||
# TestCenterRegistration.objects.all().delete()
|
||||
|
||||
|
||||
class PearsonCommandTestCase(PearsonTestCase):
|
||||
|
||||
def test_missing_demographic_fields(self):
|
||||
# We won't bother to test all details of form validation here.
|
||||
# We won't bother to test all details of form validation here.
|
||||
# It is enough to show that it works here, but deal with test cases for the form
|
||||
# validation in the student tests, not these management tests.
|
||||
username = 'baduser'
|
||||
User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass')
|
||||
options = {}
|
||||
error_string = get_command_error_text('pearson_make_tc_user', username, **options)
|
||||
error_string = get_command_error_text('pearson_make_tc_user', username, **options)
|
||||
self.assertTrue(error_string.find('Field Form errors encountered:') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: city') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: first_name') >= 0)
|
||||
@@ -178,11 +184,11 @@ class PearsonCommandTestCase(PearsonTestCase):
|
||||
self.assertTrue(error_string.find('Field Form Error: phone') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: address_1') >= 0)
|
||||
self.assertErrorContains(error_string, 'Field Form Error: address_1')
|
||||
|
||||
|
||||
def test_create_good_testcenter_user(self):
|
||||
testcenter_user = create_tc_user("test_good_user")
|
||||
self.assertIsNotNone(testcenter_user)
|
||||
|
||||
|
||||
def test_create_good_testcenter_registration(self):
|
||||
username = 'test_good_registration'
|
||||
create_tc_user(username)
|
||||
@@ -192,21 +198,21 @@ class PearsonCommandTestCase(PearsonTestCase):
|
||||
def test_cdd_missing_option(self):
|
||||
error_string = get_command_error_text('pearson_export_cdd', **{})
|
||||
self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used')
|
||||
|
||||
|
||||
def test_ead_missing_option(self):
|
||||
error_string = get_command_error_text('pearson_export_ead', **{})
|
||||
self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used')
|
||||
|
||||
def test_export_single_cdd(self):
|
||||
# before we generate any tc_users, we expect there to be nothing to output:
|
||||
options = { 'dest-from-settings' : True }
|
||||
with self.settings(PEARSON={ 'LOCAL_EXPORT' : self.export_dir }):
|
||||
options = {'dest-from-settings': True}
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}):
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 1, "Expect cdd file to have no non-header lines")
|
||||
os.remove(filepath)
|
||||
|
||||
# generating a tc_user should result in a line in the output
|
||||
# generating a tc_user should result in a line in the output
|
||||
username = 'test_single_cdd'
|
||||
create_tc_user(username)
|
||||
call_command('pearson_export_cdd', **options)
|
||||
@@ -221,23 +227,23 @@ class PearsonCommandTestCase(PearsonTestCase):
|
||||
os.remove(filepath)
|
||||
|
||||
# if we modify the record, then it should be output again:
|
||||
user_options = { 'first_name' : 'NewTestFirst', }
|
||||
user_options = {'first_name': 'NewTestFirst', }
|
||||
call_command('pearson_make_tc_user', username, **user_options)
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 2, "Expect cdd file to have one non-header line")
|
||||
os.remove(filepath)
|
||||
|
||||
|
||||
def test_export_single_ead(self):
|
||||
# before we generate any registrations, we expect there to be nothing to output:
|
||||
options = { 'dest-from-settings' : True }
|
||||
with self.settings(PEARSON={ 'LOCAL_EXPORT' : self.export_dir }):
|
||||
options = {'dest-from-settings': True}
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}):
|
||||
call_command('pearson_export_ead', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines")
|
||||
os.remove(filepath)
|
||||
|
||||
# generating a registration should result in a line in the output
|
||||
# generating a registration should result in a line in the output
|
||||
username = 'test_single_ead'
|
||||
create_tc_user(username)
|
||||
create_tc_registration(username)
|
||||
@@ -251,7 +257,7 @@ class PearsonCommandTestCase(PearsonTestCase):
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines")
|
||||
os.remove(filepath)
|
||||
|
||||
|
||||
# if we modify the record, then it should be output again:
|
||||
create_tc_registration(username, accommodation_code='EQPMNT')
|
||||
call_command('pearson_export_ead', **options)
|
||||
@@ -261,8 +267,8 @@ class PearsonCommandTestCase(PearsonTestCase):
|
||||
|
||||
def test_export_multiple(self):
|
||||
create_multiple_registrations("export")
|
||||
with self.settings(PEARSON={ 'LOCAL_EXPORT' : self.export_dir }):
|
||||
options = { 'dest-from-settings' : True }
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}):
|
||||
options = {'dest-from-settings': True}
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 5, "Expect cdd file to have four non-header lines: total was {}".format(numlines))
|
||||
@@ -294,6 +300,7 @@ S3_BUCKET = 'edx-pearson-archive'
|
||||
AWS_ACCESS_KEY_ID = 'put yours here'
|
||||
AWS_SECRET_ACCESS_KEY = 'put yours here'
|
||||
|
||||
|
||||
class PearsonTransferTestCase(PearsonTestCase):
|
||||
'''
|
||||
Class for tests running Pearson transfers
|
||||
@@ -302,14 +309,14 @@ class PearsonTransferTestCase(PearsonTestCase):
|
||||
def test_transfer_config(self):
|
||||
with self.settings(DATADOG_API='FAKE_KEY'):
|
||||
# TODO: why is this failing with the wrong error message?!
|
||||
stderrmsg = get_command_error_text('pearson_transfer', **{'mode' : 'garbage'})
|
||||
stderrmsg = get_command_error_text('pearson_transfer', **{'mode': 'garbage'})
|
||||
self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries')
|
||||
with self.settings(DATADOG_API='FAKE_KEY'):
|
||||
stderrmsg = get_command_error_text('pearson_transfer')
|
||||
self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries')
|
||||
with self.settings(DATADOG_API='FAKE_KEY',
|
||||
PEARSON={'LOCAL_EXPORT' : self.export_dir,
|
||||
'LOCAL_IMPORT' : self.import_dir }):
|
||||
PEARSON={'LOCAL_EXPORT': self.export_dir,
|
||||
'LOCAL_IMPORT': self.import_dir}):
|
||||
stderrmsg = get_command_error_text('pearson_transfer')
|
||||
self.assertErrorContains(stderrmsg, 'Error: No entry in the PEARSON settings')
|
||||
|
||||
@@ -317,16 +324,16 @@ class PearsonTransferTestCase(PearsonTestCase):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations('export_missing_dest')
|
||||
with self.settings(DATADOG_API='FAKE_KEY',
|
||||
PEARSON={'LOCAL_EXPORT' : self.export_dir,
|
||||
'SFTP_EXPORT' : 'this/does/not/exist',
|
||||
'SFTP_HOSTNAME' : SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME' : SFTP_USERNAME,
|
||||
'SFTP_PASSWORD' : SFTP_PASSWORD,
|
||||
'S3_BUCKET' : S3_BUCKET,
|
||||
PEARSON={'LOCAL_EXPORT': self.export_dir,
|
||||
'SFTP_EXPORT': 'this/does/not/exist',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID = AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY = AWS_SECRET_ACCESS_KEY):
|
||||
options = { 'mode' : 'export'}
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'export'}
|
||||
stderrmsg = get_command_error_text('pearson_transfer', **options)
|
||||
self.assertErrorContains(stderrmsg, 'Error: SFTP destination path does not exist')
|
||||
|
||||
@@ -334,16 +341,16 @@ class PearsonTransferTestCase(PearsonTestCase):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations("transfer_export")
|
||||
with self.settings(DATADOG_API='FAKE_KEY',
|
||||
PEARSON={'LOCAL_EXPORT' : self.export_dir,
|
||||
'SFTP_EXPORT' : 'results/topvue',
|
||||
'SFTP_HOSTNAME' : SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME' : SFTP_USERNAME,
|
||||
'SFTP_PASSWORD' : SFTP_PASSWORD,
|
||||
'S3_BUCKET' : S3_BUCKET,
|
||||
PEARSON={'LOCAL_EXPORT': self.export_dir,
|
||||
'SFTP_EXPORT': 'results/topvue',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID = AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY = AWS_SECRET_ACCESS_KEY):
|
||||
options = { 'mode' : 'export'}
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'export'}
|
||||
# call_command('pearson_transfer', **options)
|
||||
# # confirm that the export directory is still empty:
|
||||
# self.assertEqual(len(os.listdir(self.export_dir)), 0, "expected export directory to be empty")
|
||||
@@ -352,16 +359,16 @@ class PearsonTransferTestCase(PearsonTestCase):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations('import_missing_src')
|
||||
with self.settings(DATADOG_API='FAKE_KEY',
|
||||
PEARSON={'LOCAL_IMPORT' : self.import_dir,
|
||||
'SFTP_IMPORT' : 'this/does/not/exist',
|
||||
'SFTP_HOSTNAME' : SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME' : SFTP_USERNAME,
|
||||
'SFTP_PASSWORD' : SFTP_PASSWORD,
|
||||
'S3_BUCKET' : S3_BUCKET,
|
||||
PEARSON={'LOCAL_IMPORT': self.import_dir,
|
||||
'SFTP_IMPORT': 'this/does/not/exist',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID = AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY = AWS_SECRET_ACCESS_KEY):
|
||||
options = { 'mode' : 'import'}
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'import'}
|
||||
stderrmsg = get_command_error_text('pearson_transfer', **options)
|
||||
self.assertErrorContains(stderrmsg, 'Error: SFTP source path does not exist')
|
||||
|
||||
@@ -369,15 +376,15 @@ class PearsonTransferTestCase(PearsonTestCase):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations('import_missing_src')
|
||||
with self.settings(DATADOG_API='FAKE_KEY',
|
||||
PEARSON={'LOCAL_IMPORT' : self.import_dir,
|
||||
'SFTP_IMPORT' : 'results',
|
||||
'SFTP_HOSTNAME' : SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME' : SFTP_USERNAME,
|
||||
'SFTP_PASSWORD' : SFTP_PASSWORD,
|
||||
'S3_BUCKET' : S3_BUCKET,
|
||||
PEARSON={'LOCAL_IMPORT': self.import_dir,
|
||||
'SFTP_IMPORT': 'results',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID = AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY = AWS_SECRET_ACCESS_KEY):
|
||||
options = { 'mode' : 'import'}
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'import'}
|
||||
call_command('pearson_transfer', **options)
|
||||
self.assertEqual(len(os.listdir(self.import_dir)), 0, "expected import directory to be empty")
|
||||
|
||||
@@ -185,4 +185,4 @@ class Migration(SchemaMigration):
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
complete_apps = ['student']
|
||||
|
||||
@@ -36,7 +36,7 @@ class Migration(SchemaMigration):
|
||||
for column in ASKBOT_AUTH_USER_COLUMNS:
|
||||
db.delete_column('auth_user', column)
|
||||
except Exception as ex:
|
||||
print "Couldn't remove askbot because of {0} -- it was probably never here to begin with.".format(ex)
|
||||
print "Couldn't remove askbot because of {0} -- it was probably never here to begin with.".format(ex)
|
||||
|
||||
def backwards(self, orm):
|
||||
raise RuntimeError("Cannot reverse this migration: there's no going back to Askbot.")
|
||||
|
||||
@@ -152,4 +152,4 @@ class Migration(SchemaMigration):
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
complete_apps = ['student']
|
||||
|
||||
@@ -238,4 +238,4 @@ class Migration(SchemaMigration):
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
complete_apps = ['student']
|
||||
|
||||
@@ -169,4 +169,4 @@ class Migration(SchemaMigration):
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
complete_apps = ['student']
|
||||
|
||||
@@ -107,6 +107,7 @@ class UserProfile(models.Model):
|
||||
TEST_CENTER_STATUS_ACCEPTED = "Accepted"
|
||||
TEST_CENTER_STATUS_ERROR = "Error"
|
||||
|
||||
|
||||
class TestCenterUser(models.Model):
|
||||
"""This is our representation of the User for in-person testing, and
|
||||
specifically for Pearson at this point. A few things to note:
|
||||
@@ -190,7 +191,7 @@ class TestCenterUser(models.Model):
|
||||
|
||||
@staticmethod
|
||||
def user_provided_fields():
|
||||
return [ 'first_name', 'middle_name', 'last_name', 'suffix', 'salutation',
|
||||
return ['first_name', 'middle_name', 'last_name', 'suffix', 'salutation',
|
||||
'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country',
|
||||
'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name']
|
||||
|
||||
@@ -208,7 +209,7 @@ class TestCenterUser(models.Model):
|
||||
@staticmethod
|
||||
def _generate_edx_id(prefix):
|
||||
NUM_DIGITS = 12
|
||||
return u"{}{:012}".format(prefix, randint(1, 10**NUM_DIGITS-1))
|
||||
return u"{}{:012}".format(prefix, randint(1, 10 ** NUM_DIGITS - 1))
|
||||
|
||||
@staticmethod
|
||||
def _generate_candidate_id():
|
||||
@@ -237,10 +238,11 @@ class TestCenterUser(models.Model):
|
||||
def is_pending(self):
|
||||
return not self.is_accepted and not self.is_rejected
|
||||
|
||||
|
||||
class TestCenterUserForm(ModelForm):
|
||||
class Meta:
|
||||
model = TestCenterUser
|
||||
fields = ( 'first_name', 'middle_name', 'last_name', 'suffix', 'salutation',
|
||||
fields = ('first_name', 'middle_name', 'last_name', 'suffix', 'salutation',
|
||||
'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country',
|
||||
'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name')
|
||||
|
||||
@@ -313,7 +315,8 @@ ACCOMMODATION_CODES = (
|
||||
('SRSGNR', 'Separate Room and Sign Language Interpreter'),
|
||||
)
|
||||
|
||||
ACCOMMODATION_CODE_DICT = { code : name for (code, name) in ACCOMMODATION_CODES }
|
||||
ACCOMMODATION_CODE_DICT = {code: name for (code, name) in ACCOMMODATION_CODES}
|
||||
|
||||
|
||||
class TestCenterRegistration(models.Model):
|
||||
"""
|
||||
@@ -389,10 +392,10 @@ class TestCenterRegistration(models.Model):
|
||||
elif self.uploaded_at is None:
|
||||
return 'Add'
|
||||
elif self.registration_is_rejected:
|
||||
# Assume that if the registration was rejected before,
|
||||
# it is more likely this is the (first) correction
|
||||
# Assume that if the registration was rejected before,
|
||||
# it is more likely this is the (first) correction
|
||||
# than a second correction in flight before the first was
|
||||
# processed.
|
||||
# processed.
|
||||
return 'Add'
|
||||
else:
|
||||
# TODO: decide what to send when we have uploaded an initial version,
|
||||
@@ -417,7 +420,7 @@ class TestCenterRegistration(models.Model):
|
||||
|
||||
@classmethod
|
||||
def create(cls, testcenter_user, exam, accommodation_request):
|
||||
registration = cls(testcenter_user = testcenter_user)
|
||||
registration = cls(testcenter_user=testcenter_user)
|
||||
registration.course_id = exam.course_id
|
||||
registration.accommodation_request = accommodation_request.strip()
|
||||
registration.exam_series_code = exam.exam_series_code
|
||||
@@ -501,7 +504,7 @@ class TestCenterRegistration(models.Model):
|
||||
return self.accommodation_code.split('*')
|
||||
|
||||
def get_accommodation_names(self):
|
||||
return [ ACCOMMODATION_CODE_DICT.get(code, "Unknown code " + code) for code in self.get_accommodation_codes() ]
|
||||
return [ACCOMMODATION_CODE_DICT.get(code, "Unknown code " + code) for code in self.get_accommodation_codes()]
|
||||
|
||||
@property
|
||||
def registration_signup_url(self):
|
||||
@@ -512,7 +515,7 @@ class TestCenterRegistration(models.Model):
|
||||
return "Accepted"
|
||||
elif self.demographics_is_rejected:
|
||||
return "Rejected"
|
||||
else:
|
||||
else:
|
||||
return "Pending"
|
||||
|
||||
def accommodation_status(self):
|
||||
@@ -522,7 +525,7 @@ class TestCenterRegistration(models.Model):
|
||||
return "Accepted"
|
||||
elif self.accommodation_is_rejected:
|
||||
return "Rejected"
|
||||
else:
|
||||
else:
|
||||
return "Pending"
|
||||
|
||||
def registration_status(self):
|
||||
@@ -532,12 +535,12 @@ class TestCenterRegistration(models.Model):
|
||||
return "Rejected"
|
||||
else:
|
||||
return "Pending"
|
||||
|
||||
|
||||
|
||||
class TestCenterRegistrationForm(ModelForm):
|
||||
class Meta:
|
||||
model = TestCenterRegistration
|
||||
fields = ( 'accommodation_request', 'accommodation_code' )
|
||||
fields = ('accommodation_request', 'accommodation_code')
|
||||
|
||||
def clean_accommodation_request(self):
|
||||
code = self.cleaned_data['accommodation_request']
|
||||
@@ -576,6 +579,7 @@ def get_testcenter_registration(user, course_id, exam_series_code):
|
||||
# Correct this (https://nose.readthedocs.org/en/latest/finding_tests.html)
|
||||
get_testcenter_registration.__test__ = False
|
||||
|
||||
|
||||
def unique_id_for_user(user):
|
||||
"""
|
||||
Return a unique id for a user, suitable for inserting into
|
||||
@@ -666,6 +670,7 @@ class CourseEnrollmentAllowed(models.Model):
|
||||
|
||||
#### Helper methods for use from python manage.py shell and other classes.
|
||||
|
||||
|
||||
def get_user_by_username_or_email(username_or_email):
|
||||
"""
|
||||
Return a User object, looking up by email if username_or_email contains a
|
||||
@@ -767,4 +772,3 @@ def update_user_information(sender, instance, created, **kwargs):
|
||||
log = logging.getLogger("mitx.discussion")
|
||||
log.error(unicode(e))
|
||||
log.error("update user info to discussion failed for user with id: " + str(instance.id))
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ COURSE_2 = 'edx/full/6.002_Spring_2012'
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CourseEndingTest(TestCase):
|
||||
"""Test things related to course endings: certificates, surveys, etc"""
|
||||
|
||||
@@ -40,7 +41,7 @@ class CourseEndingTest(TestCase):
|
||||
{'status': 'processing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False,})
|
||||
'show_survey_button': False, })
|
||||
|
||||
cert_status = {'status': 'unavailable'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import datetime
|
||||
import feedparser
|
||||
#import itertools
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
#import time
|
||||
import urllib
|
||||
import uuid
|
||||
|
||||
@@ -16,17 +14,19 @@ from django.contrib.auth import logout, authenticate, login
|
||||
from django.contrib.auth.forms import PasswordResetForm
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.cache import cache
|
||||
from django.core.context_processors import csrf
|
||||
from django.core.mail import send_mail
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.validators import validate_email, validate_slug, ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.http import HttpResponse, HttpResponseForbidden, Http404
|
||||
from django.http import HttpResponse, HttpResponseRedirect, Http404
|
||||
from django.shortcuts import redirect
|
||||
from django_future.csrf import ensure_csrf_cookie, csrf_exempt
|
||||
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
from bs4 import BeautifulSoup
|
||||
from django.core.cache import cache
|
||||
|
||||
from django_future.csrf import ensure_csrf_cookie, csrf_exempt
|
||||
from student.models import (Registration, UserProfile, TestCenterUser, TestCenterUserForm,
|
||||
TestCenterRegistration, TestCenterRegistrationForm,
|
||||
PendingNameChange, PendingEmailChange,
|
||||
@@ -38,18 +38,22 @@ from certificates.models import CertificateStatuses, certificate_status_for_stud
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
#from datetime import date
|
||||
from collections import namedtuple
|
||||
|
||||
from courseware.courses import get_courses, sort_by_announcement
|
||||
from courseware.access import has_access
|
||||
from courseware.models import StudentModuleCache
|
||||
from courseware.views import get_module_for_descriptor, jump_to
|
||||
from courseware.module_render import get_instance_module
|
||||
|
||||
from statsd import statsd
|
||||
|
||||
log = logging.getLogger("mitx.student")
|
||||
Article = namedtuple('Article', 'title url author image deck publication publish_date')
|
||||
|
||||
|
||||
def csrf_token(context):
|
||||
''' A csrf token that can be included in a form.
|
||||
'''
|
||||
@@ -73,8 +77,8 @@ def index(request, extra_context={}, user=None):
|
||||
'''
|
||||
|
||||
# The course selection work is done in courseware.courses.
|
||||
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
|
||||
if domain==False: # do explicit check, because domain=None is valid
|
||||
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
|
||||
if domain == False: # do explicit check, because domain=None is valid
|
||||
domain = request.META.get('HTTP_HOST')
|
||||
|
||||
courses = get_courses(None, domain=domain)
|
||||
@@ -97,6 +101,7 @@ import re
|
||||
day_pattern = re.compile('\s\d+,\s')
|
||||
multimonth_pattern = re.compile('\s?\-\s?\S+\s')
|
||||
|
||||
|
||||
def get_date_for_press(publish_date):
|
||||
import datetime
|
||||
# strip off extra months, and just use the first:
|
||||
@@ -107,6 +112,7 @@ def get_date_for_press(publish_date):
|
||||
date = datetime.datetime.strptime(date, "%B, %Y")
|
||||
return date
|
||||
|
||||
|
||||
def press(request):
|
||||
json_articles = cache.get("student_press_json_articles")
|
||||
if json_articles == None:
|
||||
@@ -148,6 +154,7 @@ def cert_info(user, course):
|
||||
|
||||
return _cert_info(user, course, certificate_status_for_student(user, course.id))
|
||||
|
||||
|
||||
def _cert_info(user, course, cert_status):
|
||||
"""
|
||||
Implements the logic for cert_info -- split out for testing.
|
||||
@@ -175,7 +182,7 @@ def _cert_info(user, course, cert_status):
|
||||
|
||||
d = {'status': status,
|
||||
'show_download_url': status == 'ready',
|
||||
'show_disabled_download_button': status == 'generating',}
|
||||
'show_disabled_download_button': status == 'generating', }
|
||||
|
||||
if (status in ('generating', 'ready', 'notpassing', 'restricted') and
|
||||
course.end_of_course_survey_url is not None):
|
||||
@@ -204,6 +211,7 @@ def _cert_info(user, course, cert_status):
|
||||
|
||||
return d
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def dashboard(request):
|
||||
@@ -237,9 +245,9 @@ def dashboard(request):
|
||||
show_courseware_links_for = frozenset(course.id for course in courses
|
||||
if has_access(request.user, course, 'load'))
|
||||
|
||||
cert_statuses = { course.id: cert_info(request.user, course) for course in courses}
|
||||
cert_statuses = {course.id: cert_info(request.user, course) for course in courses}
|
||||
|
||||
exam_registrations = { course.id: exam_registration_info(request.user, course) for course in courses}
|
||||
exam_registrations = {course.id: exam_registration_info(request.user, course) for course in courses}
|
||||
|
||||
# Get the 3 most recent news
|
||||
top_news = _get_news(top=3)
|
||||
@@ -248,7 +256,7 @@ def dashboard(request):
|
||||
'message': message,
|
||||
'staff_access': staff_access,
|
||||
'errored_courses': errored_courses,
|
||||
'show_courseware_links_for' : show_courseware_links_for,
|
||||
'show_courseware_links_for': show_courseware_links_for,
|
||||
'cert_statuses': cert_statuses,
|
||||
'news': top_news,
|
||||
'exam_registrations': exam_registrations,
|
||||
@@ -312,7 +320,7 @@ def change_enrollment(request):
|
||||
'error': 'enrollment in {} not allowed at this time'
|
||||
.format(course.display_name)}
|
||||
|
||||
org, course_num, run=course_id.split("/")
|
||||
org, course_num, run = course_id.split("/")
|
||||
statsd.increment("common.student.enrollment",
|
||||
tags=["org:{0}".format(org),
|
||||
"course:{0}".format(course_num),
|
||||
@@ -326,7 +334,7 @@ def change_enrollment(request):
|
||||
enrollment = CourseEnrollment.objects.get(user=user, course_id=course_id)
|
||||
enrollment.delete()
|
||||
|
||||
org, course_num, run=course_id.split("/")
|
||||
org, course_num, run = course_id.split("/")
|
||||
statsd.increment("common.student.unenrollment",
|
||||
tags=["org:{0}".format(org),
|
||||
"course:{0}".format(course_num),
|
||||
@@ -345,7 +353,7 @@ def change_enrollment(request):
|
||||
def accounts_login(request, error=""):
|
||||
|
||||
|
||||
return render_to_response('accounts_login.html', { 'error': error })
|
||||
return render_to_response('accounts_login.html', {'error': error})
|
||||
|
||||
|
||||
|
||||
@@ -424,6 +432,7 @@ def change_setting(request):
|
||||
return HttpResponse(json.dumps({'success': True,
|
||||
'location': up.location, }))
|
||||
|
||||
|
||||
def _do_create_account(post_vars):
|
||||
"""
|
||||
Given cleaned post variables, create the User and UserProfile objects, as well as the
|
||||
@@ -551,7 +560,7 @@ def create_account(request, post_override=None):
|
||||
|
||||
# Ok, looks like everything is legit. Create the account.
|
||||
ret = _do_create_account(post_vars)
|
||||
if isinstance(ret,HttpResponse): # if there was an error then return that
|
||||
if isinstance(ret, HttpResponse): # if there was an error then return that
|
||||
return ret
|
||||
(user, profile, registration) = ret
|
||||
|
||||
@@ -591,7 +600,7 @@ def create_account(request, post_override=None):
|
||||
eamap.user = login_user
|
||||
eamap.dtsignup = datetime.datetime.now()
|
||||
eamap.save()
|
||||
log.debug('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'],eamap))
|
||||
log.debug('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap))
|
||||
|
||||
if settings.MITX_FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
|
||||
log.debug('bypassing activation email')
|
||||
@@ -603,6 +612,7 @@ def create_account(request, post_override=None):
|
||||
js = {'success': True}
|
||||
return HttpResponse(json.dumps(js), mimetype="application/json")
|
||||
|
||||
|
||||
def exam_registration_info(user, course):
|
||||
""" Returns a Registration object if the user is currently registered for a current
|
||||
exam of the course. Returns None if the user is not registered, or if there is no
|
||||
@@ -620,6 +630,7 @@ def exam_registration_info(user, course):
|
||||
registration = None
|
||||
return registration
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def begin_exam_registration(request, course_id):
|
||||
@@ -663,6 +674,7 @@ def begin_exam_registration(request, course_id):
|
||||
|
||||
return render_to_response('test_center_register.html', context)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def create_exam_registration(request, post_override=None):
|
||||
'''
|
||||
@@ -725,7 +737,7 @@ def create_exam_registration(request, post_override=None):
|
||||
# this registration screen.
|
||||
|
||||
else:
|
||||
accommodation_request = post_vars.get('accommodation_request','')
|
||||
accommodation_request = post_vars.get('accommodation_request', '')
|
||||
registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request)
|
||||
needs_saving = True
|
||||
log.info("User {0} enrolled in course {1} creating new exam registration".format(user.username, course_id))
|
||||
@@ -834,16 +846,17 @@ def password_reset(request):
|
||||
|
||||
form = PasswordResetForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save(use_https = request.is_secure(),
|
||||
from_email = settings.DEFAULT_FROM_EMAIL,
|
||||
request = request,
|
||||
domain_override = request.get_host())
|
||||
return HttpResponse(json.dumps({'success':True,
|
||||
form.save(use_https=request.is_secure(),
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
request=request,
|
||||
domain_override=request.get_host())
|
||||
return HttpResponse(json.dumps({'success': True,
|
||||
'value': render_to_string('registration/password_reset_done.html', {})}))
|
||||
else:
|
||||
return HttpResponse(json.dumps({'success': False,
|
||||
'error': 'Invalid e-mail'}))
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def reactivation_email(request):
|
||||
''' Send an e-mail to reactivate a deactivated account, or to
|
||||
@@ -856,6 +869,7 @@ def reactivation_email(request):
|
||||
'error': 'No inactive user with this e-mail exists'}))
|
||||
return reactivation_email_for_user(user)
|
||||
|
||||
|
||||
def reactivation_email_for_user(user):
|
||||
reg = Registration.objects.get(user=user)
|
||||
|
||||
@@ -996,11 +1010,11 @@ def pending_name_changes(request):
|
||||
|
||||
changes = list(PendingNameChange.objects.all())
|
||||
js = {'students': [{'new_name': c.new_name,
|
||||
'rationale':c.rationale,
|
||||
'old_name':UserProfile.objects.get(user=c.user).name,
|
||||
'email':c.user.email,
|
||||
'uid':c.user.id,
|
||||
'cid':c.id} for c in changes]}
|
||||
'rationale': c.rationale,
|
||||
'old_name': UserProfile.objects.get(user=c.user).name,
|
||||
'email': c.user.email,
|
||||
'uid': c.user.id,
|
||||
'cid': c.id} for c in changes]}
|
||||
return render_to_response('name_changes.html', js)
|
||||
|
||||
|
||||
@@ -1055,25 +1069,134 @@ def accept_name_change(request):
|
||||
|
||||
return accept_name_change_by_id(int(request.POST['id']))
|
||||
|
||||
# TODO: This is a giant kludge to give Pearson something to test against ASAP.
|
||||
# Will need to get replaced by something that actually ties into TestCenterUser
|
||||
@csrf_exempt
|
||||
def test_center_login(request):
|
||||
if not settings.MITX_FEATURES.get('ENABLE_PEARSON_HACK_TEST'):
|
||||
raise Http404
|
||||
|
||||
client_candidate_id = request.POST.get("clientCandidateID")
|
||||
# registration_id = request.POST.get("registrationID")
|
||||
exit_url = request.POST.get("exitURL")
|
||||
# errors are returned by navigating to the error_url, adding a query parameter named "code"
|
||||
# which contains the error code describing the exceptional condition.
|
||||
def makeErrorURL(error_url, error_code):
|
||||
log.error("generating error URL with error code {}".format(error_code))
|
||||
return "{}?code={}".format(error_url, error_code);
|
||||
|
||||
# get provided error URL, which will be used as a known prefix for returning error messages to the
|
||||
# Pearson shell.
|
||||
error_url = request.POST.get("errorURL")
|
||||
|
||||
# TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson
|
||||
# with the code we calculate for the same parameters.
|
||||
if 'code' not in request.POST:
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "missingSecurityCode"));
|
||||
code = request.POST.get("code")
|
||||
|
||||
# calculate SHA for query string
|
||||
# TODO: figure out how to get the original query string, so we can hash it and compare.
|
||||
|
||||
|
||||
if 'clientCandidateID' not in request.POST:
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID"));
|
||||
client_candidate_id = request.POST.get("clientCandidateID")
|
||||
|
||||
# TODO: check remaining parameters, and maybe at least log if they're not matching
|
||||
# expected values....
|
||||
# registration_id = request.POST.get("registrationID")
|
||||
# exit_url = request.POST.get("exitURL")
|
||||
|
||||
# find testcenter_user that matches the provided ID:
|
||||
try:
|
||||
testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id)
|
||||
except TestCenterUser.DoesNotExist:
|
||||
log.error("not able to find demographics for cand ID {}".format(client_candidate_id))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID"));
|
||||
|
||||
# find testcenter_registration that matches the provided exam code:
|
||||
# Note that we could rely in future on either the registrationId or the exam code,
|
||||
# or possibly both. But for now we know what to do with an ExamSeriesCode,
|
||||
# while we currently have no record of RegistrationID values at all.
|
||||
if 'vueExamSeriesCode' not in request.POST:
|
||||
# we are not allowed to make up a new error code, according to Pearson,
|
||||
# so instead of "missingExamSeriesCode", we use a valid one that is
|
||||
# inaccurate but at least distinct. (Sigh.)
|
||||
log.error("missing exam series code for cand ID {}".format(client_candidate_id))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID"));
|
||||
exam_series_code = request.POST.get('vueExamSeriesCode')
|
||||
# special case for supporting test user:
|
||||
if client_candidate_id == "edX003671291147" and exam_series_code != '6002x001':
|
||||
log.warning("test user {} using unexpected exam code {}, coercing to 6002x001".format(client_candidate_id, exam_series_code))
|
||||
exam_series_code = '6002x001'
|
||||
|
||||
registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code)
|
||||
if not registrations:
|
||||
log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned"));
|
||||
|
||||
# TODO: figure out what to do if there are more than one registrations....
|
||||
# for now, just take the first...
|
||||
registration = registrations[0]
|
||||
|
||||
course_id = registration.course_id
|
||||
course = course_from_id(course_id) # assume it will be found....
|
||||
if not course:
|
||||
log.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"));
|
||||
exam = course.get_test_center_exam(exam_series_code)
|
||||
if not exam:
|
||||
log.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"));
|
||||
location = exam.exam_url
|
||||
log.info("proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location))
|
||||
|
||||
# check if the test has already been taken
|
||||
timelimit_descriptor = modulestore().get_instance(course_id, Location(location))
|
||||
if not timelimit_descriptor:
|
||||
log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"));
|
||||
|
||||
timelimit_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, testcenteruser.user,
|
||||
timelimit_descriptor, depth=None)
|
||||
timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor,
|
||||
timelimit_module_cache, course_id, position=None)
|
||||
if not timelimit_module.category == 'timelimit':
|
||||
log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"));
|
||||
|
||||
if timelimit_module and timelimit_module.has_ended:
|
||||
log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken"));
|
||||
|
||||
# check if we need to provide an accommodation:
|
||||
time_accommodation_mapping = {'ET12ET' : 'ADDHALFTIME',
|
||||
'ET30MN' : 'ADD30MIN',
|
||||
'ETDBTM' : 'ADDDOUBLE', }
|
||||
|
||||
time_accommodation_code = None
|
||||
for code in registration.get_accommodation_codes():
|
||||
if code in time_accommodation_mapping:
|
||||
time_accommodation_code = time_accommodation_mapping[code]
|
||||
# special, hard-coded client ID used by Pearson shell for testing:
|
||||
if client_candidate_id == "edX003671291147":
|
||||
user = authenticate(username=settings.PEARSON_TEST_USER,
|
||||
password=settings.PEARSON_TEST_PASSWORD)
|
||||
login(request, user)
|
||||
return redirect('/courses/MITx/6.002x/2012_Fall/courseware/Final_Exam/Final_Exam_Fall_2012/')
|
||||
else:
|
||||
return HttpResponseForbidden()
|
||||
time_accommodation_code = 'TESTING'
|
||||
|
||||
if time_accommodation_code:
|
||||
timelimit_module.accommodation_code = time_accommodation_code
|
||||
instance_module = get_instance_module(course_id, testcenteruser.user, timelimit_module, timelimit_module_cache)
|
||||
instance_module.state = timelimit_module.get_instance_state()
|
||||
instance_module.save()
|
||||
log.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code))
|
||||
|
||||
# UGLY HACK!!!
|
||||
# Login assumes that authentication has occurred, and that there is a
|
||||
# backend annotation on the user object, indicating which backend
|
||||
# against which the user was authenticated. We're authenticating here
|
||||
# against the registration entry, and assuming that the request given
|
||||
# this information is correct, we allow the user to be logged in
|
||||
# without a password. This could all be formalized in a backend object
|
||||
# that does the above checking.
|
||||
# TODO: (brian) create a backend class to do this.
|
||||
# testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__)
|
||||
testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass")
|
||||
login(request, testcenteruser.user)
|
||||
|
||||
# And start the test:
|
||||
return jump_to(request, course_id, location)
|
||||
|
||||
|
||||
def _get_news(top=None):
|
||||
|
||||
@@ -45,4 +45,4 @@ class Migration(SchemaMigration):
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['track']
|
||||
complete_apps = ['track']
|
||||
|
||||
@@ -48,4 +48,4 @@ class Migration(SchemaMigration):
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['track']
|
||||
complete_apps = ['track']
|
||||
|
||||
@@ -2,21 +2,20 @@ from django.db import models
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class TrackingLog(models.Model):
|
||||
dtcreated = models.DateTimeField('creation date',auto_now_add=True)
|
||||
username = models.CharField(max_length=32,blank=True)
|
||||
ip = models.CharField(max_length=32,blank=True)
|
||||
dtcreated = models.DateTimeField('creation date', auto_now_add=True)
|
||||
username = models.CharField(max_length=32, blank=True)
|
||||
ip = models.CharField(max_length=32, blank=True)
|
||||
event_source = models.CharField(max_length=32)
|
||||
event_type = models.CharField(max_length=512,blank=True)
|
||||
event_type = models.CharField(max_length=512, blank=True)
|
||||
event = models.TextField(blank=True)
|
||||
agent = models.CharField(max_length=256,blank=True)
|
||||
page = models.CharField(max_length=512,blank=True,null=True)
|
||||
agent = models.CharField(max_length=256, blank=True)
|
||||
page = models.CharField(max_length=512, blank=True, null=True)
|
||||
time = models.DateTimeField('event time')
|
||||
host = models.CharField(max_length=64,blank=True)
|
||||
host = models.CharField(max_length=64, blank=True)
|
||||
|
||||
def __unicode__(self):
|
||||
s = "[%s] %s@%s: %s | %s | %s | %s" % (self.time, self.username, self.ip, self.event_source,
|
||||
self.event_type, self.page, self.event)
|
||||
return s
|
||||
|
||||
|
||||
|
||||
@@ -17,19 +17,21 @@ from track.models import TrackingLog
|
||||
|
||||
log = logging.getLogger("tracking")
|
||||
|
||||
LOGFIELDS = ['username','ip','event_source','event_type','event','agent','page','time','host']
|
||||
LOGFIELDS = ['username', 'ip', 'event_source', 'event_type', 'event', 'agent', 'page', 'time', 'host']
|
||||
|
||||
|
||||
def log_event(event):
|
||||
event_str = json.dumps(event)
|
||||
log.info(event_str[:settings.TRACK_MAX_EVENT])
|
||||
if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'):
|
||||
event['time'] = dateutil.parser.parse(event['time'])
|
||||
tldat = TrackingLog(**dict( (x,event[x]) for x in LOGFIELDS ))
|
||||
tldat = TrackingLog(**dict((x, event[x]) for x in LOGFIELDS))
|
||||
try:
|
||||
tldat.save()
|
||||
except Exception as err:
|
||||
log.exception(err)
|
||||
|
||||
|
||||
def user_track(request):
|
||||
try: # TODO: Do the same for many of the optional META parameters
|
||||
username = request.user.username
|
||||
@@ -87,13 +89,14 @@ def server_track(request, event_type, event, page=None):
|
||||
"host": request.META['SERVER_NAME'],
|
||||
}
|
||||
|
||||
if event_type.startswith("/event_logs") and request.user.is_staff: # don't log
|
||||
if event_type.startswith("/event_logs") and request.user.is_staff: # don't log
|
||||
return
|
||||
log_event(event)
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def view_tracking_log(request,args=''):
|
||||
def view_tracking_log(request, args=''):
|
||||
if not request.user.is_staff:
|
||||
return redirect('/')
|
||||
nlen = 100
|
||||
@@ -104,16 +107,15 @@ def view_tracking_log(request,args=''):
|
||||
nlen = int(arg)
|
||||
if arg.startswith('username='):
|
||||
username = arg[9:]
|
||||
|
||||
|
||||
record_instances = TrackingLog.objects.all().order_by('-time')
|
||||
if username:
|
||||
record_instances = record_instances.filter(username=username)
|
||||
record_instances = record_instances[0:nlen]
|
||||
|
||||
|
||||
# fix dtstamp
|
||||
fmt = '%a %d-%b-%y %H:%M:%S' # "%Y-%m-%d %H:%M:%S %Z%z"
|
||||
for rinst in record_instances:
|
||||
rinst.dtstr = rinst.time.replace(tzinfo=pytz.utc).astimezone(pytz.timezone('US/Eastern')).strftime(fmt)
|
||||
|
||||
return render_to_response('tracking_log.html',{'records':record_instances})
|
||||
|
||||
return render_to_response('tracking_log.html', {'records': record_instances})
|
||||
|
||||
@@ -58,4 +58,3 @@ def cache_if_anonymous(view_func):
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _decorated
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import time, datetime
|
||||
import time
|
||||
import datetime
|
||||
import re
|
||||
import calendar
|
||||
|
||||
|
||||
def time_to_date(time_obj):
|
||||
"""
|
||||
Convert a time.time_struct to a true universal time (can pass to js Date constructor)
|
||||
@@ -9,16 +11,20 @@ def time_to_date(time_obj):
|
||||
# TODO change to using the isoformat() function on datetime. js date can parse those
|
||||
return calendar.timegm(time_obj) * 1000
|
||||
|
||||
|
||||
def jsdate_to_time(field):
|
||||
"""
|
||||
Convert a universal time (iso format) or msec since epoch to a time obj
|
||||
"""
|
||||
if field is None:
|
||||
return field
|
||||
elif isinstance(field, basestring): # iso format but ignores time zone assuming it's Z
|
||||
d=datetime.datetime(*map(int, re.split('[^\d]', field)[:6])) # stop after seconds. Debatable
|
||||
elif isinstance(field, basestring):
|
||||
# ISO format but ignores time zone assuming it's Z.
|
||||
d = datetime.datetime(*map(int, re.split('[^\d]', field)[:6])) # stop after seconds. Debatable
|
||||
return d.utctimetuple()
|
||||
elif isinstance(field, int) or isinstance(field, float):
|
||||
elif isinstance(field, (int, long, float)):
|
||||
return time.gmtime(field / 1000)
|
||||
elif isinstance(field, time.struct_time):
|
||||
return field
|
||||
return field
|
||||
else:
|
||||
raise ValueError("Couldn't convert %r to time" % field)
|
||||
|
||||
@@ -13,7 +13,7 @@ def expect_json(view_function):
|
||||
def expect_json_with_cloned_request(request, *args, **kwargs):
|
||||
# cdodge: fix postback errors in CMS. The POST 'content-type' header can include additional information
|
||||
# e.g. 'charset', so we can't do a direct string compare
|
||||
if request.META.get('CONTENT_TYPE','').lower().startswith("application/json"):
|
||||
if request.META.get('CONTENT_TYPE', '').lower().startswith("application/json"):
|
||||
cloned_request = copy.copy(request)
|
||||
cloned_request.POST = cloned_request.POST.copy()
|
||||
cloned_request.POST.update(json.loads(request.body))
|
||||
|
||||
@@ -93,6 +93,7 @@ def accepts(request, media_type):
|
||||
accept = parse_accept_header(request.META.get("HTTP_ACCEPT", ""))
|
||||
return media_type in [t for (t, p, q) in accept]
|
||||
|
||||
|
||||
def debug_request(request):
|
||||
"""Return a pretty printed version of the request"""
|
||||
|
||||
|
||||
@@ -2,16 +2,17 @@ import re
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import static_replace
|
||||
|
||||
from django.conf import settings
|
||||
from functools import wraps
|
||||
from static_replace import replace_urls
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
from xmodule.seq_module import SequenceModule
|
||||
from xmodule.vertical_module import VerticalModule
|
||||
|
||||
log = logging.getLogger("mitx.xmodule_modifiers")
|
||||
|
||||
|
||||
def wrap_xmodule(get_html, module, template, context=None):
|
||||
"""
|
||||
Wraps the results of get_html in a standard <section> with identifying
|
||||
@@ -32,7 +33,7 @@ def wrap_xmodule(get_html, module, template, context=None):
|
||||
def _get_html():
|
||||
context.update({
|
||||
'content': get_html(),
|
||||
'display_name' : module.metadata.get('display_name') if module.metadata is not None else None,
|
||||
'display_name': module.metadata.get('display_name') if module.metadata is not None else None,
|
||||
'class_': module.__class__.__name__,
|
||||
'module_name': module.js_module_name
|
||||
})
|
||||
@@ -49,10 +50,11 @@ def replace_course_urls(get_html, course_id):
|
||||
"""
|
||||
@wraps(get_html)
|
||||
def _get_html():
|
||||
return replace_urls(get_html(), staticfiles_prefix='/courses/'+course_id, replace_prefix='/course/')
|
||||
return static_replace.replace_course_urls(get_html(), course_id)
|
||||
return _get_html
|
||||
|
||||
def replace_static_urls(get_html, prefix, course_namespace=None):
|
||||
|
||||
def replace_static_urls(get_html, data_dir, course_namespace=None):
|
||||
"""
|
||||
Updates the supplied module with a new get_html function that wraps
|
||||
the old get_html function and substitutes urls of the form /static/...
|
||||
@@ -61,7 +63,7 @@ def replace_static_urls(get_html, prefix, course_namespace=None):
|
||||
|
||||
@wraps(get_html)
|
||||
def _get_html():
|
||||
return replace_urls(get_html(), staticfiles_prefix=prefix, course_namespace = course_namespace)
|
||||
return static_replace.replace_static_urls(get_html(), data_dir, course_namespace)
|
||||
return _get_html
|
||||
|
||||
|
||||
@@ -99,7 +101,7 @@ def add_histogram(get_html, module, user):
|
||||
@wraps(get_html)
|
||||
def _get_html():
|
||||
|
||||
if type(module) in [SequenceModule, VerticalModule]: # TODO: make this more general, eg use an XModule attribute instead
|
||||
if type(module) in [SequenceModule, VerticalModule]: # TODO: make this more general, eg use an XModule attribute instead
|
||||
return get_html()
|
||||
|
||||
module_id = module.id
|
||||
@@ -115,35 +117,35 @@ def add_histogram(get_html, module, user):
|
||||
# doesn't like symlinks)
|
||||
filepath = filename
|
||||
data_dir = osfs.root_path.rsplit('/')[-1]
|
||||
giturl = module.metadata.get('giturl','https://github.com/MITx')
|
||||
edit_link = "%s/%s/tree/master/%s" % (giturl,data_dir,filepath)
|
||||
giturl = module.metadata.get('giturl', 'https://github.com/MITx')
|
||||
edit_link = "%s/%s/tree/master/%s" % (giturl, data_dir, filepath)
|
||||
else:
|
||||
edit_link = False
|
||||
# Need to define all the variables that are about to be used
|
||||
giturl = ""
|
||||
data_dir = ""
|
||||
source_file = module.metadata.get('source_file','') # source used to generate the problem XML, eg latex or word
|
||||
source_file = module.metadata.get('source_file', '') # source used to generate the problem XML, eg latex or word
|
||||
|
||||
# useful to indicate to staff if problem has been released or not
|
||||
# TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here
|
||||
now = time.gmtime()
|
||||
is_released = "unknown"
|
||||
mstart = getattr(module.descriptor,'start')
|
||||
mstart = getattr(module.descriptor, 'start')
|
||||
if mstart is not None:
|
||||
is_released = "<font color='red'>Yes!</font>" if (now > mstart) else "<font color='green'>Not yet</font>"
|
||||
|
||||
staff_context = {'definition': module.definition.get('data'),
|
||||
'metadata': json.dumps(module.metadata, indent=4),
|
||||
'location': module.location,
|
||||
'xqa_key': module.metadata.get('xqa_key',''),
|
||||
'source_file' : source_file,
|
||||
'source_url': '%s/%s/tree/master/%s' % (giturl,data_dir,source_file),
|
||||
'xqa_key': module.metadata.get('xqa_key', ''),
|
||||
'source_file': source_file,
|
||||
'source_url': '%s/%s/tree/master/%s' % (giturl, data_dir, source_file),
|
||||
'category': str(module.__class__.__name__),
|
||||
# Template uses element_id in js function names, so can't allow dashes
|
||||
'element_id': module.location.html_id().replace('-','_'),
|
||||
'element_id': module.location.html_id().replace('-', '_'),
|
||||
'edit_link': edit_link,
|
||||
'user': user,
|
||||
'xqa_server' : settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu/xqa'),
|
||||
'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa'),
|
||||
'histogram': json.dumps(histogram),
|
||||
'render_histogram': render_histogram,
|
||||
'module_content': get_html(),
|
||||
@@ -152,4 +154,3 @@ def add_histogram(get_html, module, user):
|
||||
return render_to_string("staff_problem_info.html", staff_context)
|
||||
|
||||
return _get_html
|
||||
|
||||
|
||||
@@ -121,9 +121,9 @@ def evaluator(variables, functions, string, cs=False):
|
||||
# confusing. They may also conflict with variables if we ever allow e.g.
|
||||
# 5R instead of 5*R
|
||||
suffixes = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9,
|
||||
'T': 1e12,# 'P':1e15,'E':1e18,'Z':1e21,'Y':1e24,
|
||||
'T': 1e12, # 'P':1e15,'E':1e18,'Z':1e21,'Y':1e24,
|
||||
'c': 1e-2, 'm': 1e-3, 'u': 1e-6,
|
||||
'n': 1e-9, 'p': 1e-12}# ,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24}
|
||||
'n': 1e-9, 'p': 1e-12} # ,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24}
|
||||
|
||||
def super_float(text):
|
||||
''' Like float, but with si extensions. 1k goes to 1000'''
|
||||
|
||||
@@ -75,7 +75,7 @@ global_context = {'random': random,
|
||||
'draganddrop': verifiers.draganddrop}
|
||||
|
||||
# These should be removed from HTML output, including all subelements
|
||||
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam","openendedrubric"]
|
||||
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam", "openendedrubric"]
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
@@ -453,7 +453,7 @@ class LoncapaProblem(object):
|
||||
exec code in context, context
|
||||
except Exception as err:
|
||||
log.exception("Error while execing script code: " + code)
|
||||
msg = "Error while executing script code: %s" % str(err).replace('<','<')
|
||||
msg = "Error while executing script code: %s" % str(err).replace('<', '<')
|
||||
raise responsetypes.LoncapaProblemError(msg)
|
||||
finally:
|
||||
sys.path = original_path
|
||||
@@ -502,7 +502,7 @@ class LoncapaProblem(object):
|
||||
'id': problemtree.get('id'),
|
||||
'feedback': {'message': msg,
|
||||
'hint': hint,
|
||||
'hintmode': hintmode,}}
|
||||
'hintmode': hintmode, }}
|
||||
|
||||
input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag)
|
||||
the_input = input_type_cls(self.system, problemtree, state)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
@@ -17,17 +17,17 @@ from nltk.tree import Tree
|
||||
ARROWS = ('<->', '->')
|
||||
|
||||
## Defines a simple pyparsing tokenizer for chemical equations
|
||||
elements = ['Ac','Ag','Al','Am','Ar','As','At','Au','B','Ba','Be',
|
||||
'Bh','Bi','Bk','Br','C','Ca','Cd','Ce','Cf','Cl','Cm',
|
||||
'Cn','Co','Cr','Cs','Cu','Db','Ds','Dy','Er','Es','Eu',
|
||||
'F','Fe','Fl','Fm','Fr','Ga','Gd','Ge','H','He','Hf',
|
||||
'Hg','Ho','Hs','I','In','Ir','K','Kr','La','Li','Lr',
|
||||
'Lu','Lv','Md','Mg','Mn','Mo','Mt','N','Na','Nb','Nd',
|
||||
'Ne','Ni','No','Np','O','Os','P','Pa','Pb','Pd','Pm',
|
||||
'Po','Pr','Pt','Pu','Ra','Rb','Re','Rf','Rg','Rh','Rn',
|
||||
'Ru','S','Sb','Sc','Se','Sg','Si','Sm','Sn','Sr','Ta',
|
||||
'Tb','Tc','Te','Th','Ti','Tl','Tm','U','Uuo','Uup',
|
||||
'Uus','Uut','V','W','Xe','Y','Yb','Zn','Zr']
|
||||
elements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be',
|
||||
'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm',
|
||||
'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu',
|
||||
'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf',
|
||||
'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr',
|
||||
'Lu', 'Lv', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd',
|
||||
'Ne', 'Ni', 'No', 'Np', 'O', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm',
|
||||
'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn',
|
||||
'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta',
|
||||
'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'U', 'Uuo', 'Uup',
|
||||
'Uus', 'Uut', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr']
|
||||
digits = map(str, range(10))
|
||||
symbols = list("[](){}^+-/")
|
||||
phases = ["(s)", "(l)", "(g)", "(aq)"]
|
||||
@@ -252,7 +252,7 @@ def _get_final_tree(s):
|
||||
'''
|
||||
tokenized = tokenizer.parseString(s)
|
||||
parsed = parser.parse(tokenized)
|
||||
merged = _merge_children(parsed, {'S','group'})
|
||||
merged = _merge_children(parsed, {'S', 'group'})
|
||||
final = _clean_parse_tree(merged)
|
||||
return final
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
# Used by responsetypes and capa_problem
|
||||
|
||||
|
||||
class CorrectMap(object):
|
||||
"""
|
||||
Stores map between answer_id and response evaluation result for each question
|
||||
@@ -152,6 +153,3 @@ class CorrectMap(object):
|
||||
if not isinstance(other_cmap, CorrectMap):
|
||||
raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap)
|
||||
self.cmap.update(other_cmap.get_dict())
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ log = logging.getLogger('mitx.' + __name__)
|
||||
registry = TagRegistry()
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MathRenderer(object):
|
||||
tags = ['math']
|
||||
|
||||
@@ -77,6 +79,7 @@ registry.register(MathRenderer)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SolutionRenderer(object):
|
||||
'''
|
||||
A solution is just a <span>...</span> which is given an ID, that is used for displaying an
|
||||
@@ -97,4 +100,3 @@ class SolutionRenderer(object):
|
||||
return etree.XML(html)
|
||||
|
||||
registry.register(SolutionRenderer)
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
registry = TagRegistry()
|
||||
|
||||
|
||||
class Attribute(object):
|
||||
"""
|
||||
Allows specifying required and optional attributes for input types.
|
||||
@@ -413,7 +414,7 @@ class JavascriptInput(InputTypeBase):
|
||||
return [Attribute('params', None),
|
||||
Attribute('problem_state', None),
|
||||
Attribute('display_class', None),
|
||||
Attribute('display_file', None),]
|
||||
Attribute('display_file', None), ]
|
||||
|
||||
|
||||
def setup(self):
|
||||
@@ -477,12 +478,13 @@ class TextLine(InputTypeBase):
|
||||
|
||||
def _extra_context(self):
|
||||
return {'do_math': self.do_math,
|
||||
'preprocessor': self.preprocessor,}
|
||||
'preprocessor': self.preprocessor, }
|
||||
|
||||
registry.register(TextLine)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FileSubmission(InputTypeBase):
|
||||
"""
|
||||
Upload some files (e.g. for programming assignments)
|
||||
@@ -508,7 +510,7 @@ class FileSubmission(InputTypeBase):
|
||||
Convert the list of allowed files to a convenient format.
|
||||
"""
|
||||
return [Attribute('allowed_files', '[]', transform=cls.parse_files),
|
||||
Attribute('required_files', '[]', transform=cls.parse_files),]
|
||||
Attribute('required_files', '[]', transform=cls.parse_files), ]
|
||||
|
||||
def setup(self):
|
||||
"""
|
||||
@@ -524,7 +526,7 @@ class FileSubmission(InputTypeBase):
|
||||
self.msg = FileSubmission.submitted_msg
|
||||
|
||||
def _extra_context(self):
|
||||
return {'queue_len': self.queue_len,}
|
||||
return {'queue_len': self.queue_len, }
|
||||
return context
|
||||
|
||||
registry.register(FileSubmission)
|
||||
@@ -582,7 +584,7 @@ class CodeInput(InputTypeBase):
|
||||
|
||||
def _extra_context(self):
|
||||
"""Defined queue_len, add it """
|
||||
return {'queue_len': self.queue_len,}
|
||||
return {'queue_len': self.queue_len, }
|
||||
|
||||
registry.register(CodeInput)
|
||||
|
||||
@@ -606,7 +608,7 @@ class Schematic(InputTypeBase):
|
||||
Attribute('parts', None),
|
||||
Attribute('analyses', None),
|
||||
Attribute('initial_value', None),
|
||||
Attribute('submit_analyses', None),]
|
||||
Attribute('submit_analyses', None), ]
|
||||
|
||||
return context
|
||||
|
||||
@@ -614,6 +616,7 @@ registry.register(Schematic)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ImageInput(InputTypeBase):
|
||||
"""
|
||||
Clickable image as an input field. Element should specify the image source, height,
|
||||
@@ -635,7 +638,7 @@ class ImageInput(InputTypeBase):
|
||||
"""
|
||||
return [Attribute('src'),
|
||||
Attribute('height'),
|
||||
Attribute('width'),]
|
||||
Attribute('width'), ]
|
||||
|
||||
|
||||
def setup(self):
|
||||
@@ -660,6 +663,7 @@ registry.register(ImageInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Crystallography(InputTypeBase):
|
||||
"""
|
||||
An input for crystallography -- user selects 3 points on the axes, and we get a plane.
|
||||
@@ -728,18 +732,19 @@ class ChemicalEquationInput(InputTypeBase):
|
||||
"""
|
||||
Can set size of text field.
|
||||
"""
|
||||
return [Attribute('size', '20'),]
|
||||
return [Attribute('size', '20'), ]
|
||||
|
||||
def _extra_context(self):
|
||||
"""
|
||||
TODO (vshnayder): Get rid of this once we have a standard way of requiring js to be loaded.
|
||||
"""
|
||||
return {'previewer': '/static/js/capa/chemical_equation_preview.js',}
|
||||
return {'previewer': '/static/js/capa/chemical_equation_preview.js', }
|
||||
|
||||
registry.register(ChemicalEquationInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DragAndDropInput(InputTypeBase):
|
||||
"""
|
||||
Input for drag and drop problems. Allows student to drag and drop images and
|
||||
@@ -829,3 +834,108 @@ class DragAndDropInput(InputTypeBase):
|
||||
registry.register(DragAndDropInput)
|
||||
|
||||
#--------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EditAMoleculeInput(InputTypeBase):
|
||||
"""
|
||||
An input type for edit-a-molecule. Integrates with the molecule editor java applet.
|
||||
|
||||
Example:
|
||||
|
||||
<editamolecule size="50"/>
|
||||
|
||||
options: size -- width of the textbox.
|
||||
"""
|
||||
|
||||
template = "editamolecule.html"
|
||||
tags = ['editamoleculeinput']
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Can set size of text field.
|
||||
"""
|
||||
return [Attribute('file'),
|
||||
Attribute('missing', None)]
|
||||
|
||||
def _extra_context(self):
|
||||
"""
|
||||
"""
|
||||
context = {
|
||||
'applet_loader': '/static/js/capa/editamolecule.js',
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
registry.register(EditAMoleculeInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class DesignProtein2dInput(InputTypeBase):
|
||||
"""
|
||||
An input type for design of a protein in 2D. Integrates with the Protex java applet.
|
||||
|
||||
Example:
|
||||
|
||||
<designprotein2d width="800" hight="500" target_shape="E;NE;NW;W;SW;E;none" />
|
||||
"""
|
||||
|
||||
template = "designprotein2dinput.html"
|
||||
tags = ['designprotein2dinput']
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Note: width, hight, and target_shape are required.
|
||||
"""
|
||||
return [Attribute('width'),
|
||||
Attribute('height'),
|
||||
Attribute('target_shape')
|
||||
]
|
||||
|
||||
def _extra_context(self):
|
||||
"""
|
||||
"""
|
||||
context = {
|
||||
'applet_loader': '/static/js/capa/design-protein-2d.js',
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
registry.register(DesignProtein2dInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class EditAGeneInput(InputTypeBase):
|
||||
"""
|
||||
An input type for editing a gene. Integrates with the genex java applet.
|
||||
|
||||
Example:
|
||||
|
||||
<editagene width="800" hight="500" dna_sequence="ETAAGGCTATAACCGA" />
|
||||
"""
|
||||
|
||||
template = "editageneinput.html"
|
||||
tags = ['editageneinput']
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Note: width, hight, and dna_sequencee are required.
|
||||
"""
|
||||
return [Attribute('width'),
|
||||
Attribute('height'),
|
||||
Attribute('dna_sequence')
|
||||
]
|
||||
|
||||
def _extra_context(self):
|
||||
"""
|
||||
"""
|
||||
context = {
|
||||
'applet_loader': '/static/js/capa/edit-a-gene.js',
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
registry.register(EditAGeneInput)
|
||||
|
||||
|
||||
@@ -186,9 +186,9 @@ class LoncapaResponse(object):
|
||||
tree = etree.Element('span')
|
||||
|
||||
# problem author can make this span display:inline
|
||||
if self.xml.get('inline',''):
|
||||
tree.set('class','inline')
|
||||
|
||||
if self.xml.get('inline', ''):
|
||||
tree.set('class', 'inline')
|
||||
|
||||
for item in self.xml:
|
||||
# call provided procedure to do the rendering
|
||||
item_xhtml = renderer(item)
|
||||
@@ -632,8 +632,14 @@ class MultipleChoiceResponse(LoncapaResponse):
|
||||
|
||||
# define correct choices (after calling secondary setup)
|
||||
xml = self.xml
|
||||
cxml = xml.xpath('//*[@id=$id]//choice[@correct="true"]', id=xml.get('id'))
|
||||
self.correct_choices = [contextualize_text(choice.get('name'), self.context) for choice in cxml]
|
||||
cxml = xml.xpath('//*[@id=$id]//choice', id=xml.get('id'))
|
||||
|
||||
# contextualize correct attribute and then select ones for which
|
||||
# correct = "true"
|
||||
self.correct_choices = [
|
||||
contextualize_text(choice.get('name'), self.context)
|
||||
for choice in cxml
|
||||
if contextualize_text(choice.get('correct'), self.context) == "true"]
|
||||
|
||||
def mc_setup_response(self):
|
||||
'''
|
||||
@@ -875,7 +881,8 @@ def sympy_check2():
|
||||
|
||||
allowed_inputfields = ['textline', 'textbox', 'crystallography',
|
||||
'chemicalequationinput', 'vsepr_input',
|
||||
'drag_and_drop_input']
|
||||
'drag_and_drop_input', 'editamoleculeinput',
|
||||
'designprotein2dinput', 'editageneinput']
|
||||
|
||||
def setup_response(self):
|
||||
xml = self.xml
|
||||
@@ -998,7 +1005,7 @@ def sympy_check2():
|
||||
self.context['debug'] = self.system.DEBUG
|
||||
|
||||
# exec the check function
|
||||
if type(self.code) == str:
|
||||
if isinstance(self.code, basestring):
|
||||
try:
|
||||
exec self.code in self.context['global_context'], self.context
|
||||
correct = self.context['correct']
|
||||
@@ -1294,7 +1301,7 @@ class CodeResponse(LoncapaResponse):
|
||||
|
||||
# State associated with the queueing request
|
||||
queuestate = {'key': queuekey,
|
||||
'time': qtime,}
|
||||
'time': qtime, }
|
||||
|
||||
cmap = CorrectMap()
|
||||
if error:
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
|
||||
<div id="input_${id}_preview" class="equation">
|
||||
</div>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
},
|
||||
smartIndent: false
|
||||
});
|
||||
$("#textbox_${id}").find('.CodeMirror-scroll').height(${int(13.5*eval(rows))});
|
||||
});
|
||||
</script>
|
||||
</section>
|
||||
|
||||
35
common/lib/capa/capa/templates/designprotein2dinput.html
Normal file
35
common/lib/capa/capa/templates/designprotein2dinput.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<section id="designprotein2dinput_${id}" class="designprotein2dinput">
|
||||
<div class="script_placeholder" data-src="/static/js/capa/protex/protex.nocache.js?raw"/>
|
||||
<div class="script_placeholder" data-src="${applet_loader}"/>
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<div class="unanswered" id="status_${id}">
|
||||
% elif status == 'correct':
|
||||
<div class="correct" id="status_${id}">
|
||||
% elif status == 'incorrect':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% elif status == 'incomplete':
|
||||
<div class="incomplete" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<div id="protex_container"></div>
|
||||
<input type="hidden" name="target_shape" id="target_shape" value ="${target_shape}"></input>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
|
||||
<p class="status">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
correct
|
||||
% elif status == 'incorrect':
|
||||
incorrect
|
||||
% elif status == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
39
common/lib/capa/capa/templates/editageneinput.html
Normal file
39
common/lib/capa/capa/templates/editageneinput.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<section id="editageneinput_${id}" class="editageneinput">
|
||||
<div class="script_placeholder" data-src="${applet_loader}"/>
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<div class="unanswered" id="status_${id}">
|
||||
% elif status == 'correct':
|
||||
<div class="correct" id="status_${id}">
|
||||
% elif status == 'incorrect':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% elif status == 'incomplete':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<object type="application/x-java-applet" id="applet_${id}" class="applet" width="${width}" height="${height}">
|
||||
<param name="archive" value="/static/applets/capa/genex.jar" />
|
||||
<param name="code" value="GX.GenexApplet.class" />
|
||||
<param name="DNA_SEQUENCE" value="${dna_sequence}" />
|
||||
Applet failed to run. No Java plug-in was found.
|
||||
</object>
|
||||
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
|
||||
<p class="status">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
correct
|
||||
% elif status == 'incorrect':
|
||||
incorrect
|
||||
% elif status == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
43
common/lib/capa/capa/templates/editamolecule.html
Normal file
43
common/lib/capa/capa/templates/editamolecule.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<section id="editamoleculeinput_${id}" class="editamoleculeinput">
|
||||
<div class="script_placeholder" data-src="${applet_loader}"/>
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<div class="unanswered" id="status_${id}">
|
||||
% elif status == 'correct':
|
||||
<div class="correct" id="status_${id}">
|
||||
% elif status == 'incorrect':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% elif status == 'incomplete':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<div id="applet_${id}" class="applet" data-molfile-src="${file}" style="display:block;width:500px;height:400px">
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
|
||||
<button id="reset_${id}" class="reset">Reset</button>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
<p class="status">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
correct
|
||||
% elif status == 'incorrect':
|
||||
incorrect
|
||||
% elif status == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
<br/> <br/>
|
||||
|
||||
<div class="error_message" style="padding: 5px 5px 5px 5px; background-color:#FA6666; height:60px;width:400px; display: none"></div>
|
||||
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
@@ -8,6 +8,7 @@ import xml.sax.saxutils as saxutils
|
||||
|
||||
TEST_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
|
||||
def tst_render_template(template, context):
|
||||
"""
|
||||
A test version of render to template. Renders to the repr of the context, completely ignoring
|
||||
@@ -25,7 +26,7 @@ test_system = Mock(
|
||||
user=Mock(),
|
||||
filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")),
|
||||
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"),
|
||||
anonymous_student_id = 'student'
|
||||
anonymous_student_id='student'
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from capa import customrender
|
||||
# just a handy shortcut
|
||||
lookup_tag = customrender.registry.get_class_for_tag
|
||||
|
||||
|
||||
def extract_context(xml):
|
||||
"""
|
||||
Given an xml element corresponding to the output of test_system.render_template, get back the
|
||||
@@ -15,9 +16,11 @@ def extract_context(xml):
|
||||
"""
|
||||
return eval(xml.text)
|
||||
|
||||
|
||||
def quote_attr(s):
|
||||
return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes
|
||||
|
||||
|
||||
class HelperTest(unittest.TestCase):
|
||||
'''
|
||||
Make sure that our helper function works!
|
||||
@@ -50,7 +53,7 @@ class SolutionRenderTest(unittest.TestCase):
|
||||
# our test_system "renders" templates to a div with the repr of the context
|
||||
xml = renderer.get_html()
|
||||
context = extract_context(xml)
|
||||
self.assertEqual(context, {'id' : 'solution_12'})
|
||||
self.assertEqual(context, {'id': 'solution_12'})
|
||||
|
||||
|
||||
class MathRenderTest(unittest.TestCase):
|
||||
@@ -65,12 +68,11 @@ class MathRenderTest(unittest.TestCase):
|
||||
renderer = lookup_tag('math')(test_system, element)
|
||||
|
||||
self.assertEqual(renderer.mathstr, mathjax_out)
|
||||
|
||||
|
||||
def test_parsing(self):
|
||||
self.check_parse('$abc$', '[mathjaxinline]abc[/mathjaxinline]')
|
||||
self.check_parse('$abc', '$abc')
|
||||
self.check_parse(r'$\displaystyle 2+2$', '[mathjax] 2+2[/mathjax]')
|
||||
|
||||
|
||||
|
||||
# NOTE: not testing get_html yet because I don't understand why it's doing what it's doing.
|
||||
|
||||
|
||||
4
common/lib/capa/capa/tests/test_files/js/.gitignore
vendored
Normal file
4
common/lib/capa/capa/tests/test_files/js/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
test_problem_display.js
|
||||
test_problem_generator.js
|
||||
test_problem_grader.js
|
||||
xproblem.js
|
||||
@@ -1,49 +0,0 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var MinimaxProblemDisplay, root,
|
||||
__hasProp = {}.hasOwnProperty,
|
||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
||||
|
||||
MinimaxProblemDisplay = (function(_super) {
|
||||
|
||||
__extends(MinimaxProblemDisplay, _super);
|
||||
|
||||
function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) {
|
||||
this.state = state;
|
||||
this.submission = submission;
|
||||
this.evaluation = evaluation;
|
||||
this.container = container;
|
||||
this.submissionField = submissionField;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters);
|
||||
}
|
||||
|
||||
MinimaxProblemDisplay.prototype.render = function() {};
|
||||
|
||||
MinimaxProblemDisplay.prototype.createSubmission = function() {
|
||||
var id, value, _ref, _results;
|
||||
this.newSubmission = {};
|
||||
if (this.submission != null) {
|
||||
_ref = this.submission;
|
||||
_results = [];
|
||||
for (id in _ref) {
|
||||
value = _ref[id];
|
||||
_results.push(this.newSubmission[id] = value);
|
||||
}
|
||||
return _results;
|
||||
}
|
||||
};
|
||||
|
||||
MinimaxProblemDisplay.prototype.getCurrentSubmission = function() {
|
||||
return this.newSubmission;
|
||||
};
|
||||
|
||||
return MinimaxProblemDisplay;
|
||||
|
||||
})(XProblemDisplay);
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.TestProblemDisplay = TestProblemDisplay;
|
||||
|
||||
}).call(this);
|
||||
@@ -1,29 +0,0 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var TestProblemGenerator, root,
|
||||
__hasProp = {}.hasOwnProperty,
|
||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
||||
|
||||
TestProblemGenerator = (function(_super) {
|
||||
|
||||
__extends(TestProblemGenerator, _super);
|
||||
|
||||
function TestProblemGenerator(seed, parameters) {
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
TestProblemGenerator.__super__.constructor.call(this, seed, this.parameters);
|
||||
}
|
||||
|
||||
TestProblemGenerator.prototype.generate = function() {
|
||||
this.problemState.value = this.parameters.value;
|
||||
return this.problemState;
|
||||
};
|
||||
|
||||
return TestProblemGenerator;
|
||||
|
||||
})(XProblemGenerator);
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.generatorClass = TestProblemGenerator;
|
||||
|
||||
}).call(this);
|
||||
@@ -1,50 +0,0 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var TestProblemGrader, root,
|
||||
__hasProp = {}.hasOwnProperty,
|
||||
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
|
||||
|
||||
TestProblemGrader = (function(_super) {
|
||||
|
||||
__extends(TestProblemGrader, _super);
|
||||
|
||||
function TestProblemGrader(submission, problemState, parameters) {
|
||||
this.submission = submission;
|
||||
this.problemState = problemState;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
TestProblemGrader.__super__.constructor.call(this, this.submission, this.problemState, this.parameters);
|
||||
}
|
||||
|
||||
TestProblemGrader.prototype.solve = function() {
|
||||
return this.solution = {
|
||||
0: this.problemState.value
|
||||
};
|
||||
};
|
||||
|
||||
TestProblemGrader.prototype.grade = function() {
|
||||
var allCorrect, id, value, valueCorrect, _ref;
|
||||
if (!(this.solution != null)) {
|
||||
this.solve();
|
||||
}
|
||||
allCorrect = true;
|
||||
_ref = this.solution;
|
||||
for (id in _ref) {
|
||||
value = _ref[id];
|
||||
valueCorrect = this.submission != null ? value === this.submission[id] : false;
|
||||
this.evaluation[id] = valueCorrect;
|
||||
if (!valueCorrect) {
|
||||
allCorrect = false;
|
||||
}
|
||||
}
|
||||
return allCorrect;
|
||||
};
|
||||
|
||||
return TestProblemGrader;
|
||||
|
||||
})(XProblemGrader);
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.graderClass = TestProblemGrader;
|
||||
|
||||
}).call(this);
|
||||
@@ -1,78 +0,0 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var XProblemDisplay, XProblemGenerator, XProblemGrader, root;
|
||||
|
||||
XProblemGenerator = (function() {
|
||||
|
||||
function XProblemGenerator(seed, parameters) {
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
this.random = new MersenneTwister(seed);
|
||||
this.problemState = {};
|
||||
}
|
||||
|
||||
XProblemGenerator.prototype.generate = function() {
|
||||
return console.error("Abstract method called: XProblemGenerator.generate");
|
||||
};
|
||||
|
||||
return XProblemGenerator;
|
||||
|
||||
})();
|
||||
|
||||
XProblemDisplay = (function() {
|
||||
|
||||
function XProblemDisplay(state, submission, evaluation, container, submissionField, parameters) {
|
||||
this.state = state;
|
||||
this.submission = submission;
|
||||
this.evaluation = evaluation;
|
||||
this.container = container;
|
||||
this.submissionField = submissionField;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
}
|
||||
|
||||
XProblemDisplay.prototype.render = function() {
|
||||
return console.error("Abstract method called: XProblemDisplay.render");
|
||||
};
|
||||
|
||||
XProblemDisplay.prototype.updateSubmission = function() {
|
||||
return this.submissionField.val(JSON.stringify(this.getCurrentSubmission()));
|
||||
};
|
||||
|
||||
XProblemDisplay.prototype.getCurrentSubmission = function() {
|
||||
return console.error("Abstract method called: XProblemDisplay.getCurrentSubmission");
|
||||
};
|
||||
|
||||
return XProblemDisplay;
|
||||
|
||||
})();
|
||||
|
||||
XProblemGrader = (function() {
|
||||
|
||||
function XProblemGrader(submission, problemState, parameters) {
|
||||
this.submission = submission;
|
||||
this.problemState = problemState;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
this.solution = null;
|
||||
this.evaluation = {};
|
||||
}
|
||||
|
||||
XProblemGrader.prototype.solve = function() {
|
||||
return console.error("Abstract method called: XProblemGrader.solve");
|
||||
};
|
||||
|
||||
XProblemGrader.prototype.grade = function() {
|
||||
return console.error("Abstract method called: XProblemGrader.grade");
|
||||
};
|
||||
|
||||
return XProblemGrader;
|
||||
|
||||
})();
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.XProblemGenerator = XProblemGenerator;
|
||||
|
||||
root.XProblemDisplay = XProblemDisplay;
|
||||
|
||||
root.XProblemGrader = XProblemGrader;
|
||||
|
||||
}).call(this);
|
||||
@@ -31,6 +31,7 @@ lookup_tag = inputtypes.registry.get_class_for_tag
|
||||
def quote_attr(s):
|
||||
return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes
|
||||
|
||||
|
||||
class OptionInputTest(unittest.TestCase):
|
||||
'''
|
||||
Make sure option inputs work
|
||||
@@ -100,7 +101,7 @@ class ChoiceGroupTest(unittest.TestCase):
|
||||
'input_type': expected_input_type,
|
||||
'choices': [('foil1', '<text>This is foil One.</text>'),
|
||||
('foil2', '<text>This is foil Two.</text>'),
|
||||
('foil3', 'This is foil Three.'),],
|
||||
('foil3', 'This is foil Three.'), ],
|
||||
'name_array_suffix': expected_suffix, # what is this for??
|
||||
}
|
||||
|
||||
@@ -137,7 +138,7 @@ class JavascriptInputTest(unittest.TestCase):
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': '3',}
|
||||
state = {'value': '3', }
|
||||
the_input = lookup_tag('javascriptinput')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
@@ -149,7 +150,7 @@ class JavascriptInputTest(unittest.TestCase):
|
||||
'params': params,
|
||||
'display_file': display_file,
|
||||
'display_class': display_class,
|
||||
'problem_state': problem_state,}
|
||||
'problem_state': problem_state, }
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
@@ -165,7 +166,7 @@ class TextLineTest(unittest.TestCase):
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'BumbleBee',}
|
||||
state = {'value': 'BumbleBee', }
|
||||
the_input = lookup_tag('textline')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
@@ -193,7 +194,7 @@ class TextLineTest(unittest.TestCase):
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'BumbleBee',}
|
||||
state = {'value': 'BumbleBee', }
|
||||
the_input = lookup_tag('textline')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
@@ -231,7 +232,7 @@ class FileSubmissionTest(unittest.TestCase):
|
||||
|
||||
state = {'value': 'BumbleBee.py',
|
||||
'status': 'incomplete',
|
||||
'feedback' : {'message': '3'}, }
|
||||
'feedback': {'message': '3'}, }
|
||||
input_class = lookup_tag('filesubmission')
|
||||
the_input = input_class(test_system, element, state)
|
||||
|
||||
@@ -275,7 +276,7 @@ class CodeInputTest(unittest.TestCase):
|
||||
|
||||
state = {'value': 'print "good evening"',
|
||||
'status': 'incomplete',
|
||||
'feedback' : {'message': '3'}, }
|
||||
'feedback': {'message': '3'}, }
|
||||
|
||||
input_class = lookup_tag('codeinput')
|
||||
the_input = input_class(test_system, element, state)
|
||||
@@ -488,7 +489,7 @@ class ChemicalEquationTest(unittest.TestCase):
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'H2OYeah',}
|
||||
state = {'value': 'H2OYeah', }
|
||||
the_input = lookup_tag('chemicalequationinput')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
@@ -16,6 +16,7 @@ from capa.correctmap import CorrectMap
|
||||
from capa.util import convert_files_to_filenames
|
||||
from capa.xqueue_interface import dateformat
|
||||
|
||||
|
||||
class MultiChoiceTest(unittest.TestCase):
|
||||
def test_MC_grade(self):
|
||||
multichoice_file = os.path.dirname(__file__) + "/test_files/multichoice.xml"
|
||||
@@ -295,16 +296,16 @@ class CodeResponseTest(unittest.TestCase):
|
||||
old_cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
queuestate = CodeResponseTest.make_queuestate(1000+i, datetime.now())
|
||||
queuestate = CodeResponseTest.make_queuestate(1000 + i, datetime.now())
|
||||
old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate))
|
||||
|
||||
# Message format common to external graders
|
||||
grader_msg = '<span>MESSAGE</span>' # Must be valid XML
|
||||
correct_score_msg = json.dumps({'correct':True, 'score':1, 'msg': grader_msg})
|
||||
incorrect_score_msg = json.dumps({'correct':False, 'score':0, 'msg': grader_msg})
|
||||
grader_msg = '<span>MESSAGE</span>' # Must be valid XML
|
||||
correct_score_msg = json.dumps({'correct': True, 'score': 1, 'msg': grader_msg})
|
||||
incorrect_score_msg = json.dumps({'correct': False, 'score': 0, 'msg': grader_msg})
|
||||
|
||||
xserver_msgs = {'correct': correct_score_msg,
|
||||
'incorrect': incorrect_score_msg,}
|
||||
'incorrect': incorrect_score_msg, }
|
||||
|
||||
# Incorrect queuekey, state should not be updated
|
||||
for correctness in ['correct', 'incorrect']:
|
||||
@@ -325,7 +326,7 @@ class CodeResponseTest(unittest.TestCase):
|
||||
|
||||
new_cmap = CorrectMap()
|
||||
new_cmap.update(old_cmap)
|
||||
npoints = 1 if correctness=='correct' else 0
|
||||
npoints = 1 if correctness == 'correct' else 0
|
||||
new_cmap.set(answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None)
|
||||
|
||||
test_lcp.update_score(xserver_msgs[correctness], queuekey=1000 + i)
|
||||
@@ -361,7 +362,7 @@ class CodeResponseTest(unittest.TestCase):
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
latest_timestamp = datetime.now()
|
||||
queuestate = CodeResponseTest.make_queuestate(1000+i, latest_timestamp)
|
||||
queuestate = CodeResponseTest.make_queuestate(1000 + i, latest_timestamp)
|
||||
cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate))
|
||||
test_lcp.correct_map.update(cmap)
|
||||
|
||||
@@ -412,6 +413,7 @@ class ChoiceResponseTest(unittest.TestCase):
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_4_1'), 'correct')
|
||||
|
||||
|
||||
class JavascriptResponseTest(unittest.TestCase):
|
||||
|
||||
def test_jr_grade(self):
|
||||
@@ -424,4 +426,3 @@ class JavascriptResponseTest(unittest.TestCase):
|
||||
|
||||
self.assertEquals(test_lcp.grade_answers(incorrect_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct')
|
||||
|
||||
|
||||
@@ -51,15 +51,17 @@ def convert_files_to_filenames(answers):
|
||||
new_answers = dict()
|
||||
for answer_id in answers.keys():
|
||||
answer = answers[answer_id]
|
||||
if is_list_of_files(answer): # Files are stored as a list, even if one file
|
||||
if is_list_of_files(answer): # Files are stored as a list, even if one file
|
||||
new_answers[answer_id] = [f.name for f in answer]
|
||||
else:
|
||||
new_answers[answer_id] = answers[answer_id]
|
||||
return new_answers
|
||||
|
||||
|
||||
def is_list_of_files(files):
|
||||
return isinstance(files, list) and all(is_file(f) for f in files)
|
||||
|
||||
|
||||
def is_file(file_to_test):
|
||||
'''
|
||||
Duck typing to check if 'file_to_test' is a File object
|
||||
@@ -79,11 +81,10 @@ def find_with_default(node, path, default):
|
||||
|
||||
Returns:
|
||||
node.find(path).text if the find succeeds, default otherwise.
|
||||
|
||||
|
||||
"""
|
||||
v = node.find(path)
|
||||
if v is not None:
|
||||
return v.text
|
||||
else:
|
||||
return default
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import requests
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
dateformat = '%Y%m%d%H%M%S'
|
||||
|
||||
|
||||
def make_hashkey(seed):
|
||||
'''
|
||||
Generate a string key by hashing
|
||||
@@ -29,9 +30,9 @@ def make_xheader(lms_callback_url, lms_key, queue_name):
|
||||
'queue_name': designate a specific queue within xqueue server, e.g. 'MITx-6.00x' (string)
|
||||
}
|
||||
"""
|
||||
return json.dumps({ 'lms_callback_url': lms_callback_url,
|
||||
return json.dumps({'lms_callback_url': lms_callback_url,
|
||||
'lms_key': lms_key,
|
||||
'queue_name': queue_name })
|
||||
'queue_name': queue_name})
|
||||
|
||||
|
||||
def parse_xreply(xreply):
|
||||
@@ -96,18 +97,18 @@ class XQueueInterface(object):
|
||||
|
||||
|
||||
def _login(self):
|
||||
payload = { 'username': self.auth['username'],
|
||||
'password': self.auth['password'] }
|
||||
payload = {'username': self.auth['username'],
|
||||
'password': self.auth['password']}
|
||||
return self._http_post(self.url + '/xqueue/login/', payload)
|
||||
|
||||
|
||||
def _send_to_queue(self, header, body, files_to_upload):
|
||||
payload = {'xqueue_header': header,
|
||||
'xqueue_body' : body}
|
||||
'xqueue_body': body}
|
||||
files = {}
|
||||
if files_to_upload is not None:
|
||||
for f in files_to_upload:
|
||||
files.update({ f.name: f })
|
||||
files.update({f.name: f})
|
||||
|
||||
return self._http_post(self.url + '/xqueue/submit/', payload, files=files)
|
||||
|
||||
|
||||
71
common/lib/sample-post.py
Normal file
71
common/lib/sample-post.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# A simple script demonstrating how to have an external program post problem
|
||||
# responses to an edx server.
|
||||
#
|
||||
# ***** NOTE *****
|
||||
# This is not intended as a stable public API. In fact, it is almost certainly
|
||||
# going to change. If you use this for some reason, be prepared to change your
|
||||
# code.
|
||||
#
|
||||
# We will be working to define a stable public API for external programs. We
|
||||
# don't have have one yet (Feb 2013).
|
||||
|
||||
|
||||
import requests
|
||||
import sys
|
||||
import getpass
|
||||
|
||||
def prompt(msg, default=None, safe=False):
|
||||
d = ' [{0}]'.format(default) if default is not None else ''
|
||||
prompt = 'Enter {msg}{default}: '.format(msg=msg, default=d)
|
||||
if not safe:
|
||||
print prompt
|
||||
x = sys.stdin.readline().strip()
|
||||
else:
|
||||
x = getpass.getpass(prompt=prompt)
|
||||
if x == '' and default is not None:
|
||||
return default
|
||||
return x
|
||||
|
||||
server = 'https://www.edx.org'
|
||||
course_id = 'HarvardX/PH207x/2012_Fall'
|
||||
location = 'i4x://HarvardX/PH207x/problem/ex_practice_2'
|
||||
|
||||
#server = prompt('Server (no trailing slash)', 'http://127.0.0.1:8000')
|
||||
#course_id = prompt('Course id', 'MITx/7012x/2013_Spring')
|
||||
#location = prompt('problem location', 'i4x://MITx/7012x/problem/example_upload_answer')
|
||||
value = prompt('value to upload')
|
||||
|
||||
username = prompt('username on server', 'victor@edx.org')
|
||||
password = prompt('password', 'abc123', safe=True)
|
||||
|
||||
print "get csrf cookie"
|
||||
session = requests.session()
|
||||
r = session.get(server + '/')
|
||||
r.raise_for_status()
|
||||
|
||||
# print session.cookies
|
||||
|
||||
# for some reason, the server expects a header containing the csrf cookie, not just the
|
||||
# cookie itself.
|
||||
session.headers['X-CSRFToken'] = session.cookies['csrftoken']
|
||||
# for https, need a referer header
|
||||
session.headers['Referer'] = server + '/'
|
||||
login_url = '/'.join([server, 'login'])
|
||||
|
||||
print "log in"
|
||||
r = session.post(login_url, {'email': 'victor@edx.org', 'password': 'Secret!', 'remember': 'false'})
|
||||
#print "request headers: ", r.request.headers
|
||||
#print "response headers: ", r.headers
|
||||
r.raise_for_status()
|
||||
|
||||
url = '/'.join([server, 'courses', course_id, 'modx', location, 'problem_check'])
|
||||
data = {'input_{0}_2_1'.format(location.replace('/','-').replace(':','').replace('--','-')): value}
|
||||
#data = {'input_i4x-MITx-7012x-problem-example_upload_answer_2_1': value}
|
||||
|
||||
print "Posting to '{0}': {1}".format(url, data)
|
||||
|
||||
r = session.post(url, data)
|
||||
r.raise_for_status()
|
||||
|
||||
print ("To see the uploaded answer, go to {server}/courses/{course_id}/jump_to/{location}"
|
||||
.format(server=server, course_id=course_id, location=location))
|
||||
@@ -3,7 +3,8 @@ A handy util to print a django-debug-screen-like stack trace with
|
||||
values of local variables.
|
||||
"""
|
||||
|
||||
import sys, traceback
|
||||
import sys
|
||||
import traceback
|
||||
from django.utils.encoding import smart_unicode
|
||||
|
||||
|
||||
@@ -48,5 +49,3 @@ def supertrace(max_len=160):
|
||||
print s
|
||||
except:
|
||||
print "<ERROR WHILE PRINTING VALUE>"
|
||||
|
||||
|
||||
|
||||
@@ -27,13 +27,17 @@ setup(
|
||||
"html = xmodule.html_module:HtmlDescriptor",
|
||||
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"error = xmodule.error_module:ErrorDescriptor",
|
||||
"peergrading = xmodule.peer_grading_module:PeerGradingDescriptor",
|
||||
"problem = xmodule.capa_module:CapaDescriptor",
|
||||
"problemset = xmodule.seq_module:SequenceDescriptor",
|
||||
"randomize = xmodule.randomize_module:RandomizeDescriptor",
|
||||
"section = xmodule.backcompat_module:SemanticSectionDescriptor",
|
||||
"sequential = xmodule.seq_module:SequenceDescriptor",
|
||||
"slides = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"timelimit = xmodule.timelimit_module:TimeLimitDescriptor",
|
||||
"vertical = xmodule.vertical_module:VerticalDescriptor",
|
||||
"video = xmodule.video_module:VideoDescriptor",
|
||||
"videoalpha = xmodule.videoalpha_module:VideoAlphaDescriptor",
|
||||
"videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"videosequence = xmodule.seq_module:SequenceDescriptor",
|
||||
"discussion = xmodule.discussion_module:DiscussionDescriptor",
|
||||
|
||||
@@ -51,7 +51,7 @@ class ABTestModule(XModule):
|
||||
|
||||
def get_shared_state(self):
|
||||
return json.dumps({'group': self.group})
|
||||
|
||||
|
||||
def get_child_descriptors(self):
|
||||
active_locations = set(self.definition['data']['group_content'][self.group])
|
||||
return [desc for desc in self.descriptor.get_children() if desc.location.url() in active_locations]
|
||||
@@ -171,7 +171,7 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
|
||||
group_elem.append(etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
|
||||
return xml_object
|
||||
|
||||
|
||||
|
||||
|
||||
def has_dynamic_children(self):
|
||||
return True
|
||||
|
||||
@@ -2,6 +2,7 @@ import cgi
|
||||
import datetime
|
||||
import dateutil
|
||||
import dateutil.parser
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
@@ -25,6 +26,24 @@ log = logging.getLogger("mitx.courseware")
|
||||
#-----------------------------------------------------------------------------
|
||||
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
|
||||
|
||||
# Generated this many different variants of problems with rerandomize=per_student
|
||||
NUM_RANDOMIZATION_BINS = 20
|
||||
|
||||
|
||||
def randomization_bin(seed, problem_id):
|
||||
"""
|
||||
Pick a randomization bin for the problem given the user's seed and a problem id.
|
||||
|
||||
We do this because we only want e.g. 20 randomizations of a problem to make analytics
|
||||
interesting. To avoid having sets of students that always get the same problems,
|
||||
we'll combine the system's per-student seed with the problem id in picking the bin.
|
||||
"""
|
||||
h = hashlib.sha1()
|
||||
h.update(str(seed))
|
||||
h.update(str(problem_id))
|
||||
# get the first few digits of the hash, convert to an int, then mod.
|
||||
return int(h.hexdigest()[:7], 16) % NUM_RANDOMIZATION_BINS
|
||||
|
||||
|
||||
def only_one(lst, default="", process=lambda x: x):
|
||||
"""
|
||||
@@ -138,13 +157,9 @@ class CapaModule(XModule):
|
||||
|
||||
if self.rerandomize == 'never':
|
||||
self.seed = 1
|
||||
elif self.rerandomize == "per_student" and hasattr(self.system, 'id'):
|
||||
# TODO: This line is badly broken:
|
||||
# (1) We're passing student ID to xmodule.
|
||||
# (2) There aren't bins of students. -- we only want 10 or 20 randomizations, and want to assign students
|
||||
# to these bins, and may not want cohorts. So e.g. hash(your-id, problem_id) % num_bins.
|
||||
# - analytics really needs small number of bins.
|
||||
self.seed = system.id
|
||||
elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'):
|
||||
# see comment on randomization_bin
|
||||
self.seed = randomization_bin(system.seed, self.location.url)
|
||||
else:
|
||||
self.seed = None
|
||||
|
||||
@@ -270,7 +285,7 @@ class CapaModule(XModule):
|
||||
|
||||
# Next, generate a fresh LoncapaProblem
|
||||
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
|
||||
state=None, # Tabula rasa
|
||||
state=None, # Tabula rasa
|
||||
seed=self.seed, system=self.system)
|
||||
|
||||
# Prepend a scary warning to the student
|
||||
@@ -289,7 +304,7 @@ class CapaModule(XModule):
|
||||
html = warning
|
||||
try:
|
||||
html += self.lcp.get_html()
|
||||
except Exception, err: # Couldn't do it. Give up
|
||||
except Exception, err: # Couldn't do it. Give up
|
||||
log.exception(err)
|
||||
raise
|
||||
|
||||
@@ -302,7 +317,7 @@ class CapaModule(XModule):
|
||||
# check button is context-specific.
|
||||
|
||||
# Put a "Check" button if unlimited attempts or still some left
|
||||
if self.max_attempts is None or self.attempts < self.max_attempts-1:
|
||||
if self.max_attempts is None or self.attempts < self.max_attempts - 1:
|
||||
check_button = "Check"
|
||||
else:
|
||||
# Will be final check so let user know that
|
||||
@@ -356,7 +371,7 @@ class CapaModule(XModule):
|
||||
id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "</div>"
|
||||
|
||||
# now do the substitutions which are filesystem based, e.g. '/static/' prefixes
|
||||
return self.system.replace_urls(html, self.metadata['data_dir'], course_namespace=self.location)
|
||||
return self.system.replace_urls(html)
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
'''
|
||||
@@ -477,7 +492,7 @@ class CapaModule(XModule):
|
||||
new_answers = dict()
|
||||
for answer_id in answers:
|
||||
try:
|
||||
new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.metadata['data_dir'], course_namespace=self.location)}
|
||||
new_answer = {answer_id: self.system.replace_urls(answers[answer_id])}
|
||||
except TypeError:
|
||||
log.debug('Unable to perform URL substitution on answers[%s]: %s' % (answer_id, answers[answer_id]))
|
||||
new_answer = {answer_id: answers[answer_id]}
|
||||
@@ -548,9 +563,9 @@ class CapaModule(XModule):
|
||||
current_time = datetime.datetime.now()
|
||||
prev_submit_time = self.lcp.get_recentmost_queuetime()
|
||||
waittime_between_requests = self.system.xqueue['waittime']
|
||||
if (current_time-prev_submit_time).total_seconds() < waittime_between_requests:
|
||||
if (current_time - prev_submit_time).total_seconds() < waittime_between_requests:
|
||||
msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests
|
||||
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
|
||||
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
|
||||
|
||||
try:
|
||||
old_state = self.lcp.get_state()
|
||||
@@ -583,7 +598,7 @@ class CapaModule(XModule):
|
||||
event_info['attempts'] = self.attempts
|
||||
self.system.track_function('save_problem_check', event_info)
|
||||
|
||||
if hasattr(self.system,'psychometrics_handler'): # update PsychometricsData using callback
|
||||
if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback
|
||||
self.system.psychometrics_handler(self.get_instance_state())
|
||||
|
||||
# render problem into HTML
|
||||
@@ -694,7 +709,7 @@ class CapaDescriptor(RawDescriptor):
|
||||
@property
|
||||
def editable_metadata_fields(self):
|
||||
"""Remove metadata from the editable fields since it has its own editor"""
|
||||
subset = super(CapaDescriptor,self).editable_metadata_fields
|
||||
subset = super(CapaDescriptor, self).editable_metadata_fields
|
||||
if 'markdown' in subset:
|
||||
subset.remove('markdown')
|
||||
return subset
|
||||
|
||||
@@ -19,44 +19,17 @@ from .stringify import stringify_children
|
||||
from .x_module import XModule
|
||||
from .xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
import self_assessment_module
|
||||
import open_ended_module
|
||||
from combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError
|
||||
from .stringify import stringify_children
|
||||
from combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
# Set the default number of max attempts. Should be 1 for production
|
||||
# Set higher for debugging/testing
|
||||
# attempts specified in xml definition overrides this.
|
||||
MAX_ATTEMPTS = 10000
|
||||
|
||||
# Set maximum available number of points.
|
||||
# Overriden by max_score specified in xml.
|
||||
MAX_SCORE = 1
|
||||
VERSION_TUPLES = (
|
||||
('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module),
|
||||
)
|
||||
|
||||
#The highest score allowed for the overall xmodule and for each rubric point
|
||||
MAX_SCORE_ALLOWED = 3
|
||||
|
||||
#If true, default behavior is to score module as a practice problem. Otherwise, no grade at all is shown in progress
|
||||
#Metadata overrides this.
|
||||
IS_SCORED = False
|
||||
|
||||
#If true, then default behavior is to require a file upload or pasted link from a student for this problem.
|
||||
#Metadata overrides this.
|
||||
ACCEPT_FILE_UPLOAD = False
|
||||
|
||||
#Contains all reasonable bool and case combinations of True
|
||||
TRUE_DICT = ["True", True, "TRUE", "true"]
|
||||
|
||||
HUMAN_TASK_TYPE = {
|
||||
'selfassessment' : "Self Assessment",
|
||||
'openended' : "External Grader",
|
||||
}
|
||||
|
||||
class IncorrectMaxScoreError(Exception):
|
||||
def __init__(self, msg):
|
||||
self.msg = msg
|
||||
DEFAULT_VERSION = 1
|
||||
DEFAULT_VERSION = str(DEFAULT_VERSION)
|
||||
|
||||
class CombinedOpenEndedModule(XModule):
|
||||
"""
|
||||
@@ -137,512 +110,68 @@ class CombinedOpenEndedModule(XModule):
|
||||
|
||||
"""
|
||||
|
||||
self.system = system
|
||||
self.system.set('location', location)
|
||||
|
||||
# Load instance state
|
||||
if instance_state is not None:
|
||||
instance_state = json.loads(instance_state)
|
||||
else:
|
||||
instance_state = {}
|
||||
|
||||
#We need to set the location here so the child modules can use it
|
||||
system.set('location', location)
|
||||
self.version = self.metadata.get('version', DEFAULT_VERSION)
|
||||
if not isinstance(self.version, basestring):
|
||||
try:
|
||||
self.version = str(self.version)
|
||||
except:
|
||||
log.error("Version {0} is not correct. Going with version {1}".format(self.version, DEFAULT_VERSION))
|
||||
self.version = DEFAULT_VERSION
|
||||
|
||||
#Tells the system which xml definition to load
|
||||
self.current_task_number = instance_state.get('current_task_number', 0)
|
||||
#This loads the states of the individual children
|
||||
self.task_states = instance_state.get('task_states', [])
|
||||
#Overall state of the combined open ended module
|
||||
self.state = instance_state.get('state', self.INITIAL)
|
||||
versions = [i[0] for i in VERSION_TUPLES]
|
||||
descriptors = [i[1] for i in VERSION_TUPLES]
|
||||
modules = [i[2] for i in VERSION_TUPLES]
|
||||
|
||||
self.attempts = instance_state.get('attempts', 0)
|
||||
try:
|
||||
version_index = versions.index(self.version)
|
||||
except:
|
||||
log.error("Version {0} is not correct. Going with version {1}".format(self.version, DEFAULT_VERSION))
|
||||
self.version = DEFAULT_VERSION
|
||||
version_index = versions.index(self.version)
|
||||
|
||||
#Allow reset is true if student has failed the criteria to move to the next child task
|
||||
self.allow_reset = instance_state.get('ready_to_reset', False)
|
||||
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
|
||||
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
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
self._max_score = int(self.metadata.get('max_score', MAX_SCORE))
|
||||
|
||||
if self._max_score > MAX_SCORE_ALLOWED:
|
||||
error_message = "Max score {0} is higher than max score allowed {1} for location {2}".format(self._max_score,
|
||||
MAX_SCORE_ALLOWED, location)
|
||||
log.error(error_message)
|
||||
raise IncorrectMaxScoreError(error_message)
|
||||
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
rubric_string = stringify_children(definition['rubric'])
|
||||
rubric_renderer.check_if_rubric_is_parseable(rubric_string, location, MAX_SCORE_ALLOWED)
|
||||
|
||||
#Static data is passed to the child modules to render
|
||||
self.static_data = {
|
||||
'max_score': self._max_score,
|
||||
'max_attempts': self.max_attempts,
|
||||
'prompt': definition['prompt'],
|
||||
'rubric': definition['rubric'],
|
||||
'display_name': self.display_name,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
static_data = {
|
||||
'rewrite_content_links' : self.rewrite_content_links,
|
||||
}
|
||||
|
||||
self.task_xml = definition['task_xml']
|
||||
self.setup_next_task()
|
||||
|
||||
def get_tag_name(self, xml):
|
||||
"""
|
||||
Gets the tag name of a given xml block.
|
||||
Input: XML string
|
||||
Output: The name of the root tag
|
||||
"""
|
||||
tag = etree.fromstring(xml).tag
|
||||
return tag
|
||||
|
||||
def overwrite_state(self, current_task_state):
|
||||
"""
|
||||
Overwrites an instance state and sets the latest response to the current response. This is used
|
||||
to ensure that the student response is carried over from the first child to the rest.
|
||||
Input: Task state json string
|
||||
Output: Task state json string
|
||||
"""
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
last_response = last_response_data['response']
|
||||
|
||||
loaded_task_state = json.loads(current_task_state)
|
||||
if loaded_task_state['state'] == self.INITIAL:
|
||||
loaded_task_state['state'] = self.ASSESSING
|
||||
loaded_task_state['created'] = True
|
||||
loaded_task_state['history'].append({'answer': last_response})
|
||||
current_task_state = json.dumps(loaded_task_state)
|
||||
return current_task_state
|
||||
|
||||
def child_modules(self):
|
||||
"""
|
||||
Returns the constructors associated with the child modules in a dictionary. This makes writing functions
|
||||
simpler (saves code duplication)
|
||||
Input: None
|
||||
Output: A dictionary of dictionaries containing the descriptor functions and module functions
|
||||
"""
|
||||
child_modules = {
|
||||
'openended': open_ended_module.OpenEndedModule,
|
||||
'selfassessment': self_assessment_module.SelfAssessmentModule,
|
||||
}
|
||||
child_descriptors = {
|
||||
'openended': open_ended_module.OpenEndedDescriptor,
|
||||
'selfassessment': self_assessment_module.SelfAssessmentDescriptor,
|
||||
}
|
||||
children = {
|
||||
'modules': child_modules,
|
||||
'descriptors': child_descriptors,
|
||||
}
|
||||
return children
|
||||
|
||||
def setup_next_task(self, reset=False):
|
||||
"""
|
||||
Sets up the next task for the module. Creates an instance state if none exists, carries over the answer
|
||||
from the last instance state to the next if needed.
|
||||
Input: A boolean indicating whether or not the reset function is calling.
|
||||
Output: Boolean True (not useful right now)
|
||||
"""
|
||||
current_task_state = None
|
||||
if len(self.task_states) > self.current_task_number:
|
||||
current_task_state = self.task_states[self.current_task_number]
|
||||
|
||||
self.current_task_xml = self.task_xml[self.current_task_number]
|
||||
|
||||
if self.current_task_number > 0:
|
||||
self.allow_reset = self.check_allow_reset()
|
||||
if self.allow_reset:
|
||||
self.current_task_number = self.current_task_number - 1
|
||||
|
||||
current_task_type = self.get_tag_name(self.current_task_xml)
|
||||
|
||||
children = self.child_modules()
|
||||
child_task_module = children['modules'][current_task_type]
|
||||
|
||||
self.current_task_descriptor = children['descriptors'][current_task_type](self.system)
|
||||
|
||||
#This is the xml object created from the xml definition of the current task
|
||||
etree_xml = etree.fromstring(self.current_task_xml)
|
||||
|
||||
#This sends the etree_xml object through the descriptor module of the current task, and
|
||||
#returns the xml parsed by the descriptor
|
||||
self.current_task_parsed_xml = self.current_task_descriptor.definition_from_xml(etree_xml, self.system)
|
||||
if current_task_state is None and self.current_task_number == 0:
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data)
|
||||
self.task_states.append(self.current_task.get_instance_state())
|
||||
self.state = self.ASSESSING
|
||||
elif current_task_state is None and self.current_task_number > 0:
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
last_response = last_response_data['response']
|
||||
current_task_state = json.dumps({
|
||||
'state': self.ASSESSING,
|
||||
'version': self.STATE_VERSION,
|
||||
'max_score': self._max_score,
|
||||
'attempts': 0,
|
||||
'created': True,
|
||||
'history': [{'answer': last_response}],
|
||||
})
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
instance_state=current_task_state)
|
||||
self.task_states.append(self.current_task.get_instance_state())
|
||||
self.state = self.ASSESSING
|
||||
else:
|
||||
if self.current_task_number > 0 and not reset:
|
||||
current_task_state = self.overwrite_state(current_task_state)
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
instance_state=current_task_state)
|
||||
|
||||
return True
|
||||
|
||||
def check_allow_reset(self):
|
||||
"""
|
||||
Checks to see if the student has passed the criteria to move to the next module. If not, sets
|
||||
allow_reset to true and halts the student progress through the tasks.
|
||||
Input: None
|
||||
Output: the allow_reset attribute of the current module.
|
||||
"""
|
||||
if not self.allow_reset:
|
||||
if self.current_task_number > 0:
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
current_response_data = self.get_current_attributes(self.current_task_number)
|
||||
|
||||
if(current_response_data['min_score_to_attempt'] > last_response_data['score']
|
||||
or current_response_data['max_score_to_attempt'] < last_response_data['score']):
|
||||
self.state = self.DONE
|
||||
self.allow_reset = True
|
||||
|
||||
return self.allow_reset
|
||||
|
||||
def get_context(self):
|
||||
"""
|
||||
Generates a context dictionary that is used to render html.
|
||||
Input: None
|
||||
Output: A dictionary that can be rendered into the combined open ended template.
|
||||
"""
|
||||
task_html = self.get_html_base()
|
||||
#set context variables and render template
|
||||
|
||||
context = {
|
||||
'items': [{'content': task_html}],
|
||||
'ajax_url': self.system.ajax_url,
|
||||
'allow_reset': self.allow_reset,
|
||||
'state': self.state,
|
||||
'task_count': len(self.task_xml),
|
||||
'task_number': self.current_task_number + 1,
|
||||
'status': self.get_status(),
|
||||
'display_name': self.display_name,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
return context
|
||||
self.child_descriptor = descriptors[version_index](self.system)
|
||||
self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(definition['xml_string']), self.system)
|
||||
self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor,
|
||||
instance_state = json.dumps(instance_state), metadata = self.metadata, static_data= static_data)
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Gets HTML for rendering.
|
||||
Input: None
|
||||
Output: rendered html
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
return html
|
||||
|
||||
def get_html_nonsystem(self):
|
||||
"""
|
||||
Gets HTML for rendering via AJAX. Does not use system, because system contains some additional
|
||||
html, which is not appropriate for returning via ajax calls.
|
||||
Input: None
|
||||
Output: HTML rendered directly via Mako
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
return html
|
||||
|
||||
def get_html_base(self):
|
||||
"""
|
||||
Gets the HTML associated with the current child task
|
||||
Input: None
|
||||
Output: Child task HTML
|
||||
"""
|
||||
self.update_task_states()
|
||||
html = self.current_task.get_html(self.system)
|
||||
return_html = rewrite_links(html, self.rewrite_content_links)
|
||||
return return_html
|
||||
|
||||
def get_current_attributes(self, task_number):
|
||||
"""
|
||||
Gets the min and max score to attempt attributes of the specified task.
|
||||
Input: The number of the task.
|
||||
Output: The minimum and maximum scores needed to move on to the specified task.
|
||||
"""
|
||||
task_xml = self.task_xml[task_number]
|
||||
etree_xml = etree.fromstring(task_xml)
|
||||
min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0))
|
||||
max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score))
|
||||
return {'min_score_to_attempt': min_score_to_attempt, 'max_score_to_attempt': max_score_to_attempt}
|
||||
|
||||
def get_last_response(self, task_number):
|
||||
"""
|
||||
Returns data associated with the specified task number, such as the last response, score, etc.
|
||||
Input: The number of the task.
|
||||
Output: A dictionary that contains information about the specified task.
|
||||
"""
|
||||
last_response = ""
|
||||
task_state = self.task_states[task_number]
|
||||
task_xml = self.task_xml[task_number]
|
||||
task_type = self.get_tag_name(task_xml)
|
||||
|
||||
children = self.child_modules()
|
||||
|
||||
task_descriptor = children['descriptors'][task_type](self.system)
|
||||
etree_xml = etree.fromstring(task_xml)
|
||||
|
||||
min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0))
|
||||
max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score))
|
||||
|
||||
task_parsed_xml = task_descriptor.definition_from_xml(etree_xml, self.system)
|
||||
task = children['modules'][task_type](self.system, self.location, task_parsed_xml, task_descriptor,
|
||||
self.static_data, instance_state=task_state)
|
||||
last_response = task.latest_answer()
|
||||
last_score = task.latest_score()
|
||||
last_post_assessment = task.latest_post_assessment(self.system)
|
||||
last_post_feedback = ""
|
||||
if task_type == "openended":
|
||||
last_post_assessment = task.latest_post_assessment(self.system, short_feedback=False, join_feedback=False)
|
||||
if isinstance(last_post_assessment, list):
|
||||
eval_list = []
|
||||
for i in xrange(0, len(last_post_assessment)):
|
||||
eval_list.append(task.format_feedback_with_evaluation(self.system, last_post_assessment[i]))
|
||||
last_post_evaluation = "".join(eval_list)
|
||||
else:
|
||||
last_post_evaluation = task.format_feedback_with_evaluation(self.system, last_post_assessment)
|
||||
last_post_assessment = last_post_evaluation
|
||||
last_correctness = task.is_last_response_correct()
|
||||
max_score = task.max_score()
|
||||
state = task.state
|
||||
if task_type in HUMAN_TASK_TYPE:
|
||||
human_task_name = HUMAN_TASK_TYPE[task_type]
|
||||
else:
|
||||
human_task_name = task_type
|
||||
|
||||
if state in task.HUMAN_NAMES:
|
||||
human_state = task.HUMAN_NAMES[state]
|
||||
else:
|
||||
human_state = state
|
||||
last_response_dict = {
|
||||
'response': last_response,
|
||||
'score': last_score,
|
||||
'post_assessment': last_post_assessment,
|
||||
'type': task_type,
|
||||
'max_score': max_score,
|
||||
'state': state,
|
||||
'human_state': human_state,
|
||||
'human_task': human_task_name,
|
||||
'correct': last_correctness,
|
||||
'min_score_to_attempt': min_score_to_attempt,
|
||||
'max_score_to_attempt': max_score_to_attempt,
|
||||
}
|
||||
|
||||
return last_response_dict
|
||||
|
||||
def update_task_states(self):
|
||||
"""
|
||||
Updates the task state of the combined open ended module with the task state of the current child module.
|
||||
Input: None
|
||||
Output: boolean indicating whether or not the task state changed.
|
||||
"""
|
||||
changed = False
|
||||
if not self.allow_reset:
|
||||
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
|
||||
current_task_state = json.loads(self.task_states[self.current_task_number])
|
||||
if current_task_state['state'] == self.DONE:
|
||||
self.current_task_number += 1
|
||||
if self.current_task_number >= (len(self.task_xml)):
|
||||
self.state = self.DONE
|
||||
self.current_task_number = len(self.task_xml) - 1
|
||||
else:
|
||||
self.state = self.INITIAL
|
||||
changed = True
|
||||
self.setup_next_task()
|
||||
return changed
|
||||
|
||||
def update_task_states_ajax(self, return_html):
|
||||
"""
|
||||
Runs the update task states function for ajax calls. Currently the same as update_task_states
|
||||
Input: The html returned by the handle_ajax function of the child
|
||||
Output: New html that should be rendered
|
||||
"""
|
||||
changed = self.update_task_states()
|
||||
if changed:
|
||||
#return_html=self.get_html()
|
||||
pass
|
||||
return return_html
|
||||
|
||||
def get_results(self, get):
|
||||
"""
|
||||
Gets the results of a given grader via ajax.
|
||||
Input: AJAX get dictionary
|
||||
Output: Dictionary to be rendered via ajax that contains the result html.
|
||||
"""
|
||||
task_number = int(get['task_number'])
|
||||
self.update_task_states()
|
||||
response_dict = self.get_last_response(task_number)
|
||||
context = {'results': response_dict['post_assessment'], 'task_number': task_number + 1}
|
||||
html = self.system.render_template('combined_open_ended_results.html', context)
|
||||
return {'html': html, 'success': True}
|
||||
return self.child_module.get_html()
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
"""
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
"get" is request.POST.
|
||||
|
||||
Returns a json dictionary:
|
||||
{ 'progress_changed' : True/False,
|
||||
'progress': 'none'/'in_progress'/'done',
|
||||
<other request-specific values here > }
|
||||
"""
|
||||
|
||||
handlers = {
|
||||
'next_problem': self.next_problem,
|
||||
'reset': self.reset,
|
||||
'get_results': self.get_results
|
||||
}
|
||||
|
||||
if dispatch not in handlers:
|
||||
return_html = self.current_task.handle_ajax(dispatch, get, self.system)
|
||||
return self.update_task_states_ajax(return_html)
|
||||
|
||||
d = handlers[dispatch](get)
|
||||
return json.dumps(d, cls=ComplexEncoder)
|
||||
|
||||
def next_problem(self, get):
|
||||
"""
|
||||
Called via ajax to advance to the next problem.
|
||||
Input: AJAX get request.
|
||||
Output: Dictionary to be rendered
|
||||
"""
|
||||
self.update_task_states()
|
||||
return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.allow_reset}
|
||||
|
||||
def reset(self, get):
|
||||
"""
|
||||
If resetting is allowed, reset the state of the combined open ended module.
|
||||
Input: AJAX get dictionary
|
||||
Output: AJAX dictionary to tbe rendered
|
||||
"""
|
||||
if self.state != self.DONE:
|
||||
if not self.allow_reset:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
if self.attempts > self.max_attempts:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
self.state = self.INITIAL
|
||||
self.allow_reset = False
|
||||
for i in xrange(0, len(self.task_xml)):
|
||||
self.current_task_number = i
|
||||
self.setup_next_task(reset=True)
|
||||
self.current_task.reset(self.system)
|
||||
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
|
||||
self.current_task_number = 0
|
||||
self.allow_reset = False
|
||||
self.setup_next_task()
|
||||
return {'success': True, 'html': self.get_html_nonsystem()}
|
||||
return self.child_module.handle_ajax(dispatch, get)
|
||||
|
||||
def get_instance_state(self):
|
||||
"""
|
||||
Returns the current instance state. The module can be recreated from the instance state.
|
||||
Input: None
|
||||
Output: A dictionary containing the instance state.
|
||||
"""
|
||||
|
||||
state = {
|
||||
'version': self.STATE_VERSION,
|
||||
'current_task_number': self.current_task_number,
|
||||
'state': self.state,
|
||||
'task_states': self.task_states,
|
||||
'attempts': self.attempts,
|
||||
'ready_to_reset': self.allow_reset,
|
||||
}
|
||||
|
||||
return json.dumps(state)
|
||||
|
||||
def get_status(self):
|
||||
"""
|
||||
Gets the status panel to be displayed at the top right.
|
||||
Input: None
|
||||
Output: The status html to be rendered
|
||||
"""
|
||||
status = []
|
||||
for i in xrange(0, self.current_task_number + 1):
|
||||
task_data = self.get_last_response(i)
|
||||
task_data.update({'task_number': i + 1})
|
||||
status.append(task_data)
|
||||
context = {'status_list': status}
|
||||
status_html = self.system.render_template("combined_open_ended_status.html", context)
|
||||
|
||||
return status_html
|
||||
|
||||
def check_if_done_and_scored(self):
|
||||
"""
|
||||
Checks if the object is currently in a finished state (either student didn't meet criteria to move
|
||||
to next step, in which case they are in the allow_reset state, or they are done with the question
|
||||
entirely, in which case they will be in the self.DONE state), and if it is scored or not.
|
||||
@return: Boolean corresponding to the above.
|
||||
"""
|
||||
return (self.state == self.DONE or self.allow_reset) and self.is_scored
|
||||
return self.child_module.get_instance_state()
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
Score the student received on the problem, or None if there is no
|
||||
score.
|
||||
|
||||
Returns:
|
||||
dictionary
|
||||
{'score': integer, from 0 to get_max_score(),
|
||||
'total': get_max_score()}
|
||||
"""
|
||||
max_score = None
|
||||
score = None
|
||||
if self.check_if_done_and_scored():
|
||||
last_response = self.get_last_response(self.current_task_number)
|
||||
max_score = last_response['max_score']
|
||||
score = last_response['score']
|
||||
|
||||
score_dict = {
|
||||
'score': score,
|
||||
'total': max_score,
|
||||
}
|
||||
|
||||
return score_dict
|
||||
return self.child_module.get_score()
|
||||
|
||||
def max_score(self):
|
||||
''' Maximum score. Two notes:
|
||||
|
||||
* This is generic; in abstract, a problem could be 3/5 points on one
|
||||
randomization, and 5/7 on another
|
||||
'''
|
||||
max_score = None
|
||||
if self.check_if_done_and_scored():
|
||||
last_response = self.get_last_response(self.current_task_number)
|
||||
max_score = last_response['max_score']
|
||||
return max_score
|
||||
return self.child_module.max_score()
|
||||
|
||||
def get_progress(self):
|
||||
''' Return a progress.Progress object that represents how far the
|
||||
student has gone in this module. Must be implemented to get correct
|
||||
progress tracking behavior in nesting modules like sequence and
|
||||
vertical.
|
||||
return self.child_module.get_progress()
|
||||
|
||||
If this module has no notion of progress, return None.
|
||||
'''
|
||||
progress_object = Progress(self.current_task_number, len(self.task_xml))
|
||||
@property
|
||||
def due_date(self):
|
||||
return self.child_module.due_date
|
||||
|
||||
return progress_object
|
||||
@property
|
||||
def display_name(self):
|
||||
return self.child_module.display_name
|
||||
|
||||
|
||||
class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
@@ -672,20 +201,8 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
'task_xml': dictionary of xml strings,
|
||||
}
|
||||
"""
|
||||
expected_children = ['task', 'rubric', 'prompt']
|
||||
for child in expected_children:
|
||||
if len(xml_object.xpath(child)) == 0:
|
||||
raise ValueError("Combined Open Ended definition must include at least one '{0}' tag".format(child))
|
||||
|
||||
def parse_task(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))]
|
||||
|
||||
def parse(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return xml_object.xpath(k)[0]
|
||||
|
||||
return {'task_xml': parse_task('task'), 'prompt': parse('prompt'), 'rubric': parse('rubric')}
|
||||
return {'xml_string' : etree.tostring(xml_object), 'metadata' : xml_object.attrib}
|
||||
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
|
||||
725
common/lib/xmodule/xmodule/combined_open_ended_modulev1.py
Normal file
725
common/lib/xmodule/xmodule/combined_open_ended_modulev1.py
Normal file
@@ -0,0 +1,725 @@
|
||||
import copy
|
||||
from fs.errors import ResourceNotFoundError
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
from lxml import etree
|
||||
from lxml.html import rewrite_links
|
||||
from path import path
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from .capa_module import only_one, ComplexEncoder
|
||||
from .editing_module import EditingDescriptor
|
||||
from .html_checker import check_html
|
||||
from progress import Progress
|
||||
from .stringify import stringify_children
|
||||
from .x_module import XModule
|
||||
from .xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
import self_assessment_module
|
||||
import open_ended_module
|
||||
from combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError
|
||||
from .stringify import stringify_children
|
||||
import dateutil
|
||||
import dateutil.parser
|
||||
import datetime
|
||||
from timeparse import parse_timedelta
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
# Set the default number of max attempts. Should be 1 for production
|
||||
# Set higher for debugging/testing
|
||||
# attempts specified in xml definition overrides this.
|
||||
MAX_ATTEMPTS = 10000
|
||||
|
||||
# Set maximum available number of points.
|
||||
# Overriden by max_score specified in xml.
|
||||
MAX_SCORE = 1
|
||||
|
||||
#The highest score allowed for the overall xmodule and for each rubric point
|
||||
MAX_SCORE_ALLOWED = 3
|
||||
|
||||
#If true, default behavior is to score module as a practice problem. Otherwise, no grade at all is shown in progress
|
||||
#Metadata overrides this.
|
||||
IS_SCORED = False
|
||||
|
||||
#If true, then default behavior is to require a file upload or pasted link from a student for this problem.
|
||||
#Metadata overrides this.
|
||||
ACCEPT_FILE_UPLOAD = False
|
||||
|
||||
#Contains all reasonable bool and case combinations of True
|
||||
TRUE_DICT = ["True", True, "TRUE", "true"]
|
||||
|
||||
HUMAN_TASK_TYPE = {
|
||||
'selfassessment' : "Self Assessment",
|
||||
'openended' : "External Grader",
|
||||
}
|
||||
|
||||
class CombinedOpenEndedV1Module():
|
||||
"""
|
||||
This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc).
|
||||
It transitions between problems, and support arbitrary ordering.
|
||||
Each combined open ended module contains one or multiple "child" modules.
|
||||
Child modules track their own state, and can transition between states. They also implement get_html and
|
||||
handle_ajax.
|
||||
The combined open ended module transitions between child modules as appropriate, tracks its own state, and passess
|
||||
ajax requests from the browser to the child module or handles them itself (in the cases of reset and next problem)
|
||||
ajax actions implemented by all children are:
|
||||
'save_answer' -- Saves the student answer
|
||||
'save_assessment' -- Saves the student assessment (or external grader assessment)
|
||||
'save_post_assessment' -- saves a post assessment (hint, feedback on feedback, etc)
|
||||
ajax actions implemented by combined open ended module are:
|
||||
'reset' -- resets the whole combined open ended module and returns to the first child module
|
||||
'next_problem' -- moves to the next child module
|
||||
'get_results' -- gets results from a given child module
|
||||
|
||||
Types of children. Task is synonymous with child module, so each combined open ended module
|
||||
incorporates multiple children (tasks):
|
||||
openendedmodule
|
||||
selfassessmentmodule
|
||||
"""
|
||||
STATE_VERSION = 1
|
||||
|
||||
# states
|
||||
INITIAL = 'initial'
|
||||
ASSESSING = 'assessing'
|
||||
INTERMEDIATE_DONE = 'intermediate_done'
|
||||
DONE = 'done'
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/combinedopenended/display.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]}
|
||||
js_module_name = "CombinedOpenEnded"
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, metadata = None, static_data = None, **kwargs):
|
||||
|
||||
"""
|
||||
Definition file should have one or many task blocks, a rubric block, and a prompt block:
|
||||
|
||||
Sample file:
|
||||
<combinedopenended attempts="10000" max_score="1">
|
||||
<rubric>
|
||||
Blah blah rubric.
|
||||
</rubric>
|
||||
<prompt>
|
||||
Some prompt.
|
||||
</prompt>
|
||||
<task>
|
||||
<selfassessment>
|
||||
<hintprompt>
|
||||
What hint about this problem would you give to someone?
|
||||
</hintprompt>
|
||||
<submitmessage>
|
||||
Save Succcesful. Thanks for participating!
|
||||
</submitmessage>
|
||||
</selfassessment>
|
||||
</task>
|
||||
<task>
|
||||
<openended min_score_to_attempt="1" max_score_to_attempt="1">
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf",
|
||||
"problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>
|
||||
</task>
|
||||
</combinedopenended>
|
||||
|
||||
"""
|
||||
|
||||
self.metadata = metadata
|
||||
self.display_name = metadata.get('display_name', "Open Ended")
|
||||
self.rewrite_content_links = static_data.get('rewrite_content_links',"")
|
||||
|
||||
|
||||
# Load instance state
|
||||
if instance_state is not None:
|
||||
instance_state = json.loads(instance_state)
|
||||
else:
|
||||
instance_state = {}
|
||||
|
||||
#We need to set the location here so the child modules can use it
|
||||
system.set('location', location)
|
||||
self.system = system
|
||||
|
||||
#Tells the system which xml definition to load
|
||||
self.current_task_number = instance_state.get('current_task_number', 0)
|
||||
#This loads the states of the individual children
|
||||
self.task_states = instance_state.get('task_states', [])
|
||||
#Overall state of the combined open ended module
|
||||
self.state = instance_state.get('state', self.INITIAL)
|
||||
|
||||
self.attempts = instance_state.get('attempts', 0)
|
||||
|
||||
#Allow reset is true if student has failed the criteria to move to the next child task
|
||||
self.allow_reset = instance_state.get('ready_to_reset', False)
|
||||
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
|
||||
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
|
||||
|
||||
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)
|
||||
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} for location {1}".format(grace_period_string, location))
|
||||
raise
|
||||
else:
|
||||
self.grace_period = None
|
||||
self.close_date = self.display_due_date
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
self._max_score = int(self.metadata.get('max_score', MAX_SCORE))
|
||||
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
rubric_string = stringify_children(definition['rubric'])
|
||||
rubric_renderer.check_if_rubric_is_parseable(rubric_string, location, MAX_SCORE_ALLOWED, self._max_score)
|
||||
|
||||
#Static data is passed to the child modules to render
|
||||
self.static_data = {
|
||||
'max_score': self._max_score,
|
||||
'max_attempts': self.max_attempts,
|
||||
'prompt': definition['prompt'],
|
||||
'rubric': definition['rubric'],
|
||||
'display_name': self.display_name,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
'close_date' : self.close_date,
|
||||
}
|
||||
|
||||
self.task_xml = definition['task_xml']
|
||||
self.location = location
|
||||
self.setup_next_task()
|
||||
|
||||
def get_tag_name(self, xml):
|
||||
"""
|
||||
Gets the tag name of a given xml block.
|
||||
Input: XML string
|
||||
Output: The name of the root tag
|
||||
"""
|
||||
tag = etree.fromstring(xml).tag
|
||||
return tag
|
||||
|
||||
def overwrite_state(self, current_task_state):
|
||||
"""
|
||||
Overwrites an instance state and sets the latest response to the current response. This is used
|
||||
to ensure that the student response is carried over from the first child to the rest.
|
||||
Input: Task state json string
|
||||
Output: Task state json string
|
||||
"""
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
last_response = last_response_data['response']
|
||||
|
||||
loaded_task_state = json.loads(current_task_state)
|
||||
if loaded_task_state['state'] == self.INITIAL:
|
||||
loaded_task_state['state'] = self.ASSESSING
|
||||
loaded_task_state['created'] = True
|
||||
loaded_task_state['history'].append({'answer': last_response})
|
||||
current_task_state = json.dumps(loaded_task_state)
|
||||
return current_task_state
|
||||
|
||||
def child_modules(self):
|
||||
"""
|
||||
Returns the constructors associated with the child modules in a dictionary. This makes writing functions
|
||||
simpler (saves code duplication)
|
||||
Input: None
|
||||
Output: A dictionary of dictionaries containing the descriptor functions and module functions
|
||||
"""
|
||||
child_modules = {
|
||||
'openended': open_ended_module.OpenEndedModule,
|
||||
'selfassessment': self_assessment_module.SelfAssessmentModule,
|
||||
}
|
||||
child_descriptors = {
|
||||
'openended': open_ended_module.OpenEndedDescriptor,
|
||||
'selfassessment': self_assessment_module.SelfAssessmentDescriptor,
|
||||
}
|
||||
children = {
|
||||
'modules': child_modules,
|
||||
'descriptors': child_descriptors,
|
||||
}
|
||||
return children
|
||||
|
||||
def setup_next_task(self, reset=False):
|
||||
"""
|
||||
Sets up the next task for the module. Creates an instance state if none exists, carries over the answer
|
||||
from the last instance state to the next if needed.
|
||||
Input: A boolean indicating whether or not the reset function is calling.
|
||||
Output: Boolean True (not useful right now)
|
||||
"""
|
||||
current_task_state = None
|
||||
if len(self.task_states) > self.current_task_number:
|
||||
current_task_state = self.task_states[self.current_task_number]
|
||||
|
||||
self.current_task_xml = self.task_xml[self.current_task_number]
|
||||
|
||||
if self.current_task_number > 0:
|
||||
self.allow_reset = self.check_allow_reset()
|
||||
if self.allow_reset:
|
||||
self.current_task_number = self.current_task_number - 1
|
||||
|
||||
current_task_type = self.get_tag_name(self.current_task_xml)
|
||||
|
||||
children = self.child_modules()
|
||||
child_task_module = children['modules'][current_task_type]
|
||||
|
||||
self.current_task_descriptor = children['descriptors'][current_task_type](self.system)
|
||||
|
||||
#This is the xml object created from the xml definition of the current task
|
||||
etree_xml = etree.fromstring(self.current_task_xml)
|
||||
|
||||
#This sends the etree_xml object through the descriptor module of the current task, and
|
||||
#returns the xml parsed by the descriptor
|
||||
self.current_task_parsed_xml = self.current_task_descriptor.definition_from_xml(etree_xml, self.system)
|
||||
if current_task_state is None and self.current_task_number == 0:
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data)
|
||||
self.task_states.append(self.current_task.get_instance_state())
|
||||
self.state = self.ASSESSING
|
||||
elif current_task_state is None and self.current_task_number > 0:
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
last_response = last_response_data['response']
|
||||
current_task_state = json.dumps({
|
||||
'state': self.ASSESSING,
|
||||
'version': self.STATE_VERSION,
|
||||
'max_score': self._max_score,
|
||||
'attempts': 0,
|
||||
'created': True,
|
||||
'history': [{'answer': last_response}],
|
||||
})
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
instance_state=current_task_state)
|
||||
self.task_states.append(self.current_task.get_instance_state())
|
||||
self.state = self.ASSESSING
|
||||
else:
|
||||
if self.current_task_number > 0 and not reset:
|
||||
current_task_state = self.overwrite_state(current_task_state)
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
instance_state=current_task_state)
|
||||
|
||||
return True
|
||||
|
||||
def check_allow_reset(self):
|
||||
"""
|
||||
Checks to see if the student has passed the criteria to move to the next module. If not, sets
|
||||
allow_reset to true and halts the student progress through the tasks.
|
||||
Input: None
|
||||
Output: the allow_reset attribute of the current module.
|
||||
"""
|
||||
if not self.allow_reset:
|
||||
if self.current_task_number > 0:
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
current_response_data = self.get_current_attributes(self.current_task_number)
|
||||
|
||||
if(current_response_data['min_score_to_attempt'] > last_response_data['score']
|
||||
or current_response_data['max_score_to_attempt'] < last_response_data['score']):
|
||||
self.state = self.DONE
|
||||
self.allow_reset = True
|
||||
|
||||
return self.allow_reset
|
||||
|
||||
def get_context(self):
|
||||
"""
|
||||
Generates a context dictionary that is used to render html.
|
||||
Input: None
|
||||
Output: A dictionary that can be rendered into the combined open ended template.
|
||||
"""
|
||||
task_html = self.get_html_base()
|
||||
#set context variables and render template
|
||||
|
||||
context = {
|
||||
'items': [{'content': task_html}],
|
||||
'ajax_url': self.system.ajax_url,
|
||||
'allow_reset': self.allow_reset,
|
||||
'state': self.state,
|
||||
'task_count': len(self.task_xml),
|
||||
'task_number': self.current_task_number + 1,
|
||||
'status': self.get_status(),
|
||||
'display_name': self.display_name,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Gets HTML for rendering.
|
||||
Input: None
|
||||
Output: rendered html
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
return html
|
||||
|
||||
def get_html_nonsystem(self):
|
||||
"""
|
||||
Gets HTML for rendering via AJAX. Does not use system, because system contains some additional
|
||||
html, which is not appropriate for returning via ajax calls.
|
||||
Input: None
|
||||
Output: HTML rendered directly via Mako
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
return html
|
||||
|
||||
def get_html_base(self):
|
||||
"""
|
||||
Gets the HTML associated with the current child task
|
||||
Input: None
|
||||
Output: Child task HTML
|
||||
"""
|
||||
self.update_task_states()
|
||||
html = self.current_task.get_html(self.system)
|
||||
return_html = rewrite_links(html, self.rewrite_content_links)
|
||||
return return_html
|
||||
|
||||
def get_current_attributes(self, task_number):
|
||||
"""
|
||||
Gets the min and max score to attempt attributes of the specified task.
|
||||
Input: The number of the task.
|
||||
Output: The minimum and maximum scores needed to move on to the specified task.
|
||||
"""
|
||||
task_xml = self.task_xml[task_number]
|
||||
etree_xml = etree.fromstring(task_xml)
|
||||
min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0))
|
||||
max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score))
|
||||
return {'min_score_to_attempt': min_score_to_attempt, 'max_score_to_attempt': max_score_to_attempt}
|
||||
|
||||
def get_last_response(self, task_number):
|
||||
"""
|
||||
Returns data associated with the specified task number, such as the last response, score, etc.
|
||||
Input: The number of the task.
|
||||
Output: A dictionary that contains information about the specified task.
|
||||
"""
|
||||
last_response = ""
|
||||
task_state = self.task_states[task_number]
|
||||
task_xml = self.task_xml[task_number]
|
||||
task_type = self.get_tag_name(task_xml)
|
||||
|
||||
children = self.child_modules()
|
||||
|
||||
task_descriptor = children['descriptors'][task_type](self.system)
|
||||
etree_xml = etree.fromstring(task_xml)
|
||||
|
||||
min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0))
|
||||
max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score))
|
||||
|
||||
task_parsed_xml = task_descriptor.definition_from_xml(etree_xml, self.system)
|
||||
task = children['modules'][task_type](self.system, self.location, task_parsed_xml, task_descriptor,
|
||||
self.static_data, instance_state=task_state)
|
||||
last_response = task.latest_answer()
|
||||
last_score = task.latest_score()
|
||||
last_post_assessment = task.latest_post_assessment(self.system)
|
||||
last_post_feedback = ""
|
||||
if task_type == "openended":
|
||||
last_post_assessment = task.latest_post_assessment(self.system, short_feedback=False, join_feedback=False)
|
||||
if isinstance(last_post_assessment, list):
|
||||
eval_list = []
|
||||
for i in xrange(0, len(last_post_assessment)):
|
||||
eval_list.append(task.format_feedback_with_evaluation(self.system, last_post_assessment[i]))
|
||||
last_post_evaluation = "".join(eval_list)
|
||||
else:
|
||||
last_post_evaluation = task.format_feedback_with_evaluation(self.system, last_post_assessment)
|
||||
last_post_assessment = last_post_evaluation
|
||||
last_correctness = task.is_last_response_correct()
|
||||
max_score = task.max_score()
|
||||
state = task.state
|
||||
if task_type in HUMAN_TASK_TYPE:
|
||||
human_task_name = HUMAN_TASK_TYPE[task_type]
|
||||
else:
|
||||
human_task_name = task_type
|
||||
|
||||
if state in task.HUMAN_NAMES:
|
||||
human_state = task.HUMAN_NAMES[state]
|
||||
else:
|
||||
human_state = state
|
||||
last_response_dict = {
|
||||
'response': last_response,
|
||||
'score': last_score,
|
||||
'post_assessment': last_post_assessment,
|
||||
'type': task_type,
|
||||
'max_score': max_score,
|
||||
'state': state,
|
||||
'human_state': human_state,
|
||||
'human_task': human_task_name,
|
||||
'correct': last_correctness,
|
||||
'min_score_to_attempt': min_score_to_attempt,
|
||||
'max_score_to_attempt': max_score_to_attempt,
|
||||
}
|
||||
|
||||
return last_response_dict
|
||||
|
||||
def update_task_states(self):
|
||||
"""
|
||||
Updates the task state of the combined open ended module with the task state of the current child module.
|
||||
Input: None
|
||||
Output: boolean indicating whether or not the task state changed.
|
||||
"""
|
||||
changed = False
|
||||
if not self.allow_reset:
|
||||
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
|
||||
current_task_state = json.loads(self.task_states[self.current_task_number])
|
||||
if current_task_state['state'] == self.DONE:
|
||||
self.current_task_number += 1
|
||||
if self.current_task_number >= (len(self.task_xml)):
|
||||
self.state = self.DONE
|
||||
self.current_task_number = len(self.task_xml) - 1
|
||||
else:
|
||||
self.state = self.INITIAL
|
||||
changed = True
|
||||
self.setup_next_task()
|
||||
return changed
|
||||
|
||||
def update_task_states_ajax(self, return_html):
|
||||
"""
|
||||
Runs the update task states function for ajax calls. Currently the same as update_task_states
|
||||
Input: The html returned by the handle_ajax function of the child
|
||||
Output: New html that should be rendered
|
||||
"""
|
||||
changed = self.update_task_states()
|
||||
if changed:
|
||||
#return_html=self.get_html()
|
||||
pass
|
||||
return return_html
|
||||
|
||||
def get_results(self, get):
|
||||
"""
|
||||
Gets the results of a given grader via ajax.
|
||||
Input: AJAX get dictionary
|
||||
Output: Dictionary to be rendered via ajax that contains the result html.
|
||||
"""
|
||||
task_number = int(get['task_number'])
|
||||
self.update_task_states()
|
||||
response_dict = self.get_last_response(task_number)
|
||||
context = {'results': response_dict['post_assessment'], 'task_number': task_number + 1}
|
||||
html = self.system.render_template('combined_open_ended_results.html', context)
|
||||
return {'html': html, 'success': True}
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
"""
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
"get" is request.POST.
|
||||
|
||||
Returns a json dictionary:
|
||||
{ 'progress_changed' : True/False,
|
||||
'progress': 'none'/'in_progress'/'done',
|
||||
<other request-specific values here > }
|
||||
"""
|
||||
|
||||
handlers = {
|
||||
'next_problem': self.next_problem,
|
||||
'reset': self.reset,
|
||||
'get_results': self.get_results
|
||||
}
|
||||
|
||||
if dispatch not in handlers:
|
||||
return_html = self.current_task.handle_ajax(dispatch, get, self.system)
|
||||
return self.update_task_states_ajax(return_html)
|
||||
|
||||
d = handlers[dispatch](get)
|
||||
return json.dumps(d, cls=ComplexEncoder)
|
||||
|
||||
def next_problem(self, get):
|
||||
"""
|
||||
Called via ajax to advance to the next problem.
|
||||
Input: AJAX get request.
|
||||
Output: Dictionary to be rendered
|
||||
"""
|
||||
self.update_task_states()
|
||||
return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.allow_reset}
|
||||
|
||||
def reset(self, get):
|
||||
"""
|
||||
If resetting is allowed, reset the state of the combined open ended module.
|
||||
Input: AJAX get dictionary
|
||||
Output: AJAX dictionary to tbe rendered
|
||||
"""
|
||||
if self.state != self.DONE:
|
||||
if not self.allow_reset:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
if self.attempts > self.max_attempts:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
self.state = self.INITIAL
|
||||
self.allow_reset = False
|
||||
for i in xrange(0, len(self.task_xml)):
|
||||
self.current_task_number = i
|
||||
self.setup_next_task(reset=True)
|
||||
self.current_task.reset(self.system)
|
||||
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
|
||||
self.current_task_number = 0
|
||||
self.allow_reset = False
|
||||
self.setup_next_task()
|
||||
return {'success': True, 'html': self.get_html_nonsystem()}
|
||||
|
||||
def get_instance_state(self):
|
||||
"""
|
||||
Returns the current instance state. The module can be recreated from the instance state.
|
||||
Input: None
|
||||
Output: A dictionary containing the instance state.
|
||||
"""
|
||||
|
||||
state = {
|
||||
'version': self.STATE_VERSION,
|
||||
'current_task_number': self.current_task_number,
|
||||
'state': self.state,
|
||||
'task_states': self.task_states,
|
||||
'attempts': self.attempts,
|
||||
'ready_to_reset': self.allow_reset,
|
||||
}
|
||||
|
||||
return json.dumps(state)
|
||||
|
||||
def get_status(self):
|
||||
"""
|
||||
Gets the status panel to be displayed at the top right.
|
||||
Input: None
|
||||
Output: The status html to be rendered
|
||||
"""
|
||||
status = []
|
||||
for i in xrange(0, self.current_task_number + 1):
|
||||
task_data = self.get_last_response(i)
|
||||
task_data.update({'task_number': i + 1})
|
||||
status.append(task_data)
|
||||
context = {'status_list': status}
|
||||
status_html = self.system.render_template("combined_open_ended_status.html", context)
|
||||
|
||||
return status_html
|
||||
|
||||
def check_if_done_and_scored(self):
|
||||
"""
|
||||
Checks if the object is currently in a finished state (either student didn't meet criteria to move
|
||||
to next step, in which case they are in the allow_reset state, or they are done with the question
|
||||
entirely, in which case they will be in the self.DONE state), and if it is scored or not.
|
||||
@return: Boolean corresponding to the above.
|
||||
"""
|
||||
return (self.state == self.DONE or self.allow_reset) and self.is_scored
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
Score the student received on the problem, or None if there is no
|
||||
score.
|
||||
|
||||
Returns:
|
||||
dictionary
|
||||
{'score': integer, from 0 to get_max_score(),
|
||||
'total': get_max_score()}
|
||||
"""
|
||||
max_score = None
|
||||
score = None
|
||||
if self.check_if_done_and_scored():
|
||||
last_response = self.get_last_response(self.current_task_number)
|
||||
max_score = last_response['max_score']
|
||||
score = last_response['score']
|
||||
|
||||
score_dict = {
|
||||
'score': score,
|
||||
'total': max_score,
|
||||
}
|
||||
|
||||
return score_dict
|
||||
|
||||
def max_score(self):
|
||||
''' Maximum score. Two notes:
|
||||
|
||||
* This is generic; in abstract, a problem could be 3/5 points on one
|
||||
randomization, and 5/7 on another
|
||||
'''
|
||||
max_score = None
|
||||
if self.check_if_done_and_scored():
|
||||
last_response = self.get_last_response(self.current_task_number)
|
||||
max_score = last_response['max_score']
|
||||
return max_score
|
||||
|
||||
def get_progress(self):
|
||||
''' Return a progress.Progress object that represents how far the
|
||||
student has gone in this module. Must be implemented to get correct
|
||||
progress tracking behavior in nesting modules like sequence and
|
||||
vertical.
|
||||
|
||||
If this module has no notion of progress, return None.
|
||||
'''
|
||||
progress_object = Progress(self.current_task_number, len(self.task_xml))
|
||||
|
||||
return progress_object
|
||||
|
||||
|
||||
class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
Module for adding combined open ended questions
|
||||
"""
|
||||
mako_template = "widgets/html-edit.html"
|
||||
module_class = CombinedOpenEndedV1Module
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = "combinedopenended"
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
|
||||
js_module_name = "HTMLEditingDescriptor"
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
Pull out the individual tasks, the rubric, and the prompt, and parse
|
||||
|
||||
Returns:
|
||||
{
|
||||
'rubric': 'some-html',
|
||||
'prompt': 'some-html',
|
||||
'task_xml': dictionary of xml strings,
|
||||
}
|
||||
"""
|
||||
expected_children = ['task', 'rubric', 'prompt']
|
||||
for child in expected_children:
|
||||
if len(xml_object.xpath(child)) == 0:
|
||||
raise ValueError("Combined Open Ended definition must include at least one '{0}' tag".format(child))
|
||||
|
||||
def parse_task(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))]
|
||||
|
||||
def parse(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return xml_object.xpath(k)[0]
|
||||
|
||||
return {'task_xml': parse_task('task'), 'prompt': parse('prompt'), 'rubric': parse('rubric')}
|
||||
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
'''Return an xml element representing this definition.'''
|
||||
elt = etree.Element('combinedopenended')
|
||||
|
||||
def add_child(k):
|
||||
child_str = '<{tag}>{body}</{tag}>'.format(tag=k, body=self.definition[k])
|
||||
child_node = etree.fromstring(child_str)
|
||||
elt.append(child_node)
|
||||
|
||||
for child in ['task']:
|
||||
add_child(child)
|
||||
|
||||
return elt
|
||||
@@ -1,12 +1,14 @@
|
||||
import logging
|
||||
from lxml import etree
|
||||
|
||||
log=logging.getLogger(__name__)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RubricParsingError(Exception):
|
||||
def __init__(self, msg):
|
||||
self.msg = msg
|
||||
|
||||
|
||||
class CombinedOpenEndedRubric(object):
|
||||
|
||||
def __init__ (self, system, view_only = False):
|
||||
@@ -27,16 +29,21 @@ class CombinedOpenEndedRubric(object):
|
||||
success = False
|
||||
try:
|
||||
rubric_categories = self.extract_categories(rubric_xml)
|
||||
max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories)
|
||||
max_score = max(max_scores)
|
||||
html = self.system.render_template('open_ended_rubric.html',
|
||||
{'categories' : rubric_categories,
|
||||
{'categories': rubric_categories,
|
||||
'has_score': self.has_score,
|
||||
'view_only': self.view_only})
|
||||
'view_only': self.view_only,
|
||||
'max_score': max_score})
|
||||
success = True
|
||||
except:
|
||||
raise RubricParsingError("[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml))
|
||||
error_message = "[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml)
|
||||
log.error(error_message)
|
||||
raise RubricParsingError(error_message)
|
||||
return success, html
|
||||
|
||||
def check_if_rubric_is_parseable(self, rubric_string, location, max_score_allowed):
|
||||
def check_if_rubric_is_parseable(self, rubric_string, location, max_score_allowed, max_score):
|
||||
success, rubric_feedback = self.render_rubric(rubric_string)
|
||||
if not success:
|
||||
error_message = "Could not parse rubric : {0} for location {1}".format(rubric_string, location.url())
|
||||
@@ -44,13 +51,21 @@ class CombinedOpenEndedRubric(object):
|
||||
raise RubricParsingError(error_message)
|
||||
|
||||
rubric_categories = self.extract_categories(rubric_string)
|
||||
total = 0
|
||||
for category in rubric_categories:
|
||||
total = total + len(category['options']) - 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(
|
||||
len(category['options']), max_score_allowed)
|
||||
log.error(error_message)
|
||||
raise RubricParsingError(error_message)
|
||||
|
||||
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(
|
||||
max_score, location, total)
|
||||
log.error(error_msg)
|
||||
raise RubricParsingError(error_msg)
|
||||
|
||||
def extract_categories(self, element):
|
||||
'''
|
||||
Contstruct a list of categories such that the structure looks like:
|
||||
@@ -58,8 +73,8 @@ class CombinedOpenEndedRubric(object):
|
||||
options: [{text: "Option 1 Name", points: 0}, {text:"Option 2 Name", points: 5}]
|
||||
},
|
||||
{ category: "Category 2 Name",
|
||||
options: [{text: "Option 1 Name", points: 0},
|
||||
{text: "Option 2 Name", points: 1},
|
||||
options: [{text: "Option 1 Name", points: 0},
|
||||
{text: "Option 2 Name", points: 1},
|
||||
{text: "Option 3 Name", points: 2]}]
|
||||
|
||||
'''
|
||||
@@ -75,7 +90,7 @@ class CombinedOpenEndedRubric(object):
|
||||
|
||||
|
||||
def extract_category(self, category):
|
||||
'''
|
||||
'''
|
||||
construct an individual category
|
||||
{category: "Category 1 Name",
|
||||
options: [{text: "Option 1 text", points: 1},
|
||||
@@ -108,7 +123,7 @@ class CombinedOpenEndedRubric(object):
|
||||
autonumbering = True
|
||||
# parse options
|
||||
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))
|
||||
else:
|
||||
pointstr = option.get("points")
|
||||
@@ -125,7 +140,7 @@ class CombinedOpenEndedRubric(object):
|
||||
cur_points = cur_points + 1
|
||||
else:
|
||||
raise Exception("[extract_category]: missing points attribute. Cannot continue to auto-create points values after a points value is explicitly defined.")
|
||||
|
||||
|
||||
selected = score == points
|
||||
optiontext = option.text
|
||||
options.append({'text': option.text, 'points': points, 'selected': selected})
|
||||
|
||||
@@ -9,12 +9,13 @@ from pkg_resources import resource_string
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
|
||||
class ConditionalModule(XModule):
|
||||
'''
|
||||
Blocks child module from showing unless certain conditions are met.
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
<conditional condition="require_completed" required="tag/url_name1&tag/url_name2">
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
@@ -37,13 +38,17 @@ class ConditionalModule(XModule):
|
||||
def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs):
|
||||
"""
|
||||
In addition to the normal XModule init, provide:
|
||||
|
||||
|
||||
self.condition = string describing condition required
|
||||
|
||||
"""
|
||||
XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs)
|
||||
self.contents = None
|
||||
self.condition = self.metadata.get('condition','')
|
||||
self.condition = self.metadata.get('condition', '')
|
||||
self._get_required_modules()
|
||||
children = self.get_display_items()
|
||||
if children:
|
||||
self.icon_class = children[0].get_icon_class()
|
||||
#log.debug('conditional module required=%s' % self.required_modules_list)
|
||||
|
||||
def _get_required_modules(self):
|
||||
@@ -56,7 +61,7 @@ class ConditionalModule(XModule):
|
||||
def is_condition_satisfied(self):
|
||||
self._get_required_modules()
|
||||
|
||||
if self.condition=='require_completed':
|
||||
if self.condition == 'require_completed':
|
||||
# all required modules must be completed, as determined by
|
||||
# the modules .is_completed() method
|
||||
for module in self.required_modules:
|
||||
@@ -70,7 +75,7 @@ class ConditionalModule(XModule):
|
||||
else:
|
||||
log.debug('conditional module: %s IS completed' % module)
|
||||
return True
|
||||
elif self.condition=='require_attempted':
|
||||
elif self.condition == 'require_attempted':
|
||||
# all required modules must be attempted, as determined by
|
||||
# the modules .is_attempted() method
|
||||
for module in self.required_modules:
|
||||
@@ -111,9 +116,10 @@ class ConditionalModule(XModule):
|
||||
|
||||
# for now, just deal with one child
|
||||
html = self.contents[0]
|
||||
|
||||
|
||||
return json.dumps({'html': html})
|
||||
|
||||
|
||||
class ConditionalDescriptor(SequenceDescriptor):
|
||||
module_class = ConditionalModule
|
||||
|
||||
@@ -125,17 +131,23 @@ class ConditionalDescriptor(SequenceDescriptor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ConditionalDescriptor, self).__init__(*args, **kwargs)
|
||||
|
||||
required_module_list = [tuple(x.split('/',1)) for x in self.metadata.get('required','').split('&')]
|
||||
required_module_list = [tuple(x.split('/', 1)) for x in self.metadata.get('required', '').split('&')]
|
||||
self.required_module_locations = []
|
||||
for (tag, name) in required_module_list:
|
||||
for rm in required_module_list:
|
||||
try:
|
||||
(tag, name) = rm
|
||||
except Exception as err:
|
||||
msg = "Specification of required module in conditional is broken: %s" % self.metadata.get('required')
|
||||
log.warning(msg)
|
||||
self.system.error_tracker(msg)
|
||||
continue
|
||||
loc = self.location.dict()
|
||||
loc['category'] = tag
|
||||
loc['name'] = name
|
||||
self.required_module_locations.append(Location(loc))
|
||||
log.debug('ConditionalDescriptor required_module_locations=%s' % self.required_module_locations)
|
||||
|
||||
|
||||
def get_required_module_descriptors(self):
|
||||
"""Returns a list of XModuleDescritpor instances upon which this module depends, but are
|
||||
not children of this module"""
|
||||
return [self.system.load_item(loc) for loc in self.required_module_locations]
|
||||
|
||||
|
||||
@@ -11,15 +11,16 @@ from xmodule.modulestore import Location
|
||||
from .django import contentstore
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class StaticContent(object):
|
||||
def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None, import_path=None):
|
||||
self.location = loc
|
||||
self.name = name #a display string which can be edited, and thus not part of the location which needs to be fixed
|
||||
self.name = name # a display string which can be edited, and thus not part of the location which needs to be fixed
|
||||
self.content_type = content_type
|
||||
self.data = data
|
||||
self.last_modified_at = last_modified_at
|
||||
self.thumbnail_location = Location(thumbnail_location) if thumbnail_location is not None else None
|
||||
# optional information about where this file was imported from. This is needed to support import/export
|
||||
# optional information about where this file was imported from. This is needed to support import/export
|
||||
# cycles
|
||||
self.import_path = import_path
|
||||
|
||||
@@ -29,7 +30,7 @@ class StaticContent(object):
|
||||
|
||||
@staticmethod
|
||||
def generate_thumbnail_name(original_name):
|
||||
return ('{0}'+XASSET_THUMBNAIL_TAIL_NAME).format(os.path.splitext(original_name)[0])
|
||||
return ('{0}' + XASSET_THUMBNAIL_TAIL_NAME).format(os.path.splitext(original_name)[0])
|
||||
|
||||
@staticmethod
|
||||
def compute_location(org, course, name, revision=None, is_thumbnail=False):
|
||||
@@ -41,7 +42,7 @@ class StaticContent(object):
|
||||
|
||||
def get_url_path(self):
|
||||
return StaticContent.get_url_path_from_location(self.location)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_url_path_from_location(location):
|
||||
if location is not None:
|
||||
@@ -56,15 +57,15 @@ class StaticContent(object):
|
||||
|
||||
@staticmethod
|
||||
def get_id_from_location(location):
|
||||
return { 'tag':location.tag, 'org' : location.org, 'course' : location.course,
|
||||
'category' : location.category, 'name' : location.name,
|
||||
'revision' : location.revision}
|
||||
return {'tag': location.tag, 'org': location.org, 'course': location.course,
|
||||
'category': location.category, 'name': location.name,
|
||||
'revision': location.revision}
|
||||
@staticmethod
|
||||
def get_location_from_path(path):
|
||||
# remove leading / character if it is there one
|
||||
if path.startswith('/'):
|
||||
path = path[1:]
|
||||
|
||||
|
||||
return Location(path.split('/'))
|
||||
|
||||
@staticmethod
|
||||
@@ -77,7 +78,7 @@ class StaticContent(object):
|
||||
return StaticContent.get_url_path_from_location(loc)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ContentStore(object):
|
||||
'''
|
||||
@@ -95,14 +96,14 @@ class ContentStore(object):
|
||||
|
||||
[
|
||||
|
||||
{u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'},
|
||||
{u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'},
|
||||
|
||||
{u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
{u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'ff1532598830e3feac91c2449eaa60d6'},
|
||||
|
||||
....
|
||||
@@ -117,7 +118,7 @@ class ContentStore(object):
|
||||
thumbnail_name = StaticContent.generate_thumbnail_name(content.location.name)
|
||||
|
||||
thumbnail_file_location = StaticContent.compute_location(content.location.org, content.location.course,
|
||||
thumbnail_name, is_thumbnail = True)
|
||||
thumbnail_name, is_thumbnail=True)
|
||||
|
||||
# if we're uploading an image, then let's generate a thumbnail so that we can
|
||||
# serve it up when needed without having to rescale on the fly
|
||||
@@ -129,7 +130,7 @@ class ContentStore(object):
|
||||
# @todo: move the thumbnail size to a configuration setting?!?
|
||||
im = Image.open(StringIO.StringIO(content.data))
|
||||
|
||||
# I've seen some exceptions from the PIL library when trying to save palletted
|
||||
# I've seen some exceptions from the PIL library when trying to save palletted
|
||||
# PNG files to JPEG. Per the google-universe, they suggest converting to RGB first.
|
||||
im = im.convert('RGB')
|
||||
size = 128, 128
|
||||
@@ -139,7 +140,7 @@ class ContentStore(object):
|
||||
thumbnail_file.seek(0)
|
||||
|
||||
# store this thumbnail as any other piece of content
|
||||
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
|
||||
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
|
||||
'image/jpeg', thumbnail_file)
|
||||
|
||||
contentstore().save(thumbnail_content)
|
||||
@@ -149,7 +150,3 @@ class ContentStore(object):
|
||||
logging.exception("Failed to generate thumbnail for {0}. Exception: {1}".format(content.location, str(e)))
|
||||
|
||||
return thumbnail_content, thumbnail_file_location
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.conf import settings
|
||||
|
||||
_CONTENTSTORE = None
|
||||
|
||||
|
||||
def load_function(path):
|
||||
"""
|
||||
Load a function by name.
|
||||
|
||||
@@ -17,14 +17,14 @@ import os
|
||||
|
||||
class MongoContentStore(ContentStore):
|
||||
def __init__(self, host, db, port=27017, user=None, password=None, **kwargs):
|
||||
logging.debug( 'Using MongoDB for static content serving at host={0} db={1}'.format(host,db))
|
||||
logging.debug('Using MongoDB for static content serving at host={0} db={1}'.format(host, db))
|
||||
_db = Connection(host=host, port=port, **kwargs)[db]
|
||||
|
||||
if user is not None and password is not None:
|
||||
_db.authenticate(user, password)
|
||||
|
||||
self.fs = gridfs.GridFS(_db)
|
||||
self.fs_files = _db["fs.files"] # the underlying collection GridFS uses
|
||||
self.fs_files = _db["fs.files"] # the underlying collection GridFS uses
|
||||
|
||||
|
||||
def save(self, content):
|
||||
@@ -33,24 +33,24 @@ class MongoContentStore(ContentStore):
|
||||
# Seems like with the GridFS we can't update existing ID's we have to do a delete/add pair
|
||||
self.delete(id)
|
||||
|
||||
with self.fs.new_file(_id = id, filename=content.get_url_path(), content_type=content.content_type,
|
||||
with self.fs.new_file(_id=id, filename=content.get_url_path(), content_type=content.content_type,
|
||||
displayname=content.name, thumbnail_location=content.thumbnail_location, import_path=content.import_path) as fp:
|
||||
|
||||
fp.write(content.data)
|
||||
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def delete(self, id):
|
||||
if self.fs.exists({"_id" : id}):
|
||||
if self.fs.exists({"_id": id}):
|
||||
self.fs.delete(id)
|
||||
|
||||
def find(self, location):
|
||||
id = StaticContent.get_id_from_location(location)
|
||||
try:
|
||||
with self.fs.get(id) as fp:
|
||||
return StaticContent(location, fp.displayname, fp.content_type, fp.read(),
|
||||
fp.uploadDate, thumbnail_location = fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
|
||||
import_path = fp.import_path if hasattr(fp, 'import_path') else None)
|
||||
return StaticContent(location, fp.displayname, fp.content_type, fp.read(),
|
||||
fp.uploadDate, thumbnail_location=fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
|
||||
import_path=fp.import_path if hasattr(fp, 'import_path') else None)
|
||||
except NoFile:
|
||||
raise NotFoundError()
|
||||
|
||||
@@ -76,25 +76,25 @@ class MongoContentStore(ContentStore):
|
||||
self.export(asset_location, output_directory)
|
||||
|
||||
def get_all_content_thumbnails_for_course(self, location):
|
||||
return self._get_all_content_for_course(location, get_thumbnails = True)
|
||||
return self._get_all_content_for_course(location, get_thumbnails=True)
|
||||
|
||||
def get_all_content_for_course(self, location):
|
||||
return self._get_all_content_for_course(location, get_thumbnails = False)
|
||||
return self._get_all_content_for_course(location, get_thumbnails=False)
|
||||
|
||||
def _get_all_content_for_course(self, location, get_thumbnails = False):
|
||||
def _get_all_content_for_course(self, location, get_thumbnails=False):
|
||||
'''
|
||||
Returns a list of all static assets for a course. The return format is a list of dictionary elements. Example:
|
||||
|
||||
[
|
||||
|
||||
{u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'},
|
||||
{u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'},
|
||||
|
||||
{u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
{u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073,
|
||||
u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg',
|
||||
u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x',
|
||||
u'org': u'MITx', u'revision': None}, u'md5': u'ff1532598830e3feac91c2449eaa60d6'},
|
||||
|
||||
....
|
||||
@@ -102,10 +102,7 @@ class MongoContentStore(ContentStore):
|
||||
]
|
||||
'''
|
||||
course_filter = Location(XASSET_LOCATION_TAG, category="asset" if not get_thumbnails else "thumbnail",
|
||||
course=location.course,org=location.org)
|
||||
course=location.course, org=location.org)
|
||||
# 'borrow' the function 'location_to_query' from the Mongo modulestore implementation
|
||||
items = self.fs_files.find(location_to_query(course_filter))
|
||||
return list(items)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -147,37 +147,37 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
"""
|
||||
Return a dict which is a copy of the default grading policy
|
||||
"""
|
||||
default = {"GRADER" : [
|
||||
default = {"GRADER": [
|
||||
{
|
||||
"type" : "Homework",
|
||||
"min_count" : 12,
|
||||
"drop_count" : 2,
|
||||
"short_label" : "HW",
|
||||
"weight" : 0.15
|
||||
"type": "Homework",
|
||||
"min_count": 12,
|
||||
"drop_count": 2,
|
||||
"short_label": "HW",
|
||||
"weight": 0.15
|
||||
},
|
||||
{
|
||||
"type" : "Lab",
|
||||
"min_count" : 12,
|
||||
"drop_count" : 2,
|
||||
"weight" : 0.15
|
||||
"type": "Lab",
|
||||
"min_count": 12,
|
||||
"drop_count": 2,
|
||||
"weight": 0.15
|
||||
},
|
||||
{
|
||||
"type" : "Midterm Exam",
|
||||
"short_label" : "Midterm",
|
||||
"min_count" : 1,
|
||||
"drop_count" : 0,
|
||||
"weight" : 0.3
|
||||
"type": "Midterm Exam",
|
||||
"short_label": "Midterm",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"weight": 0.3
|
||||
},
|
||||
{
|
||||
"type" : "Final Exam",
|
||||
"short_label" : "Final",
|
||||
"min_count" : 1,
|
||||
"drop_count" : 0,
|
||||
"weight" : 0.4
|
||||
"type": "Final Exam",
|
||||
"short_label": "Final",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"weight": 0.4
|
||||
}
|
||||
],
|
||||
"GRADE_CUTOFFS" : {
|
||||
"Pass" : 0.5
|
||||
"GRADE_CUTOFFS": {
|
||||
"Pass": 0.5
|
||||
}}
|
||||
return copy.deepcopy(default)
|
||||
|
||||
@@ -230,8 +230,8 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
# bleh, have to parse the XML here to just pull out the url_name attribute
|
||||
# I don't think it's stored anywhere in the instance.
|
||||
course_file = StringIO(xml_data.encode('ascii','ignore'))
|
||||
xml_obj = etree.parse(course_file,parser=edx_xml_parser).getroot()
|
||||
course_file = StringIO(xml_data.encode('ascii', 'ignore'))
|
||||
xml_obj = etree.parse(course_file, parser=edx_xml_parser).getroot()
|
||||
|
||||
policy_dir = None
|
||||
url_name = xml_obj.get('url_name', xml_obj.get('slug'))
|
||||
@@ -329,7 +329,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
def raw_grader(self, value):
|
||||
# NOTE WELL: this change will not update the processed graders. If we need that, this needs to call grader_from_conf
|
||||
self._grading_policy['RAW_GRADER'] = value
|
||||
self.definition['data'].setdefault('grading_policy',{})['GRADER'] = value
|
||||
self.definition['data'].setdefault('grading_policy', {})['GRADER'] = value
|
||||
|
||||
@property
|
||||
def grade_cutoffs(self):
|
||||
@@ -338,7 +338,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
@grade_cutoffs.setter
|
||||
def grade_cutoffs(self, value):
|
||||
self._grading_policy['GRADE_CUTOFFS'] = value
|
||||
self.definition['data'].setdefault('grading_policy',{})['GRADE_CUTOFFS'] = value
|
||||
self.definition['data'].setdefault('grading_policy', {})['GRADE_CUTOFFS'] = value
|
||||
|
||||
|
||||
@property
|
||||
@@ -377,7 +377,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
Return list of topic ids defined in course policy.
|
||||
"""
|
||||
topics = self.metadata.get("discussion_topics", {})
|
||||
return [d["id"] for d in topics.values()]
|
||||
return [d["id"] for d in topics.values()]
|
||||
|
||||
|
||||
@property
|
||||
@@ -436,17 +436,17 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
scale = 300.0 # about a year
|
||||
if announcement:
|
||||
days = (now - announcement).days
|
||||
score = -exp(-days/scale)
|
||||
score = -exp(-days / scale)
|
||||
else:
|
||||
days = (now - start).days
|
||||
score = exp(days/scale)
|
||||
score = exp(days / scale)
|
||||
return score
|
||||
|
||||
def _sorting_dates(self):
|
||||
# utility function to get datetime objects for dates used to
|
||||
# compute the is_new flag and the sorting_score
|
||||
def to_datetime(timestamp):
|
||||
return datetime.fromtimestamp(time.mktime(timestamp))
|
||||
return datetime(*timestamp[:6])
|
||||
|
||||
def get_date(field):
|
||||
timetuple = self._try_parse_time(field)
|
||||
@@ -501,16 +501,16 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
xmoduledescriptors.append(s)
|
||||
|
||||
# The xmoduledescriptors included here are only the ones that have scores.
|
||||
section_description = { 'section_descriptor' : s, 'xmoduledescriptors' : filter(lambda child: child.has_score, xmoduledescriptors) }
|
||||
section_description = {'section_descriptor': s, 'xmoduledescriptors': filter(lambda child: child.has_score, xmoduledescriptors)}
|
||||
|
||||
section_format = s.metadata.get('format', "")
|
||||
graded_sections[ section_format ] = graded_sections.get( section_format, [] ) + [section_description]
|
||||
graded_sections[section_format] = graded_sections.get(section_format, []) + [section_description]
|
||||
|
||||
all_descriptors.extend(xmoduledescriptors)
|
||||
all_descriptors.append(s)
|
||||
|
||||
return { 'graded_sections' : graded_sections,
|
||||
'all_descriptors' : all_descriptors,}
|
||||
return {'graded_sections': graded_sections,
|
||||
'all_descriptors': all_descriptors, }
|
||||
|
||||
|
||||
@staticmethod
|
||||
@@ -636,7 +636,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
# *end* of the same day, not the same time. It's going to be used as the
|
||||
# end of the exam overall, so we don't want the exam to disappear too soon.
|
||||
# It's also used optionally as the registration end date, so time matters there too.
|
||||
self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date
|
||||
self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date
|
||||
if self.last_eligible_appointment_date is None:
|
||||
raise ValueError("Last appointment date must be specified")
|
||||
self.registration_start_date = self._try_parse_time('Registration_Start_Date') or time.gmtime(0)
|
||||
@@ -648,7 +648,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
raise ValueError("First appointment date must be before last appointment date")
|
||||
if self.registration_end_date > self.last_eligible_appointment_date:
|
||||
raise ValueError("Registration end date must be before last appointment date")
|
||||
|
||||
self.exam_url = exam_info.get('Exam_URL')
|
||||
|
||||
def _try_parse_time(self, key):
|
||||
"""
|
||||
@@ -704,6 +704,10 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
else:
|
||||
return None
|
||||
|
||||
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]
|
||||
return exams[0] if len(exams) == 1 else None
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self.display_name
|
||||
@@ -715,4 +719,3 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
@property
|
||||
def org(self):
|
||||
return self.location.org
|
||||
|
||||
|
||||
@@ -231,47 +231,6 @@ div.result-container {
|
||||
}
|
||||
}
|
||||
|
||||
div.result-container, section.open-ended-child {
|
||||
.rubric {
|
||||
margin-bottom:25px;
|
||||
tr {
|
||||
margin:10px 0px;
|
||||
height: 100%;
|
||||
}
|
||||
td {
|
||||
padding: 20px 0px 25px 0px;
|
||||
margin: 10px 0px;
|
||||
height: 100%;
|
||||
}
|
||||
th {
|
||||
padding: 5px;
|
||||
margin: 5px;
|
||||
}
|
||||
label,
|
||||
.view-only {
|
||||
margin:2px;
|
||||
position: relative;
|
||||
padding: 10px 15px 25px 15px;
|
||||
width: 145px;
|
||||
height:100%;
|
||||
display: inline-block;
|
||||
min-height: 50px;
|
||||
min-width: 50px;
|
||||
background-color: #CCC;
|
||||
font-size: .85em;
|
||||
}
|
||||
.grade {
|
||||
position: absolute;
|
||||
bottom:0px;
|
||||
right:0px;
|
||||
margin:10px;
|
||||
}
|
||||
.selected-grade {
|
||||
background: #666;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section.open-ended-child {
|
||||
@media print {
|
||||
@@ -442,12 +401,12 @@ section.open-ended-child {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
span.short-form-response {
|
||||
padding: 9px;
|
||||
div.short-form-response {
|
||||
background: #F6F6F6;
|
||||
border: 1px solid #ddd;
|
||||
border-top: 0;
|
||||
margin-bottom: 20px;
|
||||
overflow-y: auto;
|
||||
height: 200px;
|
||||
@include clearfix;
|
||||
}
|
||||
|
||||
@@ -585,11 +544,6 @@ section.open-ended-child {
|
||||
}
|
||||
|
||||
.submission_feedback {
|
||||
// background: #F3F3F3;
|
||||
// border: 1px solid #ddd;
|
||||
// @include border-radius(3px);
|
||||
// padding: 8px 12px;
|
||||
// margin-top: 10px;
|
||||
@include inline-block;
|
||||
font-style: italic;
|
||||
margin: 8px 0 0 10px;
|
||||
|
||||
@@ -52,13 +52,17 @@ em, i {
|
||||
}
|
||||
|
||||
strong, b {
|
||||
font-style: bold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p + p, ul + p, ol + p {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1em 40px;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
margin: 1em 0;
|
||||
padding: 0 0 0 1em;
|
||||
|
||||
559
common/lib/xmodule/xmodule/css/videoalpha/display.scss
Normal file
559
common/lib/xmodule/xmodule/css/videoalpha/display.scss
Normal file
@@ -0,0 +1,559 @@
|
||||
& {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
div.video {
|
||||
@include clearfix();
|
||||
background: #f3f3f3;
|
||||
display: block;
|
||||
margin: 0 -12px;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
|
||||
article.video-wrapper {
|
||||
float: left;
|
||||
margin-right: flex-gutter(9);
|
||||
width: flex-grid(6, 9);
|
||||
|
||||
section.video-player {
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
padding-bottom: 56.25%;
|
||||
position: relative;
|
||||
|
||||
object, iframe {
|
||||
border: none;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
section.video-controls {
|
||||
@include clearfix();
|
||||
background: #333;
|
||||
border: 1px solid #000;
|
||||
border-top: 0;
|
||||
color: #ccc;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
ul, div {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
div.slider {
|
||||
@include clearfix();
|
||||
background: #c2c2c2;
|
||||
border: 1px solid #000;
|
||||
@include border-radius(0);
|
||||
border-top: 1px solid #000;
|
||||
@include box-shadow(inset 0 1px 0 #eee, 0 1px 0 #555);
|
||||
height: 7px;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
@include transition(height 2.0s ease-in-out);
|
||||
|
||||
div.ui-widget-header {
|
||||
background: #777;
|
||||
@include box-shadow(inset 0 1px 0 #999);
|
||||
}
|
||||
|
||||
a.ui-slider-handle {
|
||||
background: $pink url(../images/slider-handle.png) center center no-repeat;
|
||||
@include background-size(50%);
|
||||
border: 1px solid darken($pink, 20%);
|
||||
@include border-radius(15px);
|
||||
@include box-shadow(inset 0 1px 0 lighten($pink, 10%));
|
||||
cursor: pointer;
|
||||
height: 15px;
|
||||
margin-left: -7px;
|
||||
top: -4px;
|
||||
@include transition(height 2.0s ease-in-out, width 2.0s ease-in-out);
|
||||
width: 15px;
|
||||
|
||||
&:focus, &:hover {
|
||||
background-color: lighten($pink, 10%);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul.vcr {
|
||||
@extend .dullify;
|
||||
float: left;
|
||||
list-style: none;
|
||||
margin: 0 lh() 0 0;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
float: left;
|
||||
margin-bottom: 0;
|
||||
|
||||
a {
|
||||
border-bottom: none;
|
||||
border-right: 1px solid #000;
|
||||
@include box-shadow(1px 0 0 #555);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
line-height: 46px;
|
||||
padding: 0 lh(.75);
|
||||
text-indent: -9999px;
|
||||
@include transition(background-color, opacity);
|
||||
width: 14px;
|
||||
background: url('../images/vcr.png') 15px 15px no-repeat;
|
||||
outline: 0;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
height: 46px;
|
||||
background: url('../images/vcr.png') 15px 15px no-repeat;
|
||||
}
|
||||
|
||||
&.play {
|
||||
background-position: 17px -114px;
|
||||
|
||||
&:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
}
|
||||
|
||||
&.pause {
|
||||
background-position: 16px -50px;
|
||||
|
||||
&:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.vidtime {
|
||||
padding-left: lh(.75);
|
||||
font-weight: bold;
|
||||
line-height: 46px; //height of play pause buttons
|
||||
padding-left: lh(.75);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.secondary-controls {
|
||||
@extend .dullify;
|
||||
float: right;
|
||||
|
||||
div.speeds {
|
||||
float: left;
|
||||
position: relative;
|
||||
|
||||
&.open {
|
||||
&>a {
|
||||
background: url('../images/open-arrow.png') 10px center no-repeat;
|
||||
}
|
||||
|
||||
ol.video_speeds {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
&>a {
|
||||
background: url('../images/closed-arrow.png') 10px center no-repeat;
|
||||
border-left: 1px solid #000;
|
||||
border-right: 1px solid #000;
|
||||
@include box-shadow(1px 0 0 #555, inset 1px 0 0 #555);
|
||||
@include clearfix();
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
line-height: 46px; //height of play pause buttons
|
||||
margin-right: 0;
|
||||
padding-left: 15px;
|
||||
position: relative;
|
||||
@include transition();
|
||||
-webkit-font-smoothing: antialiased;
|
||||
width: 116px;
|
||||
outline: 0;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #999;
|
||||
float: left;
|
||||
font-size: em(14);
|
||||
font-weight: normal;
|
||||
letter-spacing: 1px;
|
||||
padding: 0 lh(.25) 0 lh(.5);
|
||||
line-height: 46px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
p.active {
|
||||
float: left;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0;
|
||||
padding: 0 lh(.5) 0 0;
|
||||
line-height: 46px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
opacity: 1;
|
||||
background-color: #444;
|
||||
}
|
||||
}
|
||||
|
||||
// fix for now
|
||||
ol.video_speeds {
|
||||
@include box-shadow(inset 1px 0 0 #555, 0 3px 0 #444);
|
||||
@include transition();
|
||||
background-color: #444;
|
||||
border: 1px solid #000;
|
||||
bottom: 46px;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
width: 133px;
|
||||
z-index: 10;
|
||||
|
||||
li {
|
||||
@include box-shadow( 0 1px 0 #555);
|
||||
border-bottom: 1px solid #000;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
a {
|
||||
border: 0;
|
||||
color: #fff;
|
||||
display: block;
|
||||
padding: lh(.5);
|
||||
|
||||
&:hover {
|
||||
background-color: #666;
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
@include box-shadow(none);
|
||||
border-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.volume {
|
||||
float: left;
|
||||
position: relative;
|
||||
|
||||
&.open {
|
||||
.volume-slider-container {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.muted {
|
||||
&>a {
|
||||
background: url('../images/mute.png') 10px center no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
> a {
|
||||
background: url('../images/volume.png') 10px center no-repeat;
|
||||
border-right: 1px solid #000;
|
||||
@include box-shadow(1px 0 0 #555, inset 1px 0 0 #555);
|
||||
@include clearfix();
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
height: 46px;
|
||||
margin-right: 0;
|
||||
padding-left: 15px;
|
||||
position: relative;
|
||||
@include transition();
|
||||
-webkit-font-smoothing: antialiased;
|
||||
width: 30px;
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
background-color: #444;
|
||||
}
|
||||
}
|
||||
|
||||
.volume-slider-container {
|
||||
@include box-shadow(inset 1px 0 0 #555, 0 3px 0 #444);
|
||||
@include transition();
|
||||
background-color: #444;
|
||||
border: 1px solid #000;
|
||||
bottom: 46px;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
width: 45px;
|
||||
height: 125px;
|
||||
margin-left: -1px;
|
||||
z-index: 10;
|
||||
|
||||
.volume-slider {
|
||||
height: 100px;
|
||||
border: 0;
|
||||
width: 5px;
|
||||
margin: 14px auto;
|
||||
background: #666;
|
||||
border: 1px solid #000;
|
||||
@include box-shadow(0 1px 0 #333);
|
||||
|
||||
a.ui-slider-handle {
|
||||
background: $pink url(../images/slider-handle.png) center center no-repeat;
|
||||
@include background-size(50%);
|
||||
border: 1px solid darken($pink, 20%);
|
||||
@include border-radius(15px);
|
||||
@include box-shadow(inset 0 1px 0 lighten($pink, 10%));
|
||||
cursor: pointer;
|
||||
height: 15px;
|
||||
left: -6px;
|
||||
@include transition(height 2.0s ease-in-out, width 2.0s ease-in-out);
|
||||
width: 15px;
|
||||
}
|
||||
|
||||
.ui-slider-range {
|
||||
background: #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.add-fullscreen {
|
||||
background: url(../images/fullscreen.png) center no-repeat;
|
||||
border-right: 1px solid #000;
|
||||
@include box-shadow(1px 0 0 #555, inset 1px 0 0 #555);
|
||||
color: #797979;
|
||||
display: block;
|
||||
float: left;
|
||||
line-height: 46px; //height of play pause buttons
|
||||
margin-left: 0;
|
||||
padding: 0 lh(.5);
|
||||
text-indent: -9999px;
|
||||
@include transition();
|
||||
width: 30px;
|
||||
|
||||
&:hover {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
a.quality_control {
|
||||
background: url(../images/hd.png) center no-repeat;
|
||||
border-right: 1px solid #000;
|
||||
@include box-shadow(1px 0 0 #555, inset 1px 0 0 #555);
|
||||
color: #797979;
|
||||
display: block;
|
||||
float: left;
|
||||
line-height: 46px; //height of play pause buttons
|
||||
margin-left: 0;
|
||||
padding: 0 lh(.5);
|
||||
text-indent: -9999px;
|
||||
@include transition();
|
||||
width: 30px;
|
||||
|
||||
&:hover {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #F44;
|
||||
color: #0ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
a.hide-subtitles {
|
||||
background: url('../images/cc.png') center no-repeat;
|
||||
color: #797979;
|
||||
display: block;
|
||||
float: left;
|
||||
font-weight: 800;
|
||||
line-height: 46px; //height of play pause buttons
|
||||
margin-left: 0;
|
||||
opacity: 1;
|
||||
padding: 0 lh(.5);
|
||||
position: relative;
|
||||
text-indent: -9999px;
|
||||
@include transition();
|
||||
-webkit-font-smoothing: antialiased;
|
||||
width: 30px;
|
||||
|
||||
&:hover {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.off {
|
||||
opacity: .7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover section.video-controls {
|
||||
ul, div {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
div.slider {
|
||||
height: 14px;
|
||||
margin-top: -7px;
|
||||
|
||||
a.ui-slider-handle {
|
||||
@include border-radius(20px);
|
||||
height: 20px;
|
||||
margin-left: -10px;
|
||||
top: -4px;
|
||||
width: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ol.subtitles {
|
||||
padding-left: 0;
|
||||
float: left;
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
width: flex-grid(3, 9);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
border: 0;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
padding: 0;
|
||||
line-height: lh();
|
||||
|
||||
&.current {
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.closed {
|
||||
@extend .trans;
|
||||
|
||||
article.video-wrapper {
|
||||
width: flex-grid(9,9);
|
||||
}
|
||||
|
||||
ol.subtitles {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.fullscreen {
|
||||
background: rgba(#000, .95);
|
||||
border: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 999;
|
||||
vertical-align: middle;
|
||||
|
||||
&.closed {
|
||||
ol.subtitles {
|
||||
right: -(flex-grid(4));
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
div.tc-wrapper {
|
||||
@include clearfix;
|
||||
display: table;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
article.video-wrapper {
|
||||
width: 100%;
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
float: none;
|
||||
}
|
||||
|
||||
object, iframe {
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
section.video-controls {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 9999;
|
||||
}
|
||||
}
|
||||
|
||||
ol.subtitles {
|
||||
background: rgba(#000, .8);
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
max-width: flex-grid(3);
|
||||
padding: lh();
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
@include transition();
|
||||
|
||||
li {
|
||||
color: #aaa;
|
||||
|
||||
&.current {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from xmodule.raw_module import RawDescriptor
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class DiscussionModule(XModule):
|
||||
js = {'coffee':
|
||||
[resource_string(__name__, 'js/src/time.coffee'),
|
||||
@@ -30,6 +31,7 @@ class DiscussionModule(XModule):
|
||||
self.title = xml_data.attrib['for']
|
||||
self.discussion_category = xml_data.attrib['discussion_category']
|
||||
|
||||
|
||||
class DiscussionDescriptor(RawDescriptor):
|
||||
module_class = DiscussionModule
|
||||
template_dir_name = "discussion"
|
||||
|
||||
@@ -8,12 +8,14 @@ log = logging.getLogger(__name__)
|
||||
|
||||
ErrorLog = namedtuple('ErrorLog', 'tracker errors')
|
||||
|
||||
|
||||
def exc_info_to_str(exc_info):
|
||||
"""Given some exception info, convert it into a string using
|
||||
the traceback.format_exception() function.
|
||||
"""
|
||||
return ''.join(traceback.format_exception(*exc_info))
|
||||
|
||||
|
||||
def in_exception_handler():
|
||||
'''Is there an active exception?'''
|
||||
return sys.exc_info() != (None, None, None)
|
||||
@@ -44,6 +46,7 @@ def make_error_tracker():
|
||||
|
||||
return ErrorLog(error_tracker, errors)
|
||||
|
||||
|
||||
def null_error_tracker(msg):
|
||||
'''A dummy error tracker that just ignores the messages'''
|
||||
pass
|
||||
|
||||
@@ -49,6 +49,7 @@ def invalid_args(func, argdict):
|
||||
if keywords: return set() # All accepted
|
||||
return set(argdict) - set(args)
|
||||
|
||||
|
||||
def grader_from_conf(conf):
|
||||
"""
|
||||
This creates a CourseGrader from a configuration (such as in course_settings.py).
|
||||
@@ -80,7 +81,7 @@ def grader_from_conf(conf):
|
||||
subgrader_class = SingleSectionGrader
|
||||
else:
|
||||
raise ValueError("Configuration has no appropriate grader class.")
|
||||
|
||||
|
||||
bad_args = invalid_args(subgrader_class.__init__, subgraderconf)
|
||||
# See note above concerning 'name'.
|
||||
if bad_args.issuperset({name}):
|
||||
@@ -90,7 +91,7 @@ def grader_from_conf(conf):
|
||||
log.warning("Invalid arguments for a subgrader: %s", bad_args)
|
||||
for key in bad_args:
|
||||
del subgraderconf[key]
|
||||
|
||||
|
||||
subgrader = subgrader_class(**subgraderconf)
|
||||
subgraders.append((subgrader, subgrader.category, weight))
|
||||
|
||||
@@ -210,13 +211,13 @@ class SingleSectionGrader(CourseGrader):
|
||||
break
|
||||
|
||||
if foundScore or generate_random_scores:
|
||||
if generate_random_scores: # for debugging!
|
||||
earned = random.randint(2,15)
|
||||
if generate_random_scores: # for debugging!
|
||||
earned = random.randint(2, 15)
|
||||
possible = random.randint(earned, 15)
|
||||
else: # We found the score
|
||||
else: # We found the score
|
||||
earned = foundScore.earned
|
||||
possible = foundScore.possible
|
||||
|
||||
|
||||
percent = earned / float(possible)
|
||||
detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(name=self.name,
|
||||
percent=percent,
|
||||
@@ -245,7 +246,7 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
min_count defines how many assignments are expected throughout the course. Placeholder
|
||||
scores (of 0) will be inserted if the number of matching sections in the course is < min_count.
|
||||
If there number of matching sections in the course is > min_count, min_count will be ignored.
|
||||
|
||||
|
||||
show_only_average is to suppress the display of each assignment in this grader and instead
|
||||
only show the total score of this grader in the breakdown.
|
||||
|
||||
@@ -257,7 +258,7 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
|
||||
short_label is similar to section_type, but shorter. For example, for Homework it would be
|
||||
"HW".
|
||||
|
||||
|
||||
starting_index is the first number that will appear. For example, starting_index=3 and
|
||||
min_count = 2 would produce the labels "Assignment 3", "Assignment 4"
|
||||
|
||||
@@ -296,16 +297,16 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
breakdown = []
|
||||
for i in range(max(self.min_count, len(scores))):
|
||||
if i < len(scores) or generate_random_scores:
|
||||
if generate_random_scores: # for debugging!
|
||||
earned = random.randint(2,15)
|
||||
possible = random.randint(earned, 15)
|
||||
if generate_random_scores: # for debugging!
|
||||
earned = random.randint(2, 15)
|
||||
possible = random.randint(earned, 15)
|
||||
section_name = "Generated"
|
||||
|
||||
|
||||
else:
|
||||
earned = scores[i].earned
|
||||
possible = scores[i].possible
|
||||
section_name = scores[i].section
|
||||
|
||||
|
||||
percentage = earned / float(possible)
|
||||
summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index=i + self.starting_index,
|
||||
section_type=self.section_type,
|
||||
@@ -318,7 +319,7 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index=i + self.starting_index, section_type=self.section_type)
|
||||
|
||||
short_label = "{short_label} {index:02d}".format(index=i + self.starting_index, short_label=self.short_label)
|
||||
|
||||
|
||||
breakdown.append({'percent': percentage, 'label': short_label, 'detail': summary, 'category': self.category})
|
||||
|
||||
total_percent, dropped_indices = totalWithDrops(breakdown, self.drop_count)
|
||||
@@ -328,13 +329,13 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
|
||||
total_detail = "{section_type} Average = {percent:.0%}".format(percent=total_percent, section_type=self.section_type)
|
||||
total_label = "{short_label} Avg".format(short_label=self.short_label)
|
||||
|
||||
|
||||
if self.show_only_average:
|
||||
breakdown = []
|
||||
|
||||
|
||||
if not self.hide_average:
|
||||
breakdown.append({'percent': total_percent, 'label': total_label, 'detail': total_detail, 'category': self.category, 'prominent': True})
|
||||
|
||||
|
||||
return {'percent': total_percent,
|
||||
'section_breakdown': breakdown,
|
||||
#No grade_breakdown here
|
||||
|
||||
129
common/lib/xmodule/xmodule/grading_service_module.py
Normal file
129
common/lib/xmodule/xmodule/grading_service_module.py
Normal file
@@ -0,0 +1,129 @@
|
||||
# This class gives a common interface for logging into the grading controller
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, ConnectionError, HTTPError
|
||||
import sys
|
||||
|
||||
from xmodule.combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError
|
||||
from lxml import etree
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GradingServiceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GradingService(object):
|
||||
"""
|
||||
Interface to staff grading backend.
|
||||
"""
|
||||
def __init__(self, config):
|
||||
self.username = config['username']
|
||||
self.password = config['password']
|
||||
self.url = config['url']
|
||||
self.login_url = self.url + '/login/'
|
||||
self.session = requests.session()
|
||||
self.system = config['system']
|
||||
|
||||
def _login(self):
|
||||
"""
|
||||
Log into the staff grading service.
|
||||
|
||||
Raises requests.exceptions.HTTPError if something goes wrong.
|
||||
|
||||
Returns the decoded json dict of the response.
|
||||
"""
|
||||
response = self.session.post(self.login_url,
|
||||
{'username': self.username,
|
||||
'password': self.password, })
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json
|
||||
|
||||
def post(self, url, data, allow_redirects=False):
|
||||
"""
|
||||
Make a post request to the grading controller
|
||||
"""
|
||||
try:
|
||||
op = lambda: self.session.post(url, data=data,
|
||||
allow_redirects=allow_redirects)
|
||||
r = self._try_with_login(op)
|
||||
except (RequestException, ConnectionError, HTTPError) as err:
|
||||
# reraise as promised GradingServiceError, but preserve stacktrace.
|
||||
raise GradingServiceError, str(err), sys.exc_info()[2]
|
||||
|
||||
return r.text
|
||||
|
||||
def get(self, url, params, allow_redirects=False):
|
||||
"""
|
||||
Make a get request to the grading controller
|
||||
"""
|
||||
log.debug(params)
|
||||
op = lambda: self.session.get(url,
|
||||
allow_redirects=allow_redirects,
|
||||
params=params)
|
||||
try:
|
||||
r = self._try_with_login(op)
|
||||
except (RequestException, ConnectionError, HTTPError) as err:
|
||||
# reraise as promised GradingServiceError, but preserve stacktrace.
|
||||
raise GradingServiceError, str(err), sys.exc_info()[2]
|
||||
|
||||
return r.text
|
||||
|
||||
|
||||
def _try_with_login(self, operation):
|
||||
"""
|
||||
Call operation(), which should return a requests response object. If
|
||||
the request fails with a 'login_required' error, call _login() and try
|
||||
the operation again.
|
||||
|
||||
Returns the result of operation(). Does not catch exceptions.
|
||||
"""
|
||||
response = operation()
|
||||
if (response.json
|
||||
and response.json.get('success') == False
|
||||
and response.json.get('error') == 'login_required'):
|
||||
# apparrently we aren't logged in. Try to fix that.
|
||||
r = self._login()
|
||||
if r and not r.get('success'):
|
||||
log.warning("Couldn't log into staff_grading backend. Response: %s",
|
||||
r)
|
||||
# try again
|
||||
response = operation()
|
||||
response.raise_for_status()
|
||||
|
||||
return response
|
||||
|
||||
def _render_rubric(self, response, view_only=False):
|
||||
"""
|
||||
Given an HTTP Response with the key 'rubric', render out the html
|
||||
required to display the rubric and put it back into the response
|
||||
|
||||
returns the updated response as a dictionary that can be serialized later
|
||||
|
||||
"""
|
||||
try:
|
||||
response_json = json.loads(response)
|
||||
except:
|
||||
response_json = response
|
||||
|
||||
try:
|
||||
if 'rubric' in response_json:
|
||||
rubric = response_json['rubric']
|
||||
rubric_renderer = CombinedOpenEndedRubric(self.system, view_only)
|
||||
success, rubric_html = rubric_renderer.render_rubric(rubric)
|
||||
response_json['rubric'] = rubric_html
|
||||
return response_json
|
||||
# if we can't parse the rubric into HTML,
|
||||
except etree.XMLSyntaxError, RubricParsingError:
|
||||
log.exception("Cannot parse rubric string. Raw string: {0}"
|
||||
.format(rubric))
|
||||
return {'success': False,
|
||||
'error': 'Error displaying submission'}
|
||||
except ValueError:
|
||||
log.exception("Error parsing response: {0}".format(response))
|
||||
return {'success': False,
|
||||
'error': "Error displaying submission"}
|
||||
@@ -1,5 +1,6 @@
|
||||
from lxml import etree
|
||||
|
||||
|
||||
def check_html(html):
|
||||
'''
|
||||
Check whether the passed in html string can be parsed by lxml.
|
||||
|
||||
@@ -133,7 +133,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
|
||||
# TODO (ichuang): remove this after migration
|
||||
# for Fall 2012 LMS migration: keep filename (and unmangled filename)
|
||||
definition['filename'] = [ filepath, filename ]
|
||||
definition['filename'] = [filepath, filename]
|
||||
|
||||
return definition
|
||||
|
||||
@@ -180,6 +180,7 @@ class AboutDescriptor(HtmlDescriptor):
|
||||
"""
|
||||
template_dir_name = "about"
|
||||
|
||||
|
||||
class StaticTabDescriptor(HtmlDescriptor):
|
||||
"""
|
||||
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
|
||||
@@ -187,6 +188,7 @@ class StaticTabDescriptor(HtmlDescriptor):
|
||||
"""
|
||||
template_dir_name = "statictab"
|
||||
|
||||
|
||||
class CourseInfoDescriptor(HtmlDescriptor):
|
||||
"""
|
||||
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
|
||||
|
||||
3
common/lib/xmodule/xmodule/js/src/.gitignore
vendored
3
common/lib/xmodule/xmodule/js/src/.gitignore
vendored
@@ -1,2 +1 @@
|
||||
*.js
|
||||
|
||||
# Please do not ignore *.js files. Some xmodules are written in JS.
|
||||
|
||||
@@ -11,8 +11,9 @@ class @Problem
|
||||
$(selector, @el)
|
||||
|
||||
bind: =>
|
||||
@el.find('.problem > div').each (index, element) =>
|
||||
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
|
||||
if MathJax?
|
||||
@el.find('.problem > div').each (index, element) =>
|
||||
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
|
||||
|
||||
window.update_schematics()
|
||||
|
||||
@@ -31,8 +32,9 @@ class @Problem
|
||||
|
||||
# Dynamath
|
||||
@$('input.math').keyup(@refreshMath)
|
||||
@$('input.math').each (index, element) =>
|
||||
MathJax.Hub.Queue [@refreshMath, null, element]
|
||||
if MathJax?
|
||||
@$('input.math').each (index, element) =>
|
||||
MathJax.Hub.Queue [@refreshMath, null, element]
|
||||
|
||||
updateProgress: (response) =>
|
||||
if response.progress_changed
|
||||
@@ -230,8 +232,9 @@ class @Problem
|
||||
showMethod = @inputtypeShowAnswerMethods[cls]
|
||||
showMethod(inputtype, display, answers) if showMethod?
|
||||
|
||||
@el.find('.problem > div').each (index, element) =>
|
||||
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
|
||||
if MathJax?
|
||||
@el.find('.problem > div').each (index, element) =>
|
||||
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
|
||||
|
||||
@$('.show').val 'Hide Answer'
|
||||
@el.addClass 'showed'
|
||||
@@ -273,7 +276,7 @@ class @Problem
|
||||
preprocessor_tag = "inputtype_" + elid
|
||||
mathjax_preprocessor = @inputtypeDisplays[preprocessor_tag]
|
||||
|
||||
if jax = MathJax.Hub.getAllJax(target)[0]
|
||||
if MathJax? and jax = MathJax.Hub.getAllJax(target)[0]
|
||||
eqn = $(element).val()
|
||||
if mathjax_preprocessor
|
||||
eqn = mathjax_preprocessor(eqn)
|
||||
@@ -286,7 +289,8 @@ class @Problem
|
||||
$("##{element.id}_dynamath").val(jax.root.toMathML '')
|
||||
catch exception
|
||||
throw exception unless exception.restart
|
||||
MathJax.Callback.After [@refreshMath, jax], exception.restart
|
||||
if MathJax?
|
||||
MathJax.Callback.After [@refreshMath, jax], exception.restart
|
||||
|
||||
refreshAnswers: =>
|
||||
@$('input.schematic').each (index, element) ->
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
class @Rubric
|
||||
constructor: () ->
|
||||
|
||||
# finds the scores for each rubric category
|
||||
@get_score_list: () =>
|
||||
# find the number of categories:
|
||||
num_categories = $('table.rubric tr').length
|
||||
|
||||
score_lst = []
|
||||
# get the score for each one
|
||||
for i in [0..(num_categories-2)]
|
||||
score = $("input[name='score-selection-#{i}']:checked").val()
|
||||
score_lst.push(score)
|
||||
|
||||
return score_lst
|
||||
|
||||
@get_total_score: () ->
|
||||
score_lst = @get_score_list()
|
||||
tot = 0
|
||||
for score in score_lst
|
||||
tot += parseInt(score)
|
||||
return tot
|
||||
|
||||
@check_complete: () ->
|
||||
# check to see whether or not any categories have not been scored
|
||||
num_categories = $('table.rubric tr').length
|
||||
# -2 because we want to skip the header
|
||||
for i in [0..(num_categories-2)]
|
||||
score = $("input[name='score-selection-#{i}']:checked").val()
|
||||
if score == undefined
|
||||
return false
|
||||
return true
|
||||
|
||||
class @CombinedOpenEnded
|
||||
constructor: (element) ->
|
||||
@element=element
|
||||
@@ -222,9 +255,9 @@ class @CombinedOpenEnded
|
||||
|
||||
save_assessment: (event) =>
|
||||
event.preventDefault()
|
||||
if @child_state == 'assessing'
|
||||
checked_assessment = @$('input[name="grade-selection"]:checked')
|
||||
data = {'assessment' : checked_assessment.val()}
|
||||
if @child_state == 'assessing' && Rubric.check_complete()
|
||||
checked_assessment = Rubric.get_total_score()
|
||||
data = {'assessment' : checked_assessment}
|
||||
$.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) =>
|
||||
if response.success
|
||||
@child_state = response.state
|
||||
@@ -329,7 +362,7 @@ class @CombinedOpenEnded
|
||||
$.postWithPrefix "#{@ajax_url}/check_for_score", (response) =>
|
||||
if response.state == "done" or response.state=="post_assessment"
|
||||
delete window.queuePollerID
|
||||
@reload
|
||||
location.reload()
|
||||
else
|
||||
window.queuePollerID = window.setTimeout(@poll, 10000)
|
||||
|
||||
@@ -351,7 +384,7 @@ class @CombinedOpenEnded
|
||||
answer_id = @answer_area.attr('id')
|
||||
answer_val = @answer_area.val()
|
||||
new_text = ''
|
||||
new_text = "<span class='#{answer_class}' id='#{answer_id}'>#{answer_val}</span>"
|
||||
new_text = "<div class='#{answer_class}' id='#{answer_id}'>#{answer_val}</div>"
|
||||
@answer_area.replaceWith(new_text)
|
||||
|
||||
# wrap this so that it can be mocked
|
||||
|
||||
@@ -10,7 +10,8 @@ class @HTMLEditingDescriptor
|
||||
lineWrapping: true
|
||||
})
|
||||
|
||||
$(@advanced_editor.getWrapperElement()).addClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
@$advancedEditorWrapper = $(@advanced_editor.getWrapperElement())
|
||||
@$advancedEditorWrapper.addClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
|
||||
# This is a workaround for the fact that tinyMCE's baseURL property is not getting correctly set on AWS
|
||||
# instances (like sandbox). It is not necessary to explicitly set baseURL when running locally.
|
||||
@@ -43,16 +44,21 @@ class @HTMLEditingDescriptor
|
||||
theme_advanced_blockformats : "p,pre,h1,h2,h3",
|
||||
width: '100%',
|
||||
height: '400px',
|
||||
setup : HTMLEditingDescriptor.setupTinyMCE,
|
||||
setup : @setupTinyMCE,
|
||||
# Cannot get access to tinyMCE Editor instance (for focusing) until after it is rendered.
|
||||
# The tinyMCE callback passes in the editor as a paramter.
|
||||
init_instance_callback: @focusVisualEditor
|
||||
})
|
||||
|
||||
@showingVisualEditor = true
|
||||
@element.on('click', '.editor-tabs .tab', this, @onSwitchEditor)
|
||||
# Doing these find operations within onSwitchEditor leads to sporadic failures on Chrome (version 20 and older).
|
||||
$element = $(element)
|
||||
@$htmlTab = $element.find('.html-tab')
|
||||
@$visualTab = $element.find('.visual-tab')
|
||||
|
||||
@setupTinyMCE: (ed) ->
|
||||
@element.on('click', '.editor-tabs .tab', @onSwitchEditor)
|
||||
|
||||
setupTinyMCE: (ed) =>
|
||||
ed.addButton('wrapAsCode', {
|
||||
title : 'Code',
|
||||
image : '/static/images/ico-tinymce-code.png',
|
||||
@@ -67,22 +73,23 @@ class @HTMLEditingDescriptor
|
||||
command.setActive('wrapAsCode', e.nodeName == 'CODE')
|
||||
)
|
||||
|
||||
onSwitchEditor: (e)=>
|
||||
@visualEditor = ed
|
||||
|
||||
onSwitchEditor: (e) =>
|
||||
e.preventDefault();
|
||||
|
||||
if not $(e.currentTarget).hasClass('current')
|
||||
element = e.data.element
|
||||
$currentTarget = $(e.currentTarget)
|
||||
if not $currentTarget.hasClass('current')
|
||||
$currentTarget.addClass('current')
|
||||
@$mceToolbar.toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
@$advancedEditorWrapper.toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
|
||||
$(e.currentTarget).addClass('current')
|
||||
$(element).find('table.mceToolbar').toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
$(@advanced_editor.getWrapperElement()).toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
|
||||
visualEditor = @getVisualEditor(element)
|
||||
if $(e.currentTarget).attr('data-tab') is 'visual'
|
||||
$(element).find('.html-tab').removeClass('current')
|
||||
visualEditor = @getVisualEditor()
|
||||
if $currentTarget.data('tab') is 'visual'
|
||||
@$htmlTab.removeClass('current')
|
||||
@showVisualEditor(visualEditor)
|
||||
else
|
||||
$(element).find('.visual-tab').removeClass('current')
|
||||
@$visualTab.removeClass('current')
|
||||
@showAdvancedEditor(visualEditor)
|
||||
|
||||
# Show the Advanced (codemirror) Editor. Pulled out as a helper method for unit testing.
|
||||
@@ -100,24 +107,29 @@ class @HTMLEditingDescriptor
|
||||
# In order for isDirty() to return true ONLY if edits have been made after setting the text,
|
||||
# both the startContent must be sync'ed up and the dirty flag set to false.
|
||||
visualEditor.startContent = visualEditor.getContent({format: "raw", no_events: 1});
|
||||
visualEditor.isNotDirty = true
|
||||
@focusVisualEditor(visualEditor)
|
||||
@showingVisualEditor = true
|
||||
|
||||
focusVisualEditor: (visualEditor) ->
|
||||
focusVisualEditor: (visualEditor) =>
|
||||
visualEditor.focus()
|
||||
# Need to mark editor as not dirty both when it is initially created and when we switch back to it.
|
||||
visualEditor.isNotDirty = true
|
||||
if not @$mceToolbar?
|
||||
@$mceToolbar = $(@element).find('table.mceToolbar')
|
||||
|
||||
getVisualEditor: (element) ->
|
||||
getVisualEditor: () ->
|
||||
###
|
||||
Returns the instance of TinyMCE.
|
||||
This is different from the textarea that exists in the HTML template (@tiny_mce_textarea.
|
||||
|
||||
Pulled out as a helper method for unit test.
|
||||
###
|
||||
return tinyMCE.get($(element).find('.tiny-mce').attr('id'))
|
||||
return @visualEditor
|
||||
|
||||
save: ->
|
||||
@element.off('click', '.editor-tabs .tab', @onSwitchEditor)
|
||||
text = @advanced_editor.getValue()
|
||||
visualEditor = @getVisualEditor(@element)
|
||||
visualEditor = @getVisualEditor()
|
||||
if @showingVisualEditor and visualEditor.isDirty()
|
||||
text = visualEditor.getContent({no_events: 1})
|
||||
data: text
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# This is a simple class that just hides the error container
|
||||
# and message container when they are empty
|
||||
# Can (and should be) expanded upon when our problem list
|
||||
# becomes more sophisticated
|
||||
class @PeerGrading
|
||||
constructor: (element) ->
|
||||
@peer_grading_container = $('.peer-grading')
|
||||
@use_single_location = @peer_grading_container.data('use-single-location')
|
||||
@peer_grading_outer_container = $('.peer-grading-container')
|
||||
@ajax_url = @peer_grading_container.data('ajax-url')
|
||||
@error_container = $('.error-container')
|
||||
@error_container.toggle(not @error_container.is(':empty'))
|
||||
|
||||
@message_container = $('.message-container')
|
||||
@message_container.toggle(not @message_container.is(':empty'))
|
||||
|
||||
@problem_button = $('.problem-button')
|
||||
@problem_button.click @show_results
|
||||
|
||||
@problem_list = $('.problem-list')
|
||||
@construct_progress_bar()
|
||||
|
||||
if @use_single_location
|
||||
@activate_problem()
|
||||
|
||||
construct_progress_bar: () =>
|
||||
problems = @problem_list.find('tr').next()
|
||||
problems.each( (index, element) =>
|
||||
problem = $(element)
|
||||
progress_bar = problem.find('.progress-bar')
|
||||
bar_value = parseInt(problem.data('graded'))
|
||||
bar_max = parseInt(problem.data('required')) + bar_value
|
||||
progress_bar.progressbar({value: bar_value, max: bar_max})
|
||||
)
|
||||
|
||||
show_results: (event) =>
|
||||
location_to_fetch = $(event.target).data('location')
|
||||
data = {'location' : location_to_fetch}
|
||||
$.postWithPrefix "#{@ajax_url}problem", data, (response) =>
|
||||
if response.success
|
||||
@peer_grading_outer_container.after(response.html).remove()
|
||||
backend = new PeerGradingProblemBackend(@ajax_url, false)
|
||||
new PeerGradingProblem(backend)
|
||||
else
|
||||
@gentle_alert response.error
|
||||
|
||||
activate_problem: () =>
|
||||
backend = new PeerGradingProblemBackend(@ajax_url, false)
|
||||
new PeerGradingProblem(backend)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user