studio - merging soft landing local work with rebased branch
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# .coveragerc for cms
|
||||
[run]
|
||||
data_file = reports/cms/.coverage
|
||||
source = cms
|
||||
source = cms,common/djangoapps
|
||||
omit = cms/envs/*, cms/manage.py
|
||||
|
||||
[report]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from static_replace import replace_urls
|
||||
from static_replace import replace_static_urls
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -18,7 +18,17 @@ def get_module_info(store, location, parent_location = None, rewrite_static_link
|
||||
|
||||
data = module.definition['data']
|
||||
if rewrite_static_links:
|
||||
data = replace_urls(module.definition['data'], course_namespace = Location([module.location.tag, module.location.org, module.location.course, None, None]))
|
||||
data = replace_static_urls(
|
||||
module.definition['data'],
|
||||
None,
|
||||
course_namespace=Location([
|
||||
module.location.tag,
|
||||
module.location.org,
|
||||
module.location.course,
|
||||
None,
|
||||
None
|
||||
])
|
||||
)
|
||||
|
||||
return {
|
||||
'id': module.location.url(),
|
||||
@@ -47,7 +57,7 @@ def set_module_info(store, location, post_data):
|
||||
if post_data.get('data') is not None:
|
||||
data = post_data['data']
|
||||
store.update_item(location, data)
|
||||
|
||||
|
||||
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
|
||||
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
|
||||
# deleting the children object from the children collection
|
||||
|
||||
@@ -515,6 +515,9 @@ class ContentStoreTest(TestCase):
|
||||
# note, we know the link it should be because that's what in the 'full' course in the test data
|
||||
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
|
||||
|
||||
def test_missing_static_content(self):
|
||||
resp = self.client.get("/c4x/asd/asd/asd/asd")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_capa_module(self):
|
||||
"""Test that a problem treats markdown specially."""
|
||||
|
||||
@@ -31,7 +31,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationErr
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
from static_replace import replace_urls
|
||||
import static_replace
|
||||
from external_auth.views import ssl_login_shortcut
|
||||
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
@@ -134,7 +134,7 @@ def has_access(user, location, role=STAFF_ROLE_NAME):
|
||||
Return True if user allowed to access this piece of data
|
||||
Note that the CMS permissions model is with respect to courses
|
||||
There is a super-admin permissions if user.is_staff is set
|
||||
Also, since we're unifying the user database between LMS and CAS,
|
||||
Also, since we're unifying the user database between LMS and CAS,
|
||||
I'm presuming that the course instructor (formally known as admin)
|
||||
will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our queries here as INSTRUCTOR
|
||||
has all the rights that STAFF do
|
||||
@@ -156,7 +156,7 @@ def course_index(request, org, course, name):
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
@@ -215,7 +215,7 @@ def edit_subsection(request, location):
|
||||
|
||||
# remove all metadata from the generic dictionary that is presented in a more normalized UI
|
||||
|
||||
policy_metadata = dict((key,value) for key, value in item.metadata.iteritems()
|
||||
policy_metadata = dict((key,value) for key, value in item.metadata.iteritems()
|
||||
if key not in ['display_name', 'start', 'due', 'format'] and key not in item.system_metadata_fields)
|
||||
|
||||
can_view_live = False
|
||||
@@ -263,7 +263,6 @@ def edit_unit(request, location):
|
||||
break
|
||||
|
||||
lms_link = get_lms_link_for_item(item.location)
|
||||
preview_lms_link = get_lms_link_for_item(item.location, preview=True)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
|
||||
@@ -294,7 +293,7 @@ def edit_unit(request, location):
|
||||
containing_section = modulestore().get_item(containing_section_locs[0])
|
||||
|
||||
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
|
||||
# so let's generate the link url here
|
||||
# so let's generate the link url here
|
||||
|
||||
# need to figure out where this item is in the list of children as the preview will need this
|
||||
index =1
|
||||
@@ -305,12 +304,12 @@ def edit_unit(request, location):
|
||||
|
||||
preview_lms_link = '//{preview}{lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format(
|
||||
preview='preview.',
|
||||
lms_base=settings.LMS_BASE,
|
||||
lms_base=settings.LMS_BASE,
|
||||
org=course.location.org,
|
||||
course=course.location.course,
|
||||
course_name=course.location.name,
|
||||
section=containing_section.location.name,
|
||||
subsection=containing_subsection.location.name,
|
||||
course=course.location.course,
|
||||
course_name=course.location.name,
|
||||
section=containing_section.location.name,
|
||||
subsection=containing_subsection.location.name,
|
||||
index=index)
|
||||
|
||||
unit_state = compute_unit_state(item)
|
||||
@@ -361,14 +360,14 @@ def assignment_type_update(request, org, course, category, name):
|
||||
location = Location(['i4x', org, course, category, name])
|
||||
if not has_access(request.user, location):
|
||||
raise HttpResponseForbidden()
|
||||
|
||||
|
||||
if request.method == 'GET':
|
||||
return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)),
|
||||
return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)),
|
||||
mimetype="application/json")
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)),
|
||||
return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)),
|
||||
mimetype="application/json")
|
||||
|
||||
|
||||
|
||||
def user_author_string(user):
|
||||
'''Get an author string for commits by this user. Format:
|
||||
@@ -476,7 +475,7 @@ def preview_module_system(request, preview_id, descriptor):
|
||||
get_module=partial(get_preview_module, request, preview_id),
|
||||
render_template=render_from_lms,
|
||||
debug=True,
|
||||
replace_urls=replace_urls,
|
||||
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location),
|
||||
user=request.user,
|
||||
)
|
||||
|
||||
@@ -513,20 +512,20 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_
|
||||
error_msg=exc_info_to_str(sys.exc_info())
|
||||
).xmodule_constructor(system)(None, None)
|
||||
|
||||
# cdodge: Special case
|
||||
# cdodge: Special case
|
||||
if module.location.category == 'static_tab':
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_tab_display.html",
|
||||
)
|
||||
else:
|
||||
else:
|
||||
module.get_html = wrap_xmodule(
|
||||
module.get_html,
|
||||
module,
|
||||
"xmodule_display.html",
|
||||
)
|
||||
|
||||
|
||||
module.get_html = replace_static_urls(
|
||||
module.get_html,
|
||||
module.metadata.get('data_dir', module.location.course),
|
||||
@@ -557,7 +556,7 @@ def _xmodule_recurse(item, action):
|
||||
_xmodule_recurse(child, action)
|
||||
|
||||
action(item)
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
@@ -592,7 +591,7 @@ def delete_item(request):
|
||||
# delete_item on a vertical tries to delete the draft version leaving the
|
||||
# requested delete to never occur
|
||||
if item.location.revision is None and item.location.category=='vertical' and delete_all_versions:
|
||||
modulestore('direct').delete_item(item.location)
|
||||
modulestore('direct').delete_item(item.location)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
@@ -611,7 +610,7 @@ def save_item(request):
|
||||
if request.POST.get('data') is not None:
|
||||
data = request.POST['data']
|
||||
store.update_item(item_location, data)
|
||||
|
||||
|
||||
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
|
||||
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
|
||||
# deleting the children object from the children collection
|
||||
@@ -701,7 +700,7 @@ def unpublish_unit(request):
|
||||
def clone_item(request):
|
||||
parent_location = Location(request.POST['parent_location'])
|
||||
template = Location(request.POST['template'])
|
||||
|
||||
|
||||
display_name = request.POST.get('display_name')
|
||||
|
||||
if not has_access(request.user, parent_location):
|
||||
@@ -741,9 +740,9 @@ def upload_asset(request, org, course, coursename):
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
if not has_access(request.user, location):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
|
||||
# Does the course actually exist?!? Get anything from it to prove its existance
|
||||
|
||||
|
||||
try:
|
||||
item = modulestore().get_item(location)
|
||||
except:
|
||||
@@ -777,9 +776,9 @@ def upload_asset(request, org, course, coursename):
|
||||
|
||||
# readback the saved content - we need the database timestamp
|
||||
readback = contentstore().find(content.location)
|
||||
|
||||
response_payload = {'displayname' : content.name,
|
||||
'uploadDate' : get_date_display(readback.last_modified_at),
|
||||
|
||||
response_payload = {'displayname' : content.name,
|
||||
'uploadDate' : get_date_display(readback.last_modified_at),
|
||||
'url' : StaticContent.get_url_path_from_location(content.location),
|
||||
'thumb_url' : StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
|
||||
'msg' : 'Upload completed'
|
||||
@@ -795,7 +794,7 @@ This view will return all CMS users who are editors for the specified course
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def manage_users(request, location):
|
||||
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
@@ -811,7 +810,7 @@ def manage_users(request, location):
|
||||
'allow_actions' : has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME),
|
||||
'request_user_id' : request.user.id
|
||||
})
|
||||
|
||||
|
||||
|
||||
def create_json_response(errmsg = None):
|
||||
if errmsg is not None:
|
||||
@@ -833,13 +832,13 @@ def add_user(request, location):
|
||||
|
||||
if email=='':
|
||||
return create_json_response('Please specify an email address.')
|
||||
|
||||
|
||||
# check that logged in user has admin permissions to this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
user = get_user_by_email(email)
|
||||
|
||||
|
||||
# user doesn't exist?!? Return error.
|
||||
if user is None:
|
||||
return create_json_response('Could not find user by email address \'{0}\'.'.format(email))
|
||||
@@ -862,7 +861,7 @@ the specified course
|
||||
@ensure_csrf_cookie
|
||||
def remove_user(request, location):
|
||||
email = request.POST["email"]
|
||||
|
||||
|
||||
# check that logged in user has admin permissions on this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
@@ -889,7 +888,7 @@ def landing(request, org, course, coursename):
|
||||
def static_pages(request, org, course, coursename):
|
||||
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
@@ -918,7 +917,7 @@ def reorder_static_tabs(request):
|
||||
# get list of existing static tabs in course
|
||||
# make sure they are the same lengths (i.e. the number of passed in tabs equals the number
|
||||
# that we know about) otherwise we can drop some!
|
||||
|
||||
|
||||
existing_static_tabs = [t for t in course.tabs if t['type'] == 'static_tab']
|
||||
if len(existing_static_tabs) != len(tabs):
|
||||
return HttpResponseBadRequest()
|
||||
@@ -937,15 +936,15 @@ def reorder_static_tabs(request):
|
||||
static_tab_idx = 0
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reordered_tabs.append({'type': 'static_tab',
|
||||
'name' : tab_items[static_tab_idx].metadata.get('display_name'),
|
||||
reordered_tabs.append({'type': 'static_tab',
|
||||
'name' : tab_items[static_tab_idx].metadata.get('display_name'),
|
||||
'url_slug' : tab_items[static_tab_idx].location.name})
|
||||
static_tab_idx += 1
|
||||
else:
|
||||
reordered_tabs.append(tab)
|
||||
|
||||
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
course.tabs = reordered_tabs
|
||||
modulestore('direct').update_metadata(course.location, course.metadata)
|
||||
return HttpResponse()
|
||||
@@ -954,7 +953,7 @@ def reorder_static_tabs(request):
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def edit_tabs(request, org, course, coursename):
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
course_item = modulestore().get_item(location)
|
||||
static_tabs_loc = Location('i4x', org, course, 'static_tab', None)
|
||||
|
||||
@@ -983,7 +982,7 @@ def edit_tabs(request, org, course, coursename):
|
||||
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course':course_item,
|
||||
'context_course':course_item,
|
||||
'components': components
|
||||
})
|
||||
|
||||
@@ -1004,13 +1003,13 @@ def course_info(request, org, course, name, provided_id=None):
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
|
||||
# get current updates
|
||||
location = ['i4x', org, course, 'course_info', "updates"]
|
||||
|
||||
@@ -1021,7 +1020,7 @@ def course_info(request, org, course, name, provided_id=None):
|
||||
'course_updates' : json.dumps(get_course_updates(location)),
|
||||
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -1035,7 +1034,7 @@ def course_info_updates(request, org, course, provided_id=None):
|
||||
# ??? No way to check for access permission afaik
|
||||
# get current updates
|
||||
location = ['i4x', org, course, 'course_info', "updates"]
|
||||
|
||||
|
||||
# Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-(
|
||||
# Possibly due to my removing the seemingly redundant pattern in urls.py
|
||||
if provided_id == '':
|
||||
@@ -1050,7 +1049,7 @@ def course_info_updates(request, org, course, provided_id=None):
|
||||
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
|
||||
else:
|
||||
real_method = request.method
|
||||
|
||||
|
||||
if request.method == 'GET':
|
||||
return HttpResponse(json.dumps(get_course_updates(location)), mimetype="application/json")
|
||||
elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE
|
||||
@@ -1067,7 +1066,7 @@ def course_info_updates(request, org, course, provided_id=None):
|
||||
@ensure_csrf_cookie
|
||||
def module_info(request, module_location):
|
||||
location = Location(module_location)
|
||||
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
@@ -1080,10 +1079,10 @@ def module_info(request, module_location):
|
||||
|
||||
rewrite_static_links = request.GET.get('rewrite_url_links','True') in ['True', 'true']
|
||||
logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links','False'), rewrite_static_links))
|
||||
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
raise PermissionDenied()
|
||||
|
||||
if real_method == 'GET':
|
||||
return HttpResponse(json.dumps(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links)), mimetype="application/json")
|
||||
@@ -1101,20 +1100,20 @@ def get_course_settings(request, org, course, name):
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
course_details = CourseDetails.fetch(location)
|
||||
|
||||
|
||||
return render_to_response('settings.html', {
|
||||
'active_tab': 'settings',
|
||||
'active_tab': 'settings',
|
||||
'context_course': course_module,
|
||||
'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder)
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -1137,13 +1136,13 @@ def course_settings_updates(request, org, course, name, section):
|
||||
elif section == 'grading':
|
||||
manager = CourseGradingModel
|
||||
else: return
|
||||
|
||||
|
||||
if request.method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course',name])), cls=CourseSettingsEncoder),
|
||||
return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course',name])), cls=CourseSettingsEncoder),
|
||||
mimetype="application/json")
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder),
|
||||
return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder),
|
||||
mimetype="application/json")
|
||||
|
||||
@expect_json
|
||||
@@ -1156,7 +1155,7 @@ def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
|
||||
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
@@ -1167,13 +1166,13 @@ def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
|
||||
else:
|
||||
real_method = request.method
|
||||
|
||||
|
||||
if real_method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(['i4x', org, course, 'course',name]), grader_index)),
|
||||
return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(['i4x', org, course, 'course',name]), grader_index)),
|
||||
mimetype="application/json")
|
||||
elif real_method == "DELETE":
|
||||
# ??? Shoudl this return anything? Perhaps success fail?
|
||||
# ??? Shoudl this return anything? Perhaps success fail?
|
||||
CourseGradingModel.delete_grader(Location(['i4x', org, course, 'course',name]), grader_index)
|
||||
return HttpResponse()
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
@@ -1190,7 +1189,7 @@ def asset_index(request, org, course, name):
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
@@ -1203,7 +1202,7 @@ def asset_index(request, org, course, name):
|
||||
})
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
|
||||
course_reference = StaticContent.compute_location(org, course, name)
|
||||
assets = contentstore().get_all_content_for_course(course_reference)
|
||||
|
||||
@@ -1217,15 +1216,15 @@ def asset_index(request, org, course, name):
|
||||
display_info = {}
|
||||
display_info['displayname'] = asset['displayname']
|
||||
display_info['uploadDate'] = get_date_display(asset['uploadDate'])
|
||||
|
||||
|
||||
asset_location = StaticContent.compute_location(id['org'], id['course'], id['name'])
|
||||
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
|
||||
|
||||
|
||||
# note, due to the schema change we may not have a 'thumbnail_location' in the result set
|
||||
_thumbnail_location = asset.get('thumbnail_location', None)
|
||||
thumbnail_location = Location(_thumbnail_location) if _thumbnail_location is not None else None
|
||||
display_info['thumb_url'] = StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None
|
||||
|
||||
|
||||
asset_display.append(display_info)
|
||||
|
||||
return render_to_response('asset_index.html', {
|
||||
@@ -1244,9 +1243,9 @@ def edge(request):
|
||||
@expect_json
|
||||
def create_new_course(request):
|
||||
template = Location(request.POST['template'])
|
||||
org = request.POST.get('org')
|
||||
number = request.POST.get('number')
|
||||
display_name = request.POST.get('display_name')
|
||||
org = request.POST.get('org')
|
||||
number = request.POST.get('number')
|
||||
display_name = request.POST.get('display_name')
|
||||
|
||||
try:
|
||||
dest_location = Location('i4x', org, number, 'course', Location.clean(display_name))
|
||||
@@ -1292,13 +1291,13 @@ def initialize_course_tabs(course):
|
||||
# at least a list populated with the minimal times
|
||||
# @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
|
||||
# place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
|
||||
course.tabs = [{"type": "courseware"},
|
||||
{"type": "course_info", "name": "Course Info"},
|
||||
course.tabs = [{"type": "courseware"},
|
||||
{"type": "course_info", "name": "Course Info"},
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"},
|
||||
{"type": "progress", "name": "Progress"}]
|
||||
|
||||
modulestore('direct').update_metadata(course.location.url(), course.own_metadata)
|
||||
modulestore('direct').update_metadata(course.location.url(), course.own_metadata)
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
@@ -1391,7 +1390,7 @@ def generate_export_course(request, org, course, name):
|
||||
root_dir = path(mkdtemp())
|
||||
|
||||
# export out to a tempdir
|
||||
|
||||
|
||||
logging.debug('root = {0}'.format(root_dir))
|
||||
|
||||
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name)
|
||||
@@ -1403,7 +1402,7 @@ def generate_export_course(request, org, course, name):
|
||||
tf.close()
|
||||
|
||||
# remove temp dir
|
||||
shutil.rmtree(root_dir/name)
|
||||
shutil.rmtree(root_dir/name)
|
||||
|
||||
wrapper = FileWrapper(export_file)
|
||||
response = HttpResponse(wrapper, content_type='application/x-tgz')
|
||||
@@ -1433,4 +1432,4 @@ def event(request):
|
||||
A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at
|
||||
console logs don't get distracted :-)
|
||||
'''
|
||||
return HttpResponse(True)
|
||||
return HttpResponse(True)
|
||||
|
||||
@@ -52,10 +52,6 @@ LOGGING = get_logger_config(LOG_DIR,
|
||||
debug=False,
|
||||
service_variant=SERVICE_VARIANT)
|
||||
|
||||
with open(ENV_ROOT / "repos.json") as repos_file:
|
||||
REPOS = json.load(repos_file)
|
||||
|
||||
|
||||
################ SECURE AUTH ITEMS ###############################
|
||||
# Secret things: passwords, access keys, etc.
|
||||
with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
|
||||
|
||||
@@ -285,4 +285,5 @@ INSTALLED_APPS = (
|
||||
# For asset pipelining
|
||||
'pipeline',
|
||||
'staticfiles',
|
||||
'static_replace',
|
||||
)
|
||||
|
||||
@@ -95,64 +95,6 @@ $(document).ready(function() {
|
||||
$('.import .file-input').click();
|
||||
});
|
||||
|
||||
// making the unit list draggable. Note: sortable didn't work b/c it considered
|
||||
// drop points which the user hovered over as destinations and proactively changed
|
||||
// the dom; so, if the user subsequently dropped at an illegal spot, the reversion
|
||||
// point was the last dom change.
|
||||
$('.unit').draggable({
|
||||
axis: 'y',
|
||||
handle: '.drag-handle',
|
||||
zIndex: 999,
|
||||
start: initiateHesitate,
|
||||
drag: checkHoverState,
|
||||
stop: removeHesitate,
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
// Subsection reordering
|
||||
$('.id-holder').draggable({
|
||||
axis: 'y',
|
||||
handle: '.section-item .drag-handle',
|
||||
zIndex: 999,
|
||||
start: initiateHesitate,
|
||||
drag: checkHoverState,
|
||||
stop: removeHesitate,
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
// Section reordering
|
||||
$('.courseware-section').draggable({
|
||||
axis: 'y',
|
||||
handle: 'header .drag-handle',
|
||||
stack: '.courseware-section',
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
|
||||
$('.sortable-unit-list').droppable({
|
||||
accept : '.unit',
|
||||
greedy: true,
|
||||
tolerance: "pointer",
|
||||
hoverClass: "dropover",
|
||||
drop: onUnitReordered
|
||||
});
|
||||
$('.subsection-list > ol').droppable({
|
||||
// why don't we have a more useful class for subsections than id-holder?
|
||||
accept : '.id-holder', // '.unit, .id-holder',
|
||||
tolerance: "pointer",
|
||||
hoverClass: "dropover",
|
||||
drop: onSubsectionReordered,
|
||||
greedy: true
|
||||
});
|
||||
|
||||
// Section reordering
|
||||
$('.courseware-overview').droppable({
|
||||
accept : '.courseware-section',
|
||||
tolerance: "pointer",
|
||||
drop: onSectionReordered,
|
||||
greedy: true
|
||||
});
|
||||
|
||||
$('.new-course-button').bind('click', addNewCourse);
|
||||
|
||||
// section name editing
|
||||
@@ -294,136 +236,6 @@ function removePolicyMetadata(e) {
|
||||
saveSubsection()
|
||||
}
|
||||
|
||||
CMS.HesitateEvent.toggleXpandHesitation = null;
|
||||
function initiateHesitate(event, ui) {
|
||||
CMS.HesitateEvent.toggleXpandHesitation = new CMS.HesitateEvent(expandSection, 'dragLeave', true);
|
||||
$('.collapsed').on('dragEnter', CMS.HesitateEvent.toggleXpandHesitation, CMS.HesitateEvent.toggleXpandHesitation.trigger);
|
||||
$('.collapsed').each(function() {
|
||||
this.proportions = {width : this.offsetWidth, height : this.offsetHeight };
|
||||
// reset b/c these were holding values from aborts
|
||||
this.isover = false;
|
||||
});
|
||||
}
|
||||
function checkHoverState(event, ui) {
|
||||
// copied from jquery.ui.droppable.js $.ui.ddmanager.drag & other ui.intersect
|
||||
var draggable = $(this).data("ui-draggable"),
|
||||
x1 = (draggable.positionAbs || draggable.position.absolute).left + (draggable.helperProportions.width / 2),
|
||||
y1 = (draggable.positionAbs || draggable.position.absolute).top + (draggable.helperProportions.height / 2);
|
||||
$('.collapsed').each(function() {
|
||||
// don't expand the thing being carried
|
||||
if (ui.helper.is(this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$.extend(this, {offset : $(this).offset()});
|
||||
|
||||
var droppable = this,
|
||||
l = droppable.offset.left,
|
||||
r = l + droppable.proportions.width,
|
||||
t = droppable.offset.top,
|
||||
b = t + droppable.proportions.height;
|
||||
|
||||
if (l === r) {
|
||||
// probably wrong values b/c invisible at the time of caching
|
||||
droppable.proportions = { width : droppable.offsetWidth, height : droppable.offsetHeight };
|
||||
r = l + droppable.proportions.width;
|
||||
b = t + droppable.proportions.height;
|
||||
}
|
||||
// equivalent to the intersects test
|
||||
var intersects = (l < x1 && // Right Half
|
||||
x1 < r && // Left Half
|
||||
t < y1 && // Bottom Half
|
||||
y1 < b ), // Top Half
|
||||
|
||||
c = !intersects && this.isover ? "isout" : (intersects && !this.isover ? "isover" : null);
|
||||
|
||||
if(!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[c] = true;
|
||||
this[c === "isout" ? "isover" : "isout"] = false;
|
||||
$(this).trigger(c === "isover" ? "dragEnter" : "dragLeave");
|
||||
});
|
||||
}
|
||||
function removeHesitate(event, ui) {
|
||||
$('.collapsed').off('dragEnter', CMS.HesitateEvent.toggleXpandHesitation.trigger);
|
||||
CMS.HesitateEvent.toggleXpandHesitation = null;
|
||||
}
|
||||
|
||||
function expandSection(event) {
|
||||
$(event.delegateTarget).removeClass('collapsed', 400);
|
||||
// don't descend to icon's on children (which aren't under first child) only to this element's icon
|
||||
$(event.delegateTarget).children().first().find('.expand-collapse-icon').removeClass('expand', 400).addClass('collapse');
|
||||
}
|
||||
|
||||
function onUnitReordered(event, ui) {
|
||||
// a unit's been dropped on this subsection,
|
||||
// figure out where it came from and where it slots in.
|
||||
_handleReorder(event, ui, 'subsection-id', 'li:.leaf');
|
||||
}
|
||||
|
||||
function onSubsectionReordered(event, ui) {
|
||||
// a subsection has been dropped on this section,
|
||||
// figure out where it came from and where it slots in.
|
||||
_handleReorder(event, ui, 'section-id', 'li:.branch');
|
||||
}
|
||||
|
||||
function onSectionReordered(event, ui) {
|
||||
// a section moved w/in the overall (cannot change course via this, so no parentage change possible, just order)
|
||||
_handleReorder(event, ui, 'course-id', '.courseware-section');
|
||||
}
|
||||
|
||||
function _handleReorder(event, ui, parentIdField, childrenSelector) {
|
||||
// figure out where it came from and where it slots in.
|
||||
var subsection_id = $(event.target).data(parentIdField);
|
||||
var _els = $(event.target).children(childrenSelector);
|
||||
var children = _els.map(function(idx, el) { return $(el).data('id'); }).get();
|
||||
// if new to this parent, figure out which parent to remove it from and do so
|
||||
if (!_.contains(children, ui.draggable.data('id'))) {
|
||||
var old_parent = ui.draggable.parent();
|
||||
var old_children = old_parent.children(childrenSelector).map(function(idx, el) { return $(el).data('id'); }).get();
|
||||
old_children = _.without(old_children, ui.draggable.data('id'));
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
contentType: "application/json",
|
||||
data:JSON.stringify({ 'id' : old_parent.data(parentIdField), 'children' : old_children})
|
||||
});
|
||||
}
|
||||
else {
|
||||
// staying in same parent
|
||||
// remove so that the replacement in the right place doesn't double it
|
||||
children = _.without(children, ui.draggable.data('id'));
|
||||
}
|
||||
// add to this parent (figure out where)
|
||||
for (var i = 0; i < _els.length; i++) {
|
||||
if (!ui.draggable.is(_els[i]) && ui.offset.top < $(_els[i]).offset().top) {
|
||||
// insert at i in children and _els
|
||||
ui.draggable.insertBefore($(_els[i]));
|
||||
// TODO figure out correct way to have it remove the style: top:n; setting (and similar line below)
|
||||
ui.draggable.attr("style", "position:relative;");
|
||||
children.splice(i, 0, ui.draggable.data('id'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// see if it goes at end (the above loop didn't insert it)
|
||||
if (!_.contains(children, ui.draggable.data('id'))) {
|
||||
$(event.target).append(ui.draggable);
|
||||
ui.draggable.attr("style", "position:relative;"); // STYLE hack too
|
||||
children.push(ui.draggable.data('id'));
|
||||
}
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
contentType: "application/json",
|
||||
data:JSON.stringify({ 'id' : subsection_id, 'children' : children})
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function getEdxTimeFromDateTimeVals(date_val, time_val, format) {
|
||||
var edxTimeStr = null;
|
||||
|
||||
|
||||
231
cms/static/js/views/overview.js
Normal file
231
cms/static/js/views/overview.js
Normal file
@@ -0,0 +1,231 @@
|
||||
$(document).ready(function() {
|
||||
// making the unit list draggable. Note: sortable didn't work b/c it considered
|
||||
// drop points which the user hovered over as destinations and proactively changed
|
||||
// the dom; so, if the user subsequently dropped at an illegal spot, the reversion
|
||||
// point was the last dom change.
|
||||
$('.unit').draggable({
|
||||
axis: 'y',
|
||||
handle: '.drag-handle',
|
||||
zIndex: 999,
|
||||
start: initiateHesitate,
|
||||
// left 2nd arg in as inert selector b/c i was uncertain whether we'd try to get the shove up/down
|
||||
// to work in the future
|
||||
drag: generateCheckHoverState('.collapsed', ''),
|
||||
stop: removeHesitate,
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
// Subsection reordering
|
||||
$('.id-holder').draggable({
|
||||
axis: 'y',
|
||||
handle: '.section-item .drag-handle',
|
||||
zIndex: 999,
|
||||
start: initiateHesitate,
|
||||
drag: generateCheckHoverState('.courseware-section.collapsed', ''),
|
||||
stop: removeHesitate,
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
// Section reordering
|
||||
$('.courseware-section').draggable({
|
||||
axis: 'y',
|
||||
handle: 'header .drag-handle',
|
||||
stack: '.courseware-section',
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
|
||||
$('.sortable-unit-list').droppable({
|
||||
accept : '.unit',
|
||||
greedy: true,
|
||||
tolerance: "pointer",
|
||||
hoverClass: "dropover",
|
||||
drop: onUnitReordered
|
||||
});
|
||||
$('.subsection-list > ol').droppable({
|
||||
// why don't we have a more useful class for subsections than id-holder?
|
||||
accept : '.id-holder', // '.unit, .id-holder',
|
||||
tolerance: "pointer",
|
||||
hoverClass: "dropover",
|
||||
drop: onSubsectionReordered,
|
||||
greedy: true
|
||||
});
|
||||
|
||||
// Section reordering
|
||||
$('.courseware-overview').droppable({
|
||||
accept : '.courseware-section',
|
||||
tolerance: "pointer",
|
||||
drop: onSectionReordered,
|
||||
greedy: true
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
CMS.HesitateEvent.toggleXpandHesitation = null;
|
||||
function initiateHesitate(event, ui) {
|
||||
CMS.HesitateEvent.toggleXpandHesitation = new CMS.HesitateEvent(expandSection, 'dragLeave', true);
|
||||
$('.collapsed').on('dragEnter', CMS.HesitateEvent.toggleXpandHesitation, CMS.HesitateEvent.toggleXpandHesitation.trigger);
|
||||
$('.collapsed, .unit, .id-holder').each(function() {
|
||||
this.proportions = {width : this.offsetWidth, height : this.offsetHeight };
|
||||
// reset b/c these were holding values from aborts
|
||||
this.isover = false;
|
||||
});
|
||||
}
|
||||
|
||||
function computeIntersection(droppable, uiHelper, y) {
|
||||
/*
|
||||
* Test whether y falls within the bounds of the droppable on the Y axis
|
||||
*/
|
||||
// NOTE: this only judges y axis intersection b/c that's all we're doing right now
|
||||
// don't expand the thing being carried
|
||||
if (uiHelper.is(droppable)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$.extend(droppable, {offset : $(droppable).offset()});
|
||||
|
||||
var t = droppable.offset.top,
|
||||
b = t + droppable.proportions.height;
|
||||
|
||||
if (t === b) {
|
||||
// probably wrong values b/c invisible at the time of caching
|
||||
droppable.proportions = { width : droppable.offsetWidth, height : droppable.offsetHeight };
|
||||
b = t + droppable.proportions.height;
|
||||
}
|
||||
// equivalent to the intersects test
|
||||
return (t < y && // Bottom Half
|
||||
y < b ); // Top Half
|
||||
}
|
||||
|
||||
// NOTE: selectorsToShove is not currently being used but I left this code as it did work but not well
|
||||
function generateCheckHoverState(selectorsToOpen, selectorsToShove) {
|
||||
return function(event, ui) {
|
||||
// copied from jquery.ui.droppable.js $.ui.ddmanager.drag & other ui.intersect
|
||||
var draggable = $(this).data("ui-draggable"),
|
||||
centerY = (draggable.positionAbs || draggable.position.absolute).top + (draggable.helperProportions.height / 2);
|
||||
$(selectorsToOpen).each(function() {
|
||||
var intersects = computeIntersection(this, ui.helper, centerY),
|
||||
c = !intersects && this.isover ? "isout" : (intersects && !this.isover ? "isover" : null);
|
||||
|
||||
if(!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[c] = true;
|
||||
this[c === "isout" ? "isover" : "isout"] = false;
|
||||
$(this).trigger(c === "isover" ? "dragEnter" : "dragLeave");
|
||||
});
|
||||
|
||||
$(selectorsToShove).each(function() {
|
||||
var intersectsBottom = computeIntersection(this, ui.helper, (draggable.positionAbs || draggable.position.absolute).top);
|
||||
|
||||
if ($(this).hasClass('ui-dragging-pushup')) {
|
||||
if (!intersectsBottom) {
|
||||
console.log('not up', $(this).data('id'));
|
||||
$(this).removeClass('ui-dragging-pushup');
|
||||
}
|
||||
}
|
||||
else if (intersectsBottom) {
|
||||
console.log('up', $(this).data('id'));
|
||||
$(this).addClass('ui-dragging-pushup');
|
||||
}
|
||||
|
||||
var intersectsTop = computeIntersection(this, ui.helper,
|
||||
(draggable.positionAbs || draggable.position.absolute).top + draggable.helperProportions.height);
|
||||
|
||||
if ($(this).hasClass('ui-dragging-pushdown')) {
|
||||
if (!intersectsTop) {
|
||||
console.log('not down', $(this).data('id'));
|
||||
$(this).removeClass('ui-dragging-pushdown');
|
||||
}
|
||||
}
|
||||
else if (intersectsTop) {
|
||||
console.log('down', $(this).data('id'));
|
||||
$(this).addClass('ui-dragging-pushdown');
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function removeHesitate(event, ui) {
|
||||
$('.collapsed').off('dragEnter', CMS.HesitateEvent.toggleXpandHesitation.trigger);
|
||||
$('.ui-dragging-pushdown').removeClass('ui-dragging-pushdown');
|
||||
$('.ui-dragging-pushup').removeClass('ui-dragging-pushup');
|
||||
CMS.HesitateEvent.toggleXpandHesitation = null;
|
||||
}
|
||||
|
||||
function expandSection(event) {
|
||||
$(event.delegateTarget).removeClass('collapsed', 400);
|
||||
// don't descend to icon's on children (which aren't under first child) only to this element's icon
|
||||
$(event.delegateTarget).children().first().find('.expand-collapse-icon').removeClass('expand', 400).addClass('collapse');
|
||||
}
|
||||
|
||||
function onUnitReordered(event, ui) {
|
||||
// a unit's been dropped on this subsection,
|
||||
// figure out where it came from and where it slots in.
|
||||
_handleReorder(event, ui, 'subsection-id', 'li:.leaf');
|
||||
}
|
||||
|
||||
function onSubsectionReordered(event, ui) {
|
||||
// a subsection has been dropped on this section,
|
||||
// figure out where it came from and where it slots in.
|
||||
_handleReorder(event, ui, 'section-id', 'li:.branch');
|
||||
}
|
||||
|
||||
function onSectionReordered(event, ui) {
|
||||
// a section moved w/in the overall (cannot change course via this, so no parentage change possible, just order)
|
||||
_handleReorder(event, ui, 'course-id', '.courseware-section');
|
||||
}
|
||||
|
||||
function _handleReorder(event, ui, parentIdField, childrenSelector) {
|
||||
// figure out where it came from and where it slots in.
|
||||
var subsection_id = $(event.target).data(parentIdField);
|
||||
var _els = $(event.target).children(childrenSelector);
|
||||
var children = _els.map(function(idx, el) { return $(el).data('id'); }).get();
|
||||
// if new to this parent, figure out which parent to remove it from and do so
|
||||
if (!_.contains(children, ui.draggable.data('id'))) {
|
||||
var old_parent = ui.draggable.parent();
|
||||
var old_children = old_parent.children(childrenSelector).map(function(idx, el) { return $(el).data('id'); }).get();
|
||||
old_children = _.without(old_children, ui.draggable.data('id'));
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
contentType: "application/json",
|
||||
data:JSON.stringify({ 'id' : old_parent.data(parentIdField), 'children' : old_children})
|
||||
});
|
||||
}
|
||||
else {
|
||||
// staying in same parent
|
||||
// remove so that the replacement in the right place doesn't double it
|
||||
children = _.without(children, ui.draggable.data('id'));
|
||||
}
|
||||
// add to this parent (figure out where)
|
||||
for (var i = 0; i < _els.length; i++) {
|
||||
if (!ui.draggable.is(_els[i]) && ui.offset.top < $(_els[i]).offset().top) {
|
||||
// insert at i in children and _els
|
||||
ui.draggable.insertBefore($(_els[i]));
|
||||
// TODO figure out correct way to have it remove the style: top:n; setting (and similar line below)
|
||||
ui.draggable.attr("style", "position:relative;");
|
||||
children.splice(i, 0, ui.draggable.data('id'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// see if it goes at end (the above loop didn't insert it)
|
||||
if (!_.contains(children, ui.draggable.data('id'))) {
|
||||
$(event.target).append(ui.draggable);
|
||||
ui.draggable.attr("style", "position:relative;"); // STYLE hack too
|
||||
children.push(ui.draggable.data('id'));
|
||||
}
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
contentType: "application/json",
|
||||
data:JSON.stringify({ 'id' : subsection_id, 'children' : children})
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
|
||||
input.courseware-unit-search-input {
|
||||
float: left;
|
||||
width: 260px;
|
||||
background-color: #fff;
|
||||
float: left;
|
||||
width: 260px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.branch {
|
||||
|
||||
.section-item {
|
||||
@include clearfix();
|
||||
.section-item {
|
||||
@include clearfix();
|
||||
|
||||
.details {
|
||||
display: block;
|
||||
float: left;
|
||||
margin-bottom: 0;
|
||||
width: 650px;
|
||||
}
|
||||
.details {
|
||||
display: block;
|
||||
float: left;
|
||||
margin-bottom: 0;
|
||||
width: 650px;
|
||||
}
|
||||
|
||||
.gradable-status {
|
||||
float: right;
|
||||
position: relative;
|
||||
top: -4px;
|
||||
right: 50px;
|
||||
width: 145px;
|
||||
.gradable-status {
|
||||
float: right;
|
||||
position: relative;
|
||||
top: -4px;
|
||||
right: 50px;
|
||||
width: 145px;
|
||||
|
||||
.status-label {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: -5px;
|
||||
display: none;
|
||||
width: 110px;
|
||||
padding: 5px 40px 5px 10px;
|
||||
@include border-radius(3px);
|
||||
color: $lightGrey;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
line-height: 16px;
|
||||
}
|
||||
.status-label {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: -5px;
|
||||
display: none;
|
||||
width: 110px;
|
||||
padding: 5px 40px 5px 10px;
|
||||
@include border-radius(3px);
|
||||
color: $lightGrey;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 5px;
|
||||
padding: 5px;
|
||||
color: $mediumGrey;
|
||||
.menu-toggle {
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 5px;
|
||||
padding: 5px;
|
||||
color: $mediumGrey;
|
||||
|
||||
&:hover, &.is-active {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
&:hover, &.is-active {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
z-index: 1;
|
||||
display: none;
|
||||
opacity: 0.0;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 5px;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: $white;
|
||||
border: 1px solid $mediumGrey;
|
||||
font-size: 12px;
|
||||
@include border-radius(4px);
|
||||
@include box-shadow(0 1px 2px rgba(0, 0, 0, .2));
|
||||
@include transition(opacity .15s);
|
||||
.menu {
|
||||
z-index: 1;
|
||||
display: none;
|
||||
opacity: 0.0;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 5px;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: $white;
|
||||
border: 1px solid $mediumGrey;
|
||||
font-size: 12px;
|
||||
@include border-radius(4px);
|
||||
@include box-shadow(0 1px 2px rgba(0, 0, 0, .2));
|
||||
@include transition(opacity .15s);
|
||||
|
||||
|
||||
li {
|
||||
width: 115px;
|
||||
margin-bottom: 3px;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px solid $lightGrey;
|
||||
li {
|
||||
width: 115px;
|
||||
margin-bottom: 3px;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px solid $lightGrey;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border: none;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border: none;
|
||||
|
||||
a {
|
||||
color: $darkGrey;
|
||||
}
|
||||
}
|
||||
}
|
||||
a {
|
||||
color: $darkGrey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: $blue;
|
||||
@@ -127,262 +127,262 @@ input.courseware-unit-search-input {
|
||||
|
||||
|
||||
.courseware-section {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
border: 1px solid $mediumGrey;
|
||||
margin-top: 15px;
|
||||
padding-bottom: 12px;
|
||||
@include box-shadow(0 1px 1px rgba(0, 0, 0, 0.1));
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
border: 1px solid $mediumGrey;
|
||||
margin-top: 15px;
|
||||
padding-bottom: 12px;
|
||||
@include box-shadow(0 1px 1px rgba(0, 0, 0, 0.1));
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
&.collapsed {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
float: left;
|
||||
line-height: 29px;
|
||||
}
|
||||
label {
|
||||
float: left;
|
||||
line-height: 29px;
|
||||
}
|
||||
|
||||
.datepair {
|
||||
float: left;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.datepair {
|
||||
float: left;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.section-published-date {
|
||||
position: absolute;
|
||||
top: 19px;
|
||||
right: 90px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
background: $lightGrey;
|
||||
text-align: right;
|
||||
.section-published-date {
|
||||
position: absolute;
|
||||
top: 19px;
|
||||
right: 90px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
background: $lightGrey;
|
||||
text-align: right;
|
||||
|
||||
.published-status {
|
||||
font-size: 12px;
|
||||
margin-right: 15px;
|
||||
.published-status {
|
||||
font-size: 12px;
|
||||
margin-right: 15px;
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-button {
|
||||
@include blue-button;
|
||||
}
|
||||
.schedule-button {
|
||||
@include blue-button;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
@include blue-button;
|
||||
}
|
||||
.edit-button {
|
||||
@include blue-button;
|
||||
}
|
||||
|
||||
.schedule-button,
|
||||
.edit-button {
|
||||
font-size: 11px;
|
||||
padding: 3px 15px 5px;
|
||||
}
|
||||
}
|
||||
.schedule-button,
|
||||
.edit-button {
|
||||
font-size: 11px;
|
||||
padding: 3px 15px 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.datepair .date,
|
||||
.datepair .time {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
@include box-shadow(none);
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: $blue;
|
||||
cursor: pointer;
|
||||
}
|
||||
.datepair .date,
|
||||
.datepair .time {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
@include box-shadow(none);
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: $blue;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.datepair .date {
|
||||
width: 80px;
|
||||
}
|
||||
.datepair .date {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.datepair .time {
|
||||
width: 65px;
|
||||
}
|
||||
.datepair .time {
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
&.collapsed .subsection-list,
|
||||
.collapsed .subsection-list,
|
||||
.collapsed > ol {
|
||||
display: none !important;
|
||||
}
|
||||
&.collapsed .subsection-list,
|
||||
.collapsed .subsection-list,
|
||||
.collapsed > ol {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
header {
|
||||
min-height: 75px;
|
||||
@include clearfix();
|
||||
header {
|
||||
min-height: 75px;
|
||||
@include clearfix();
|
||||
|
||||
.item-details, .section-published-date {
|
||||
.item-details, .section-published-date {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: inline-block;
|
||||
padding: 20px 0 10px 0;
|
||||
@include clearfix();
|
||||
.item-details {
|
||||
display: inline-block;
|
||||
padding: 20px 0 10px 0;
|
||||
@include clearfix();
|
||||
|
||||
.section-name {
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
width: 350px;
|
||||
font-size: 19px;
|
||||
font-weight: bold;
|
||||
color: $blue;
|
||||
}
|
||||
.section-name {
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
width: 350px;
|
||||
font-size: 19px;
|
||||
font-weight: bold;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
.section-name-span {
|
||||
cursor: pointer;
|
||||
@include transition(color .15s);
|
||||
.section-name-span {
|
||||
cursor: pointer;
|
||||
@include transition(color .15s);
|
||||
|
||||
&:hover {
|
||||
color: $orange;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
color: $orange;
|
||||
}
|
||||
}
|
||||
|
||||
.section-name-edit {
|
||||
position: relative;
|
||||
width: 400px;
|
||||
background: $white;
|
||||
.section-name-edit {
|
||||
position: relative;
|
||||
width: 400px;
|
||||
background: $white;
|
||||
|
||||
input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
@include blue-button;
|
||||
padding: 7px 20px 7px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
@include blue-button;
|
||||
padding: 7px 20px 7px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
@include white-button;
|
||||
padding: 7px 20px 7px;
|
||||
}
|
||||
}
|
||||
.cancel-button {
|
||||
@include white-button;
|
||||
padding: 7px 20px 7px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-published-date {
|
||||
float: right;
|
||||
width: 265px;
|
||||
margin-right: 220px;
|
||||
@include border-radius(3px);
|
||||
background: $lightGrey;
|
||||
.section-published-date {
|
||||
float: right;
|
||||
width: 265px;
|
||||
margin-right: 220px;
|
||||
@include border-radius(3px);
|
||||
background: $lightGrey;
|
||||
|
||||
.published-status {
|
||||
font-size: 12px;
|
||||
margin-right: 15px;
|
||||
.published-status {
|
||||
font-size: 12px;
|
||||
margin-right: 15px;
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-button {
|
||||
@include blue-button;
|
||||
}
|
||||
.schedule-button {
|
||||
@include blue-button;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
@include blue-button;
|
||||
}
|
||||
.edit-button {
|
||||
@include blue-button;
|
||||
}
|
||||
|
||||
.schedule-button,
|
||||
.edit-button {
|
||||
font-size: 11px;
|
||||
padding: 3px 15px 5px;
|
||||
|
||||
}
|
||||
}
|
||||
.schedule-button,
|
||||
.edit-button {
|
||||
font-size: 11px;
|
||||
padding: 3px 15px 5px;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.gradable-status {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 70px;
|
||||
width: 145px;
|
||||
.gradable-status {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 70px;
|
||||
width: 145px;
|
||||
|
||||
.status-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2px;
|
||||
display: none;
|
||||
width: 100px;
|
||||
padding: 10px 35px 10px 10px;
|
||||
@include border-radius(3px);
|
||||
background: $lightGrey;
|
||||
color: $lightGrey;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
line-height: 16px;
|
||||
}
|
||||
.status-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2px;
|
||||
display: none;
|
||||
width: 100px;
|
||||
padding: 10px 35px 10px 10px;
|
||||
@include border-radius(3px);
|
||||
background: $lightGrey;
|
||||
color: $lightGrey;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 5px;
|
||||
padding: 5px;
|
||||
color: $lightGrey;
|
||||
.menu-toggle {
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 5px;
|
||||
padding: 5px;
|
||||
color: $lightGrey;
|
||||
|
||||
&:hover, &.is-active {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
&:hover, &.is-active {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
z-index: 1;
|
||||
display: none;
|
||||
opacity: 0.0;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 2px;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: $white;
|
||||
border: 1px solid $mediumGrey;
|
||||
font-size: 12px;
|
||||
@include border-radius(4px);
|
||||
@include box-shadow(0 1px 2px rgba(0, 0, 0, .2));
|
||||
@include transition(opacity .15s);
|
||||
@include transition(display .15s);
|
||||
.menu {
|
||||
z-index: 1;
|
||||
display: none;
|
||||
opacity: 0.0;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 2px;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: $white;
|
||||
border: 1px solid $mediumGrey;
|
||||
font-size: 12px;
|
||||
@include border-radius(4px);
|
||||
@include box-shadow(0 1px 2px rgba(0, 0, 0, .2));
|
||||
@include transition(opacity .15s);
|
||||
@include transition(display .15s);
|
||||
|
||||
|
||||
li {
|
||||
width: 115px;
|
||||
margin-bottom: 3px;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px solid $lightGrey;
|
||||
li {
|
||||
width: 115px;
|
||||
margin-bottom: 3px;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px solid $lightGrey;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border: none;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border: none;
|
||||
|
||||
a {
|
||||
color: $darkGrey;
|
||||
}
|
||||
}
|
||||
}
|
||||
a {
|
||||
color: $darkGrey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
a {
|
||||
|
||||
&.is-selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.is-selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dropdown state
|
||||
&.is-active {
|
||||
// dropdown state
|
||||
&.is-active {
|
||||
|
||||
.menu {
|
||||
z-index: 1000;
|
||||
display: block;
|
||||
opacity: 1.0;
|
||||
}
|
||||
.menu {
|
||||
z-index: 1000;
|
||||
display: block;
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
|
||||
.menu-toggle {
|
||||
@@ -408,256 +408,272 @@ input.courseware-unit-search-input {
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
margin-top: 21px;
|
||||
margin-right: 12px;
|
||||
.item-actions {
|
||||
margin-top: 21px;
|
||||
margin-right: 12px;
|
||||
|
||||
.edit-button,
|
||||
.delete-button {
|
||||
margin-top: -3px;
|
||||
}
|
||||
}
|
||||
.edit-button,
|
||||
.delete-button {
|
||||
margin-top: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.expand-collapse-icon {
|
||||
float: left;
|
||||
margin: 29px 6px 16px 16px;
|
||||
@include transition(none);
|
||||
.expand-collapse-icon {
|
||||
float: left;
|
||||
margin: 29px 6px 16px 16px;
|
||||
@include transition(none);
|
||||
|
||||
&.expand {
|
||||
background-position: 0 0;
|
||||
}
|
||||
&.expand {
|
||||
background-position: 0 0;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
|
||||
}
|
||||
}
|
||||
&.collapsed {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
margin-left: 11px;
|
||||
}
|
||||
}
|
||||
.drag-handle {
|
||||
margin-left: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: $blue;
|
||||
}
|
||||
h3 {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
.section-name-span {
|
||||
cursor: pointer;
|
||||
@include transition(color .15s);
|
||||
.section-name-span {
|
||||
cursor: pointer;
|
||||
@include transition(color .15s);
|
||||
|
||||
&:hover {
|
||||
color: $orange;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
color: $orange;
|
||||
}
|
||||
}
|
||||
|
||||
.section-name-form {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.section-name-form {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.section-name-edit {
|
||||
input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
@include blue-button;
|
||||
padding: 7px 20px 7px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.section-name-edit {
|
||||
input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
@include blue-button;
|
||||
padding: 7px 20px 7px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
@include white-button;
|
||||
padding: 7px 20px 7px;
|
||||
}
|
||||
}
|
||||
.cancel-button {
|
||||
@include white-button;
|
||||
padding: 7px 20px 7px;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 12px;
|
||||
color: #878e9d;
|
||||
h4 {
|
||||
font-size: 12px;
|
||||
color: #878e9d;
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.list-header {
|
||||
@include linear-gradient(top, transparent, rgba(0, 0, 0, .1));
|
||||
background-color: #ced2db;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
.list-header {
|
||||
@include linear-gradient(top, transparent, rgba(0, 0, 0, .1));
|
||||
background-color: #ced2db;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.subsection-list {
|
||||
margin: 0 12px;
|
||||
.subsection-list {
|
||||
margin: 0 12px;
|
||||
|
||||
> ol {
|
||||
@include tree-view;
|
||||
border-top-width: 0;
|
||||
}
|
||||
}
|
||||
> ol {
|
||||
@include tree-view;
|
||||
border-top-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.new-section {
|
||||
header {
|
||||
height: auto;
|
||||
@include clearfix();
|
||||
}
|
||||
&.new-section {
|
||||
header {
|
||||
height: auto;
|
||||
@include clearfix();
|
||||
}
|
||||
|
||||
.expand-collapse-icon {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
.expand-collapse-icon {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-button-sections {
|
||||
display: none;
|
||||
position: relative;
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
position: relative;
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
|
||||
font-size: 13px;
|
||||
color: $darkGrey;
|
||||
font-size: 13px;
|
||||
color: $darkGrey;
|
||||
|
||||
&.is-shown {
|
||||
display: block;
|
||||
}
|
||||
&.is-shown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ss-icon {
|
||||
@include border-radius(20px);
|
||||
position: relative;
|
||||
top: -1px;
|
||||
display: inline-block;
|
||||
margin-right: 2px;
|
||||
line-height: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.ss-icon {
|
||||
@include border-radius(20px);
|
||||
position: relative;
|
||||
top: -1px;
|
||||
display: inline-block;
|
||||
margin-right: 2px;
|
||||
line-height: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-block;
|
||||
}
|
||||
.label {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.new-section-name,
|
||||
.new-subsection-name-input {
|
||||
width: 515px;
|
||||
width: 515px;
|
||||
}
|
||||
|
||||
.new-section-name-save,
|
||||
.new-subsection-name-save {
|
||||
@include blue-button;
|
||||
padding: 4px 20px 7px;
|
||||
margin: 0 5px;
|
||||
color: #fff !important;
|
||||
@include blue-button;
|
||||
padding: 4px 20px 7px;
|
||||
margin: 0 5px;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.new-section-name-cancel,
|
||||
.new-subsection-name-cancel {
|
||||
@include white-button;
|
||||
padding: 4px 20px 7px;
|
||||
color: #8891a1 !important;
|
||||
@include white-button;
|
||||
padding: 4px 20px 7px;
|
||||
color: #8891a1 !important;
|
||||
}
|
||||
|
||||
.dummy-calendar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 55px;
|
||||
left: 110px;
|
||||
z-index: 9999;
|
||||
border: 1px solid #3C3C3C;
|
||||
@include box-shadow(0 1px 15px rgba(0, 0, 0, .2));
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 55px;
|
||||
left: 110px;
|
||||
z-index: 9999;
|
||||
border: 1px solid #3C3C3C;
|
||||
@include box-shadow(0 1px 15px rgba(0, 0, 0, .2));
|
||||
}
|
||||
|
||||
.unit-name-input {
|
||||
padding: 20px 40px;
|
||||
padding: 20px 40px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
font-size: 20px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
background: url(../img/preview.jpg) center top no-repeat;
|
||||
background: url(../img/preview.jpg) center top no-repeat;
|
||||
}
|
||||
|
||||
.edit-subsection-publish-settings {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 100px;
|
||||
left: 50%;
|
||||
z-index: 99999;
|
||||
width: 600px;
|
||||
margin-left: -300px;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 100px;
|
||||
left: 50%;
|
||||
z-index: 99999;
|
||||
width: 600px;
|
||||
margin-left: -300px;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
|
||||
.settings {
|
||||
padding: 40px;
|
||||
}
|
||||
.settings {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 34px;
|
||||
font-weight: 300;
|
||||
}
|
||||
h3 {
|
||||
font-size: 34px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.picker {
|
||||
margin: 30px 0 65px;
|
||||
}
|
||||
.picker {
|
||||
margin: 30px 0 65px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 30px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.description {
|
||||
margin-top: 30px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.start-date,
|
||||
.start-time {
|
||||
font-size: 19px;
|
||||
}
|
||||
.start-date,
|
||||
.start-time {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
@include blue-button;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.save-button {
|
||||
@include blue-button;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
@include white-button;
|
||||
}
|
||||
.cancel-button {
|
||||
@include white-button;
|
||||
}
|
||||
|
||||
.save-button,
|
||||
.cancel-button {
|
||||
font-size: 16px;
|
||||
}
|
||||
.save-button,
|
||||
.cancel-button {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-all-button {
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: $darkGrey;
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: $darkGrey;
|
||||
}
|
||||
|
||||
// sort/drag and drop
|
||||
.ui-droppable {
|
||||
min-height: 20px;
|
||||
@include transition (padding 0.5s ease-in-out 0s);
|
||||
min-height: 20px;
|
||||
padding: 0;
|
||||
|
||||
&.dropover {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
&.dropover {
|
||||
padding: 15px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-draggable-dragging {
|
||||
@include box-shadow(0 1px 2px rgba(0, 0, 0, .3));
|
||||
border: 1px solid $darkGrey;
|
||||
opacity : 0.2;
|
||||
&:hover {
|
||||
opacity : 1.0;
|
||||
.section-item {
|
||||
background: $yellow !important;
|
||||
}
|
||||
}
|
||||
|
||||
// hiding unit button - temporary fix until this semantically corrected
|
||||
.new-unit-item {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
ol.ui-droppable .branch:first-child .section-item {
|
||||
border-top: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@
|
||||
.wrapper-component-editor {
|
||||
z-index: 9999;
|
||||
position: relative;
|
||||
background: $lightBluishGrey2;
|
||||
}
|
||||
|
||||
.component-editor {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<script src="${static.url('js/vendor/date.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/models/course_relative.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/grader-select-view.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/overview.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/models/settings/course_grading_policy.js')}"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
98
common/djangoapps/static_replace/__init__.py
Normal file
98
common/djangoapps/static_replace/__init__.py
Normal file
@@ -0,0 +1,98 @@
|
||||
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):
|
||||
return r"""
|
||||
(?x) # flags=re.VERBOSE
|
||||
(?P<quote>\\?['"]) # the opening quotes
|
||||
(?P<prefix>{prefix}) # theeprefix
|
||||
(?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')
|
||||
|
||||
# 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)
|
||||
# If we're in debug mode, and the file as requested exists, then don't change the links
|
||||
elif (settings.DEBUG and finders.find(rest, True)):
|
||||
return original
|
||||
# Otherwise, look the file up in staticfiles_storage without the data directory
|
||||
else:
|
||||
try:
|
||||
url = staticfiles_storage.url(rest)
|
||||
# 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, data_directory, '/', rest])
|
||||
|
||||
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()
|
||||
64
common/djangoapps/static_replace/test/test_static_replace.py
Normal file
64
common/djangoapps/static_replace/test/test_static_replace.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from nose.tools import assert_equals
|
||||
from static_replace import replace_static_urls, replace_course_urls
|
||||
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.finders')
|
||||
@patch('static_replace.settings')
|
||||
def test_debug_no_modify(mock_settings, mock_finders):
|
||||
mock_settings.DEBUG = True
|
||||
mock_finders.find.return_value = True
|
||||
|
||||
assert_equals(STATIC_SOURCE, replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY))
|
||||
|
||||
mock_finders.find.assert_called_once_with('file.png', True)
|
||||
|
||||
|
||||
@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_settings.DEBUG = False
|
||||
mock_storage.url.side_effect = Exception
|
||||
|
||||
assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY))
|
||||
@@ -2,10 +2,10 @@ 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
|
||||
@@ -49,10 +49,10 @@ 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,10 +61,9 @@ 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
|
||||
|
||||
|
||||
def grade_histogram(module_id):
|
||||
''' Print out a histogram of grades on a given problem.
|
||||
Part of staff member debug info.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
// Generated by CoffeeScript 1.4.0
|
||||
(function() {
|
||||
var MinimaxProblemDisplay, root,
|
||||
__hasProp = {}.hasOwnProperty,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
// Generated by CoffeeScript 1.4.0
|
||||
(function() {
|
||||
var TestProblemGenerator, root,
|
||||
__hasProp = {}.hasOwnProperty,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
// Generated by CoffeeScript 1.4.0
|
||||
(function() {
|
||||
var TestProblemGrader, root,
|
||||
__hasProp = {}.hasOwnProperty,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
// Generated by CoffeeScript 1.4.0
|
||||
(function() {
|
||||
var XProblemDisplay, XProblemGenerator, XProblemGrader, root;
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ def get_logger_config(log_dir,
|
||||
if console_loglevel is None or console_loglevel not in LOG_LEVELS:
|
||||
console_loglevel = 'DEBUG' if debug else 'INFO'
|
||||
|
||||
if service_variant is None:
|
||||
# default to a blank string so that if SERVICE_VARIANT is not
|
||||
# set we will not log to a sub directory
|
||||
service_variant = ''
|
||||
|
||||
hostname = platform.node().split(".")[0]
|
||||
syslog_format = ("[service_variant={service_variant}]"
|
||||
"[%(name)s][env:{logging_env}] %(levelname)s "
|
||||
|
||||
@@ -20,14 +20,17 @@ setup(
|
||||
"book = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"chapter = xmodule.seq_module:SequenceDescriptor",
|
||||
"combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor",
|
||||
"conditional = xmodule.conditional_module:ConditionalDescriptor",
|
||||
"course = xmodule.course_module:CourseDescriptor",
|
||||
"customtag = xmodule.template_module:CustomTagDescriptor",
|
||||
"discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"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",
|
||||
|
||||
@@ -2,6 +2,7 @@ import cgi
|
||||
import datetime
|
||||
import dateutil
|
||||
import dateutil.parser
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
@@ -25,6 +26,22 @@ 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 +155,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
|
||||
|
||||
@@ -356,7 +369,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):
|
||||
'''
|
||||
@@ -389,38 +402,54 @@ class CapaModule(XModule):
|
||||
})
|
||||
return json.dumps(d, cls=ComplexEncoder)
|
||||
|
||||
def is_past_due(self):
|
||||
"""
|
||||
Is it now past this problem's due date, including grace period?
|
||||
"""
|
||||
return (self.close_date is not None and
|
||||
datetime.datetime.utcnow() > self.close_date)
|
||||
|
||||
def closed(self):
|
||||
''' Is the student still allowed to submit answers? '''
|
||||
if self.attempts == self.max_attempts:
|
||||
return True
|
||||
if self.close_date is not None and datetime.datetime.utcnow() > self.close_date:
|
||||
if self.is_past_due():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_completed(self):
|
||||
# used by conditional module
|
||||
# return self.answer_available()
|
||||
return self.lcp.done
|
||||
|
||||
def is_attempted(self):
|
||||
# used by conditional module
|
||||
return self.attempts > 0
|
||||
|
||||
def answer_available(self):
|
||||
''' Is the user allowed to see an answer?
|
||||
'''
|
||||
Is the user allowed to see an answer?
|
||||
'''
|
||||
if self.show_answer == '':
|
||||
return False
|
||||
|
||||
if self.show_answer == "never":
|
||||
elif self.show_answer == "never":
|
||||
return False
|
||||
|
||||
# Admins can see the answer, unless the problem explicitly prevents it
|
||||
if self.system.user_is_staff:
|
||||
elif self.system.user_is_staff:
|
||||
# This is after the 'never' check because admins can see the answer
|
||||
# unless the problem explicitly prevents it
|
||||
return True
|
||||
|
||||
if self.show_answer == 'attempted':
|
||||
elif self.show_answer == 'attempted':
|
||||
return self.attempts > 0
|
||||
|
||||
if self.show_answer == 'answered':
|
||||
elif self.show_answer == 'answered':
|
||||
# NOTE: this is slightly different from 'attempted' -- resetting the problems
|
||||
# makes lcp.done False, but leaves attempts unchanged.
|
||||
return self.lcp.done
|
||||
|
||||
if self.show_answer == 'closed':
|
||||
elif self.show_answer == 'closed':
|
||||
return self.closed()
|
||||
|
||||
if self.show_answer == 'always':
|
||||
elif self.show_answer == 'past_due':
|
||||
return self.is_past_due()
|
||||
elif self.show_answer == 'always':
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -461,7 +490,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]}
|
||||
@@ -669,18 +698,18 @@ class CapaDescriptor(RawDescriptor):
|
||||
# TODO (vshnayder): do problems have any other metadata? Do they
|
||||
# actually use type and points?
|
||||
metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points')
|
||||
|
||||
|
||||
def get_context(self):
|
||||
_context = RawDescriptor.get_context(self)
|
||||
_context.update({'markdown': self.metadata.get('markdown', '')})
|
||||
return _context
|
||||
|
||||
|
||||
@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
|
||||
if 'markdown' in subset:
|
||||
subset.remove('markdown')
|
||||
subset.remove('markdown')
|
||||
return subset
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ class CombinedOpenEndedRubric(object):
|
||||
'view_only': self.view_only})
|
||||
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):
|
||||
|
||||
141
common/lib/xmodule/xmodule/conditional_module.py
Normal file
141
common/lib/xmodule/xmodule/conditional_module.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
|
||||
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>
|
||||
|
||||
<conditional condition="require_attempted" required="tag/url_name1&tag/url_name2">
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
|
||||
'''
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/conditional/display.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]}
|
||||
|
||||
js_module_name = "Conditional"
|
||||
css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]}
|
||||
|
||||
|
||||
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','')
|
||||
#log.debug('conditional module required=%s' % self.required_modules_list)
|
||||
|
||||
def _get_required_modules(self):
|
||||
self.required_modules = []
|
||||
for descriptor in self.descriptor.get_required_module_descriptors():
|
||||
module = self.system.get_module(descriptor)
|
||||
self.required_modules.append(module)
|
||||
#log.debug('required_modules=%s' % (self.required_modules))
|
||||
|
||||
def is_condition_satisfied(self):
|
||||
self._get_required_modules()
|
||||
|
||||
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:
|
||||
#log.debug('in is_condition_satisfied; student_answers=%s' % module.lcp.student_answers)
|
||||
#log.debug('in is_condition_satisfied; instance_state=%s' % module.instance_state)
|
||||
if not hasattr(module, 'is_completed'):
|
||||
raise Exception('Error in conditional module: required module %s has no .is_completed() method' % module)
|
||||
if not module.is_completed():
|
||||
log.debug('conditional module: %s not completed' % module)
|
||||
return False
|
||||
else:
|
||||
log.debug('conditional module: %s IS completed' % module)
|
||||
return True
|
||||
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:
|
||||
if not hasattr(module, 'is_attempted'):
|
||||
raise Exception('Error in conditional module: required module %s has no .is_attempted() method' % module)
|
||||
if not module.is_attempted():
|
||||
log.debug('conditional module: %s not attempted' % module)
|
||||
return False
|
||||
else:
|
||||
log.debug('conditional module: %s IS attempted' % module)
|
||||
return True
|
||||
else:
|
||||
raise Exception('Error in conditional module: unknown condition "%s"' % self.condition)
|
||||
|
||||
return True
|
||||
|
||||
def get_html(self):
|
||||
self.is_condition_satisfied()
|
||||
return self.system.render_template('conditional_ajax.html', {
|
||||
'element_id': self.location.html_id(),
|
||||
'id': self.id,
|
||||
'ajax_url': self.system.ajax_url,
|
||||
})
|
||||
|
||||
def handle_ajax(self, dispatch, post):
|
||||
'''
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
'''
|
||||
#log.debug('conditional_module handle_ajax: dispatch=%s' % dispatch)
|
||||
|
||||
if not self.is_condition_satisfied():
|
||||
context = {'module': self}
|
||||
html = self.system.render_template('conditional_module.html', context)
|
||||
return json.dumps({'html': html})
|
||||
|
||||
if self.contents is None:
|
||||
self.contents = [child.get_html() for child in self.get_display_items()]
|
||||
|
||||
# for now, just deal with one child
|
||||
html = self.contents[0]
|
||||
|
||||
return json.dumps({'html': html})
|
||||
|
||||
class ConditionalDescriptor(SequenceDescriptor):
|
||||
module_class = ConditionalModule
|
||||
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = False
|
||||
|
||||
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('&')]
|
||||
self.required_module_locations = []
|
||||
for (tag, name) in required_module_list:
|
||||
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]
|
||||
|
||||
@@ -442,12 +442,13 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,16 +5,8 @@ import requests
|
||||
from requests.exceptions import RequestException, ConnectionError, HTTPError
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, Http404
|
||||
|
||||
from courseware.access import has_access
|
||||
from util.json_request import expect_json
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError
|
||||
from lxml import etree
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
from xmodule.x_module import ModuleSystem
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,7 +23,7 @@ class GradingService(object):
|
||||
self.url = config['url']
|
||||
self.login_url = self.url + '/login/'
|
||||
self.session = requests.session()
|
||||
self.system = ModuleSystem(None, None, None, render_to_string, None)
|
||||
self.system = config['system']
|
||||
|
||||
def _login(self):
|
||||
"""
|
||||
@@ -42,20 +34,20 @@ class GradingService(object):
|
||||
Returns the decoded json dict of the response.
|
||||
"""
|
||||
response = self.session.post(self.login_url,
|
||||
{'username': self.username,
|
||||
'password': self.password,})
|
||||
{'username': self.username,
|
||||
'password': self.password,})
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json
|
||||
|
||||
def post(self, url, data, allow_redirects=False):
|
||||
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)
|
||||
allow_redirects=allow_redirects)
|
||||
r = self._try_with_login(op)
|
||||
except (RequestException, ConnectionError, HTTPError) as err:
|
||||
# reraise as promised GradingServiceError, but preserve stacktrace.
|
||||
@@ -69,8 +61,8 @@ class GradingService(object):
|
||||
"""
|
||||
log.debug(params)
|
||||
op = lambda: self.session.get(url,
|
||||
allow_redirects=allow_redirects,
|
||||
params=params)
|
||||
allow_redirects=allow_redirects,
|
||||
params=params)
|
||||
try:
|
||||
r = self._try_with_login(op)
|
||||
except (RequestException, ConnectionError, HTTPError) as err:
|
||||
@@ -78,7 +70,7 @@ class GradingService(object):
|
||||
raise GradingServiceError, str(err), sys.exc_info()[2]
|
||||
|
||||
return r.text
|
||||
|
||||
|
||||
|
||||
def _try_with_login(self, operation):
|
||||
"""
|
||||
@@ -96,8 +88,8 @@ class GradingService(object):
|
||||
r = self._login()
|
||||
if r and not r.get('success'):
|
||||
log.warning("Couldn't log into staff_grading backend. Response: %s",
|
||||
r)
|
||||
# try again
|
||||
r)
|
||||
# try again
|
||||
response = operation()
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -113,23 +105,23 @@ class GradingService(object):
|
||||
"""
|
||||
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, False)
|
||||
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,
|
||||
# if we can't parse the rubric into HTML,
|
||||
except etree.XMLSyntaxError, RubricParsingError:
|
||||
log.exception("Cannot parse rubric string. Raw string: {0}"
|
||||
.format(rubric))
|
||||
.format(rubric))
|
||||
return {'success': False,
|
||||
'error': 'Error displaying submission'}
|
||||
'error': 'Error displaying submission'}
|
||||
except ValueError:
|
||||
log.exception("Error parsing response: {0}".format(response))
|
||||
return {'success': False,
|
||||
'error': "Error displaying submission"}
|
||||
|
||||
|
||||
|
||||
|
||||
'error': "Error displaying submission"}
|
||||
@@ -329,7 +329,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 +351,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
|
||||
|
||||
26
common/lib/xmodule/xmodule/js/src/conditional/display.coffee
Normal file
26
common/lib/xmodule/xmodule/js/src/conditional/display.coffee
Normal file
@@ -0,0 +1,26 @@
|
||||
class @Conditional
|
||||
|
||||
constructor: (element) ->
|
||||
@el = $(element).find('.conditional-wrapper')
|
||||
@id = @el.data('problem-id')
|
||||
@element_id = @el.attr('id')
|
||||
@url = @el.data('url')
|
||||
@render()
|
||||
|
||||
$: (selector) ->
|
||||
$(selector, @el)
|
||||
|
||||
updateProgress: (response) =>
|
||||
if response.progress_changed
|
||||
@el.attr progress: response.progress_status
|
||||
@el.trigger('progressChanged')
|
||||
|
||||
render: (content) ->
|
||||
if content
|
||||
@el.html(content)
|
||||
XModule.loadModules(@el)
|
||||
else
|
||||
$.postWithPrefix "#{@url}/conditional_get", (response) =>
|
||||
@el.html(response.html)
|
||||
XModule.loadModules(@el)
|
||||
|
||||
@@ -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
|
||||
# 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')
|
||||
|
||||
@element.on('click', '.editor-tabs .tab', @onSwitchEditor)
|
||||
|
||||
@setupTinyMCE: (ed) ->
|
||||
setupTinyMCE: (ed) =>
|
||||
ed.addButton('wrapAsCode', {
|
||||
title : 'Code',
|
||||
image : '/static/images/ico-tinymce-code.png',
|
||||
@@ -67,19 +73,23 @@ class @HTMLEditingDescriptor
|
||||
command.setActive('wrapAsCode', e.nodeName == 'CODE')
|
||||
)
|
||||
|
||||
onSwitchEditor: (e)=>
|
||||
@visualEditor = ed
|
||||
|
||||
onSwitchEditor: (e) =>
|
||||
e.preventDefault();
|
||||
|
||||
if not $(e.currentTarget).hasClass('current')
|
||||
$('.editor-tabs .current', @element).removeClass('current')
|
||||
$(e.currentTarget).addClass('current')
|
||||
$('table.mceToolbar', @element).toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
$(@advanced_editor.getWrapperElement()).toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
$currentTarget = $(e.currentTarget)
|
||||
if not $currentTarget.hasClass('current')
|
||||
$currentTarget.addClass('current')
|
||||
@$mceToolbar.toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
@$advancedEditorWrapper.toggleClass(HTMLEditingDescriptor.isInactiveClass)
|
||||
|
||||
visualEditor = @getVisualEditor()
|
||||
if $(e.currentTarget).attr('data-tab') is 'visual'
|
||||
if $currentTarget.data('tab') is 'visual'
|
||||
@$htmlTab.removeClass('current')
|
||||
@showVisualEditor(visualEditor)
|
||||
else
|
||||
@$visualTab.removeClass('current')
|
||||
@showAdvancedEditor(visualEditor)
|
||||
|
||||
# Show the Advanced (codemirror) Editor. Pulled out as a helper method for unit testing.
|
||||
@@ -101,15 +111,19 @@ class @HTMLEditingDescriptor
|
||||
@focusVisualEditor(visualEditor)
|
||||
@showingVisualEditor = true
|
||||
|
||||
focusVisualEditor: (visualEditor) ->
|
||||
focusVisualEditor: (visualEditor) =>
|
||||
visualEditor.focus()
|
||||
if not @$mceToolbar?
|
||||
@$mceToolbar = $(@element).find('table.mceToolbar')
|
||||
|
||||
getVisualEditor: ->
|
||||
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($('.tiny-mce', this.element).attr('id'))
|
||||
return @visualEditor
|
||||
|
||||
save: ->
|
||||
@element.off('click', '.editor-tabs .tab', @onSwitchEditor)
|
||||
|
||||
@@ -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)
|
||||
@@ -7,7 +7,7 @@
|
||||
# Should not be run when we don't have a location to send back
|
||||
# to the server
|
||||
#
|
||||
# PeerGradingProblemBackend -
|
||||
# PeerGradingProblemBackend -
|
||||
# makes all the ajax requests and provides a mock interface
|
||||
# for testing purposes
|
||||
#
|
||||
@@ -15,7 +15,7 @@
|
||||
# handles the rendering and user interactions with the interface
|
||||
#
|
||||
##################################
|
||||
class PeerGradingProblemBackend
|
||||
class @PeerGradingProblemBackend
|
||||
constructor: (ajax_url, mock_backend) ->
|
||||
@mock_backend = mock_backend
|
||||
@ajax_url = ajax_url
|
||||
@@ -32,141 +32,140 @@ class PeerGradingProblemBackend
|
||||
mock: (cmd, data) ->
|
||||
if cmd == 'is_student_calibrated'
|
||||
# change to test each version
|
||||
response =
|
||||
success: true
|
||||
response =
|
||||
success: true
|
||||
calibrated: @mock_cnt >= 2
|
||||
else if cmd == 'show_calibration_essay'
|
||||
#response =
|
||||
#response =
|
||||
# success: false
|
||||
# error: "There was an error"
|
||||
@mock_cnt++
|
||||
response =
|
||||
response =
|
||||
success: true
|
||||
submission_id: 1
|
||||
submission_key: 'abcd'
|
||||
student_response: '''
|
||||
Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32.
|
||||
Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32.
|
||||
|
||||
The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.
|
||||
'''
|
||||
The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.
|
||||
'''
|
||||
prompt: '''
|
||||
<h2>S11E3: Metal Bands</h2>
|
||||
<p>Shown below are schematic band diagrams for two different metals. Both diagrams appear different, yet both of the elements are undisputably metallic in nature.</p>
|
||||
<p>* Why is it that both sodium and magnesium behave as metals, even though the s-band of magnesium is filled? </p>
|
||||
<p>This is a self-assessed open response question. Please use as much space as you need in the box below to answer the question.</p>
|
||||
'''
|
||||
<h2>S11E3: Metal Bands</h2>
|
||||
<p>Shown below are schematic band diagrams for two different metals. Both diagrams appear different, yet both of the elements are undisputably metallic in nature.</p>
|
||||
<p>* Why is it that both sodium and magnesium behave as metals, even though the s-band of magnesium is filled? </p>
|
||||
<p>This is a self-assessed open response question. Please use as much space as you need in the box below to answer the question.</p>
|
||||
'''
|
||||
rubric: '''
|
||||
<table class="rubric"><tbody><tr><th>Purpose</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-0" value="0"><label for="score-0-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-1" value="1"><label for="score-0-1">Unclear purpose or main idea</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-2" value="2"><label for="score-0-2">Communicates an identifiable purpose and/or main idea for an audience</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-3" value="3"><label for="score-0-3">Achieves a clear and distinct purpose for a targeted audience and communicates main ideas with effectively used techniques to introduce and represent ideas and insights</label>
|
||||
</td>
|
||||
</tr><tr><th>Organization</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-0" value="0"><label for="score-1-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-1" value="1"><label for="score-1-1">Organization is unclear; introduction, body, and/or conclusion are underdeveloped, missing or confusing.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-2" value="2"><label for="score-1-2">Organization is occasionally unclear; introduction, body or conclusion may be underdeveloped.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-3" value="3"><label for="score-1-3">Organization is clear and easy to follow; introduction, body and conclusion are defined and aligned with purpose.</label>
|
||||
</td>
|
||||
</tr></tbody></table>
|
||||
'''
|
||||
<table class="rubric"><tbody><tr><th>Purpose</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-0" value="0"><label for="score-0-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-1" value="1"><label for="score-0-1">Unclear purpose or main idea</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-2" value="2"><label for="score-0-2">Communicates an identifiable purpose and/or main idea for an audience</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-3" value="3"><label for="score-0-3">Achieves a clear and distinct purpose for a targeted audience and communicates main ideas with effectively used techniques to introduce and represent ideas and insights</label>
|
||||
</td>
|
||||
</tr><tr><th>Organization</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-0" value="0"><label for="score-1-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-1" value="1"><label for="score-1-1">Organization is unclear; introduction, body, and/or conclusion are underdeveloped, missing or confusing.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-2" value="2"><label for="score-1-2">Organization is occasionally unclear; introduction, body or conclusion may be underdeveloped.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-3" value="3"><label for="score-1-3">Organization is clear and easy to follow; introduction, body and conclusion are defined and aligned with purpose.</label>
|
||||
</td>
|
||||
</tr></tbody></table>
|
||||
'''
|
||||
max_score: 4
|
||||
else if cmd == 'get_next_submission'
|
||||
response =
|
||||
response =
|
||||
success: true
|
||||
submission_id: 1
|
||||
submission_key: 'abcd'
|
||||
student_response: '''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nec tristique ante. Proin at mauris sapien, quis varius leo. Morbi laoreet leo nisi. Morbi aliquam lacus ante. Cras iaculis velit sed diam mattis a fermentum urna luctus. Duis consectetur nunc vitae felis facilisis eget vulputate risus viverra. Cras consectetur ullamcorper lobortis. Nam eu gravida lorem. Nulla facilisi. Nullam quis felis enim. Mauris orci lectus, dictum id cursus in, vulputate in massa.
|
||||
|
||||
Phasellus non varius sem. Nullam commodo lacinia odio sit amet egestas. Donec ullamcorper sapien sagittis arcu volutpat placerat. Phasellus ut pretium ante. Nam dictum pulvinar nibh dapibus tristique. Sed at tellus mi, fringilla convallis justo. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus tristique rutrum nulla sed eleifend. Praesent at nunc arcu. Mauris condimentum faucibus nibh, eget commodo quam viverra sed. Morbi in tincidunt dolor. Morbi sed augue et augue interdum fermentum.
|
||||
Phasellus non varius sem. Nullam commodo lacinia odio sit amet egestas. Donec ullamcorper sapien sagittis arcu volutpat placerat. Phasellus ut pretium ante. Nam dictum pulvinar nibh dapibus tristique. Sed at tellus mi, fringilla convallis justo. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus tristique rutrum nulla sed eleifend. Praesent at nunc arcu. Mauris condimentum faucibus nibh, eget commodo quam viverra sed. Morbi in tincidunt dolor. Morbi sed augue et augue interdum fermentum.
|
||||
|
||||
Curabitur tristique purus ac arcu consequat cursus. Cras diam felis, dignissim quis placerat at, aliquet ac metus. Mauris vulputate est eu nibh imperdiet varius. Cras aliquet rhoncus elit a laoreet. Mauris consectetur erat et erat scelerisque eu faucibus dolor consequat. Nam adipiscing sagittis nisl, eu mollis massa tempor ac. Nulla scelerisque tempus blandit. Phasellus ac ipsum eros, id posuere arcu. Nullam non sapien arcu. Vivamus sit amet lorem justo, ac tempus turpis. Suspendisse pharetra gravida imperdiet. Pellentesque lacinia mi eu elit luctus pellentesque. Sed accumsan libero a magna elementum varius. Nunc eget pellentesque metus. '''
|
||||
Curabitur tristique purus ac arcu consequat cursus. Cras diam felis, dignissim quis placerat at, aliquet ac metus. Mauris vulputate est eu nibh imperdiet varius. Cras aliquet rhoncus elit a laoreet. Mauris consectetur erat et erat scelerisque eu faucibus dolor consequat. Nam adipiscing sagittis nisl, eu mollis massa tempor ac. Nulla scelerisque tempus blandit. Phasellus ac ipsum eros, id posuere arcu. Nullam non sapien arcu. Vivamus sit amet lorem justo, ac tempus turpis. Suspendisse pharetra gravida imperdiet. Pellentesque lacinia mi eu elit luctus pellentesque. Sed accumsan libero a magna elementum varius. Nunc eget pellentesque metus. '''
|
||||
prompt: '''
|
||||
<h2>S11E3: Metal Bands</h2>
|
||||
<p>Shown below are schematic band diagrams for two different metals. Both diagrams appear different, yet both of the elements are undisputably metallic in nature.</p>
|
||||
<p>* Why is it that both sodium and magnesium behave as metals, even though the s-band of magnesium is filled? </p>
|
||||
<p>This is a self-assessed open response question. Please use as much space as you need in the box below to answer the question.</p>
|
||||
'''
|
||||
<h2>S11E3: Metal Bands</h2>
|
||||
<p>Shown below are schematic band diagrams for two different metals. Both diagrams appear different, yet both of the elements are undisputably metallic in nature.</p>
|
||||
<p>* Why is it that both sodium and magnesium behave as metals, even though the s-band of magnesium is filled? </p>
|
||||
<p>This is a self-assessed open response question. Please use as much space as you need in the box below to answer the question.</p>
|
||||
'''
|
||||
rubric: '''
|
||||
<table class="rubric"><tbody><tr><th>Purpose</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-0" value="0"><label for="score-0-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-1" value="1"><label for="score-0-1">Unclear purpose or main idea</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-2" value="2"><label for="score-0-2">Communicates an identifiable purpose and/or main idea for an audience</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-3" value="3"><label for="score-0-3">Achieves a clear and distinct purpose for a targeted audience and communicates main ideas with effectively used techniques to introduce and represent ideas and insights</label>
|
||||
</td>
|
||||
</tr><tr><th>Organization</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-0" value="0"><label for="score-1-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-1" value="1"><label for="score-1-1">Organization is unclear; introduction, body, and/or conclusion are underdeveloped, missing or confusing.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-2" value="2"><label for="score-1-2">Organization is occasionally unclear; introduction, body or conclusion may be underdeveloped.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-3" value="3"><label for="score-1-3">Organization is clear and easy to follow; introduction, body and conclusion are defined and aligned with purpose.</label>
|
||||
</td>
|
||||
</tr></tbody></table>
|
||||
'''
|
||||
<table class="rubric"><tbody><tr><th>Purpose</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-0" value="0"><label for="score-0-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-1" value="1"><label for="score-0-1">Unclear purpose or main idea</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-2" value="2"><label for="score-0-2">Communicates an identifiable purpose and/or main idea for an audience</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-0" id="score-0-3" value="3"><label for="score-0-3">Achieves a clear and distinct purpose for a targeted audience and communicates main ideas with effectively used techniques to introduce and represent ideas and insights</label>
|
||||
</td>
|
||||
</tr><tr><th>Organization</th>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-0" value="0"><label for="score-1-0">No product</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-1" value="1"><label for="score-1-1">Organization is unclear; introduction, body, and/or conclusion are underdeveloped, missing or confusing.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-2" value="2"><label for="score-1-2">Organization is occasionally unclear; introduction, body or conclusion may be underdeveloped.</label>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input type="radio" class="score-selection" name="score-selection-1" id="score-1-3" value="3"><label for="score-1-3">Organization is clear and easy to follow; introduction, body and conclusion are defined and aligned with purpose.</label>
|
||||
</td>
|
||||
</tr></tbody></table>
|
||||
'''
|
||||
max_score: 4
|
||||
else if cmd == 'save_calibration_essay'
|
||||
response =
|
||||
response =
|
||||
success: true
|
||||
actual_score: 2
|
||||
else if cmd == 'save_grade'
|
||||
response =
|
||||
response =
|
||||
success: true
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class PeerGradingProblem
|
||||
class @PeerGradingProblem
|
||||
constructor: (backend) ->
|
||||
@prompt_wrapper = $('.prompt-wrapper')
|
||||
@backend = backend
|
||||
|
||||
|
||||
|
||||
# get the location of the problem
|
||||
@location = $('.peer-grading').data('location')
|
||||
# prevent this code from trying to run
|
||||
# prevent this code from trying to run
|
||||
# when we don't have a location
|
||||
if(!@location)
|
||||
return
|
||||
@@ -175,6 +174,7 @@ class PeerGradingProblem
|
||||
@submission_container = $('.submission-container')
|
||||
@prompt_container = $('.prompt-container')
|
||||
@rubric_container = $('.rubric-container')
|
||||
@flag_student_container = $('.flag-student-container')
|
||||
@calibration_panel = $('.calibration-panel')
|
||||
@grading_panel = $('.grading-panel')
|
||||
@content_panel = $('.content-panel')
|
||||
@@ -201,12 +201,13 @@ class PeerGradingProblem
|
||||
@action_button = $('.action-button')
|
||||
@calibration_feedback_button = $('.calibration-feedback-button')
|
||||
@interstitial_page_button = $('.interstitial-page-button')
|
||||
@flag_student_checkbox = $('.flag-checkbox')
|
||||
|
||||
Collapsible.setCollapsibles(@content_panel)
|
||||
|
||||
# Set up the click event handlers
|
||||
@action_button.click -> history.back()
|
||||
@calibration_feedback_button.click =>
|
||||
@calibration_feedback_button.click =>
|
||||
@calibration_feedback_panel.hide()
|
||||
@grading_wrapper.show()
|
||||
@is_calibrated_check()
|
||||
@@ -252,7 +253,8 @@ class PeerGradingProblem
|
||||
location: @location
|
||||
submission_id: @essay_id_input.val()
|
||||
submission_key: @submission_key_input.val()
|
||||
feedback: @feedback_area.val()
|
||||
feedback: @feedback_area.val()
|
||||
submission_flagged: @flag_student_checkbox.is(':checked')
|
||||
return data
|
||||
|
||||
|
||||
@@ -263,7 +265,7 @@ class PeerGradingProblem
|
||||
submit_grade: () =>
|
||||
data = @construct_data()
|
||||
@backend.post('save_grade', data, @submission_callback)
|
||||
|
||||
|
||||
|
||||
##########
|
||||
#
|
||||
@@ -298,7 +300,7 @@ class PeerGradingProblem
|
||||
@render_calibration_feedback(response)
|
||||
else if response.error
|
||||
@render_error(response.error)
|
||||
else
|
||||
else
|
||||
@render_error("Error saving calibration score")
|
||||
|
||||
# called after we submit a submission score
|
||||
@@ -327,8 +329,8 @@ class PeerGradingProblem
|
||||
# show button if we have scores for all categories
|
||||
@show_submit_button()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
##########
|
||||
#
|
||||
# Rendering methods and helpers
|
||||
@@ -341,7 +343,7 @@ class PeerGradingProblem
|
||||
# load in all the data
|
||||
@submission_container.html("<h3>Training Essay</h3>")
|
||||
@render_submission_data(response)
|
||||
# TODO: indicate that we're in calibration mode
|
||||
# TODO: indicate that we're in calibration mode
|
||||
@calibration_panel.addClass('current-state')
|
||||
@grading_panel.removeClass('current-state')
|
||||
|
||||
@@ -352,7 +354,7 @@ class PeerGradingProblem
|
||||
@grading_panel.find('.calibration-text').show()
|
||||
@calibration_panel.find('.grading-text').hide()
|
||||
@grading_panel.find('.grading-text').hide()
|
||||
|
||||
@flag_student_container.hide()
|
||||
|
||||
@submit_button.unbind('click')
|
||||
@submit_button.click @submit_calibration_essay
|
||||
@@ -379,6 +381,7 @@ class PeerGradingProblem
|
||||
@grading_panel.find('.calibration-text').hide()
|
||||
@calibration_panel.find('.grading-text').show()
|
||||
@grading_panel.find('.grading-text').show()
|
||||
@flag_student_container.show()
|
||||
|
||||
@submit_button.unbind('click')
|
||||
@submit_button.click @submit_grade
|
||||
@@ -424,12 +427,12 @@ class PeerGradingProblem
|
||||
if score == actual_score
|
||||
calibration_wrapper.append("<p>Congratulations! Your score matches the actual score!</p>")
|
||||
else
|
||||
calibration_wrapper.append("<p>Please try to understand the grading critera better to be more accurate next time.</p>")
|
||||
calibration_wrapper.append("<p>Please try to understand the grading critera better to be more accurate next time.</p>")
|
||||
|
||||
# disable score selection and submission from the grading interface
|
||||
$("input[name='score-selection']").attr('disabled', true)
|
||||
@submit_button.hide()
|
||||
|
||||
|
||||
render_interstitial_page: () =>
|
||||
@content_panel.hide()
|
||||
@interstitial_page.show()
|
||||
@@ -445,7 +448,7 @@ class PeerGradingProblem
|
||||
@submit_button.show()
|
||||
|
||||
setup_score_selection: (max_score) =>
|
||||
|
||||
|
||||
# first, get rid of all the old inputs, if any.
|
||||
@score_selection_container.html("""
|
||||
<h3>Overall Score</h3>
|
||||
@@ -456,7 +459,7 @@ class PeerGradingProblem
|
||||
for score in [0..max_score]
|
||||
id = 'score-' + score
|
||||
label = """<label for="#{id}">#{score}</label>"""
|
||||
|
||||
|
||||
input = """
|
||||
<input type="radio" name="grade-selection" id="#{id}" value="#{score}"/>
|
||||
""" # " fix broken parsing in emacs
|
||||
@@ -466,9 +469,7 @@ class PeerGradingProblem
|
||||
$("input[name='score-selection']").change @graded_callback
|
||||
$("input[name='grade-selection']").change @graded_callback
|
||||
|
||||
|
||||
|
||||
mock_backend = false
|
||||
ajax_url = $('.peer-grading').data('ajax_url')
|
||||
backend = new PeerGradingProblemBackend(ajax_url, mock_backend)
|
||||
$(document).ready(() -> new PeerGradingProblem(backend))
|
||||
#mock_backend = false
|
||||
#ajax_url = $('.peer-grading').data('ajax_url')
|
||||
#backend = new PeerGradingProblemBackend(ajax_url, mock_backend)
|
||||
#$(document).ready(() -> new PeerGradingProblem(backend))
|
||||
@@ -13,6 +13,10 @@ from urlparse import urlparse
|
||||
import requests
|
||||
from boto.s3.connection import S3Connection
|
||||
from boto.s3.key import Key
|
||||
#TODO: Settings import is needed now in order to specify the URL and keys for amazon s3 (to upload images).
|
||||
#Eventually, the goal is to replace the global django settings import with settings specifically
|
||||
#for this module. There is no easy way to do this now, so piggybacking on the django settings
|
||||
#makes sense.
|
||||
from django.conf import settings
|
||||
import pickle
|
||||
import logging
|
||||
|
||||
537
common/lib/xmodule/xmodule/peer_grading_module.py
Normal file
537
common/lib/xmodule/xmodule/peer_grading_module.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""
|
||||
This module provides an interface on the grading-service backend
|
||||
for peer grading
|
||||
|
||||
Use peer_grading_service() to get the version specified
|
||||
in settings.PEER_GRADING_INTERFACE
|
||||
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from combined_open_ended_rubric import CombinedOpenEndedRubric
|
||||
from lxml import etree
|
||||
|
||||
import copy
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
from lxml.html import rewrite_links
|
||||
import os
|
||||
|
||||
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
|
||||
|
||||
from peer_grading_service import peer_grading_service, GradingServiceError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
USE_FOR_SINGLE_LOCATION = False
|
||||
LINK_TO_LOCATION = ""
|
||||
TRUE_DICT = [True, "True", "true", "TRUE"]
|
||||
MAX_SCORE = 1
|
||||
IS_GRADED = True
|
||||
|
||||
class PeerGradingModule(XModule):
|
||||
_VERSION = 1
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/peergrading/peer_grading.coffee'),
|
||||
resource_string(__name__, 'js/src/peergrading/peer_grading_problem.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]}
|
||||
js_module_name = "PeerGrading"
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
|
||||
# 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
|
||||
self.peer_gs = peer_grading_service(self.system)
|
||||
|
||||
self.use_for_single_location = self.metadata.get('use_for_single_location', USE_FOR_SINGLE_LOCATION)
|
||||
if isinstance(self.use_for_single_location, basestring):
|
||||
self.use_for_single_location = (self.use_for_single_location in TRUE_DICT)
|
||||
|
||||
self.is_graded = self.metadata.get('is_graded', IS_GRADED)
|
||||
if isinstance(self.is_graded, basestring):
|
||||
self.is_graded = (self.is_graded in TRUE_DICT)
|
||||
|
||||
self.link_to_location = self.metadata.get('link_to_location', USE_FOR_SINGLE_LOCATION)
|
||||
if self.use_for_single_location ==True:
|
||||
#This will raise an exception if the location is invalid
|
||||
link_to_location_object = Location(self.link_to_location)
|
||||
|
||||
self.ajax_url = self.system.ajax_url
|
||||
if not self.ajax_url.endswith("/"):
|
||||
self.ajax_url = self.ajax_url + "/"
|
||||
|
||||
self.student_data_for_location = instance_state.get('student_data_for_location', {})
|
||||
self.max_grade = instance_state.get('max_grade', MAX_SCORE)
|
||||
if not isinstance(self.max_grade, (int, long)):
|
||||
#This could result in an exception, but not wrapping in a try catch block so it moves up the stack
|
||||
self.max_grade = int(self.max_grade)
|
||||
|
||||
def _err_response(self, msg):
|
||||
"""
|
||||
Return a HttpResponse with a json dump with success=False, and the given error message.
|
||||
"""
|
||||
return {'success': False, 'error': msg}
|
||||
|
||||
def _check_required(self, get, required):
|
||||
actual = set(get.keys())
|
||||
missing = required - actual
|
||||
if len(missing) > 0:
|
||||
return False, "Missing required keys: {0}".format(', '.join(missing))
|
||||
else:
|
||||
return True, ""
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Needs to be implemented by inheritors. Renders the HTML that students see.
|
||||
@return:
|
||||
"""
|
||||
if not self.use_for_single_location:
|
||||
return self.peer_grading()
|
||||
else:
|
||||
return self.peer_grading_problem({'location' : self.link_to_location})['html']
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
"""
|
||||
Needs to be implemented by child modules. Handles AJAX events.
|
||||
@return:
|
||||
"""
|
||||
handlers = {
|
||||
'get_next_submission': self.get_next_submission,
|
||||
'show_calibration_essay': self.show_calibration_essay,
|
||||
'is_student_calibrated': self.is_student_calibrated,
|
||||
'save_grade': self.save_grade,
|
||||
'save_calibration_essay' : self.save_calibration_essay,
|
||||
'problem' : self.peer_grading_problem,
|
||||
}
|
||||
|
||||
if dispatch not in handlers:
|
||||
return 'Error'
|
||||
|
||||
d = handlers[dispatch](get)
|
||||
|
||||
return json.dumps(d, cls=ComplexEncoder)
|
||||
|
||||
def query_data_for_location(self):
|
||||
student_id = self.system.anonymous_student_id
|
||||
location = self.system.location
|
||||
success = False
|
||||
response = {}
|
||||
|
||||
try:
|
||||
response = self.peer_gs.get_data_for_location(location, student_id)
|
||||
count_graded = response['count_graded']
|
||||
count_required = response['count_required']
|
||||
success = True
|
||||
except GradingServiceError:
|
||||
log.exception("Error getting location data from controller for location {0}, student {1}"
|
||||
.format(location, student_id))
|
||||
|
||||
return success, response
|
||||
|
||||
def get_progress(self):
|
||||
pass
|
||||
|
||||
def get_score(self):
|
||||
if not self.use_for_single_location or not self.is_graded:
|
||||
return None
|
||||
|
||||
try:
|
||||
count_graded = self.student_data_for_location['count_graded']
|
||||
count_required = self.student_data_for_location['count_required']
|
||||
except:
|
||||
success, response = self.query_data_for_location()
|
||||
if not success:
|
||||
log.exception("No instance data found and could not get data from controller for loc {0} student {1}".format(
|
||||
self.system.location, self.system.anonymous_student_id
|
||||
))
|
||||
return None
|
||||
count_graded = response['count_graded']
|
||||
count_required = response['count_required']
|
||||
if count_required>0 and count_graded>=count_required:
|
||||
self.student_data_for_location = response
|
||||
|
||||
score_dict = {
|
||||
'score': int(count_graded>=count_required),
|
||||
'total': self.max_grade,
|
||||
}
|
||||
|
||||
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_grade = None
|
||||
if self.use_for_single_location and self.is_graded:
|
||||
max_grade = self.max_grade
|
||||
return max_grade
|
||||
|
||||
def get_next_submission(self, get):
|
||||
"""
|
||||
Makes a call to the grading controller for the next essay that should be graded
|
||||
Returns a json dict with the following keys:
|
||||
|
||||
'success': bool
|
||||
|
||||
'submission_id': a unique identifier for the submission, to be passed back
|
||||
with the grade.
|
||||
|
||||
'submission': the submission, rendered as read-only html for grading
|
||||
|
||||
'rubric': the rubric, also rendered as html.
|
||||
|
||||
'submission_key': a key associated with the submission for validation reasons
|
||||
|
||||
'error': if success is False, will have an error message with more info.
|
||||
"""
|
||||
required = set(['location'])
|
||||
success, message = self._check_required(get, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
grader_id = self.system.anonymous_student_id
|
||||
location = get['location']
|
||||
|
||||
try:
|
||||
response = self.peer_gs.get_next_submission(location, grader_id)
|
||||
return response
|
||||
except GradingServiceError:
|
||||
log.exception("Error getting next submission. server url: {0} location: {1}, grader_id: {2}"
|
||||
.format(self.peer_gs.url, location, grader_id))
|
||||
return {'success': False,
|
||||
'error': 'Could not connect to grading service'}
|
||||
|
||||
def save_grade(self, get):
|
||||
"""
|
||||
Saves the grade of a given submission.
|
||||
Input:
|
||||
The request should have the following keys:
|
||||
location - problem location
|
||||
submission_id - id associated with this submission
|
||||
submission_key - submission key given for validation purposes
|
||||
score - the grade that was given to the submission
|
||||
feedback - the feedback from the student
|
||||
Returns
|
||||
A json object with the following keys:
|
||||
success: bool indicating whether the save was a success
|
||||
error: if there was an error in the submission, this is the error message
|
||||
"""
|
||||
|
||||
required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]', 'submission_flagged'])
|
||||
success, message = self._check_required(get, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
grader_id = self.system.anonymous_student_id
|
||||
|
||||
location = get.get('location')
|
||||
submission_id = get.get('submission_id')
|
||||
score = get.get('score')
|
||||
feedback = get.get('feedback')
|
||||
submission_key = get.get('submission_key')
|
||||
rubric_scores = get.getlist('rubric_scores[]')
|
||||
submission_flagged = get.get('submission_flagged')
|
||||
|
||||
try:
|
||||
response = self.peer_gs.save_grade(location, grader_id, submission_id,
|
||||
score, feedback, submission_key, rubric_scores, submission_flagged)
|
||||
return response
|
||||
except GradingServiceError:
|
||||
log.exception("""Error saving grade. server url: {0}, location: {1}, submission_id:{2},
|
||||
submission_key: {3}, score: {4}"""
|
||||
.format(self.peer_gs.url,
|
||||
location, submission_id, submission_key, score)
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Could not connect to grading service'
|
||||
}
|
||||
|
||||
def is_student_calibrated(self, get):
|
||||
"""
|
||||
Calls the grading controller to see if the given student is calibrated
|
||||
on the given problem
|
||||
|
||||
Input:
|
||||
In the request, we need the following arguments:
|
||||
location - problem location
|
||||
|
||||
Returns:
|
||||
Json object with the following keys
|
||||
success - bool indicating whether or not the call was successful
|
||||
calibrated - true if the grader has fully calibrated and can now move on to grading
|
||||
- false if the grader is still working on calibration problems
|
||||
total_calibrated_on_so_far - the number of calibration essays for this problem
|
||||
that this grader has graded
|
||||
"""
|
||||
|
||||
required = set(['location'])
|
||||
success, message = self._check_required(get, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
grader_id = self.system.anonymous_student_id
|
||||
|
||||
location = get['location']
|
||||
|
||||
try:
|
||||
response = self.peer_gs.is_student_calibrated(location, grader_id)
|
||||
return response
|
||||
except GradingServiceError:
|
||||
log.exception("Error from grading service. server url: {0}, grader_id: {0}, location: {1}"
|
||||
.format(self.peer_gs.url, grader_id, location))
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Could not connect to grading service'
|
||||
}
|
||||
|
||||
def show_calibration_essay(self, get):
|
||||
"""
|
||||
Fetch the next calibration essay from the grading controller and return it
|
||||
Inputs:
|
||||
In the request
|
||||
location - problem location
|
||||
|
||||
Returns:
|
||||
A json dict with the following keys
|
||||
'success': bool
|
||||
|
||||
'submission_id': a unique identifier for the submission, to be passed back
|
||||
with the grade.
|
||||
|
||||
'submission': the submission, rendered as read-only html for grading
|
||||
|
||||
'rubric': the rubric, also rendered as html.
|
||||
|
||||
'submission_key': a key associated with the submission for validation reasons
|
||||
|
||||
'error': if success is False, will have an error message with more info.
|
||||
|
||||
"""
|
||||
|
||||
required = set(['location'])
|
||||
success, message = self._check_required(get, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
|
||||
grader_id = self.system.anonymous_student_id
|
||||
|
||||
location = get['location']
|
||||
try:
|
||||
response = self.peer_gs.show_calibration_essay(location, grader_id)
|
||||
return response
|
||||
except GradingServiceError:
|
||||
log.exception("Error from grading service. server url: {0}, location: {0}"
|
||||
.format(self.peer_gs.url, location))
|
||||
return {'success': False,
|
||||
'error': 'Could not connect to grading service'}
|
||||
# if we can't parse the rubric into HTML,
|
||||
except etree.XMLSyntaxError:
|
||||
log.exception("Cannot parse rubric string. Raw string: {0}"
|
||||
.format(rubric))
|
||||
return {'success': False,
|
||||
'error': 'Error displaying submission'}
|
||||
|
||||
|
||||
def save_calibration_essay(self, get):
|
||||
"""
|
||||
Saves the grader's grade of a given calibration.
|
||||
Input:
|
||||
The request should have the following keys:
|
||||
location - problem location
|
||||
submission_id - id associated with this submission
|
||||
submission_key - submission key given for validation purposes
|
||||
score - the grade that was given to the submission
|
||||
feedback - the feedback from the student
|
||||
Returns
|
||||
A json object with the following keys:
|
||||
success: bool indicating whether the save was a success
|
||||
error: if there was an error in the submission, this is the error message
|
||||
actual_score: the score that the instructor gave to this calibration essay
|
||||
|
||||
"""
|
||||
|
||||
required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]'])
|
||||
success, message = self._check_required(get, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
grader_id = self.system.anonymous_student_id
|
||||
|
||||
location = get.get('location')
|
||||
calibration_essay_id = get.get('submission_id')
|
||||
submission_key = get.get('submission_key')
|
||||
score = get.get('score')
|
||||
feedback = get.get('feedback')
|
||||
rubric_scores = get.getlist('rubric_scores[]')
|
||||
|
||||
try:
|
||||
response = self.peer_gs.save_calibration_essay(location, grader_id, calibration_essay_id,
|
||||
submission_key, score, feedback, rubric_scores)
|
||||
return response
|
||||
except GradingServiceError:
|
||||
log.exception("Error saving calibration grade, location: {0}, submission_id: {1}, submission_key: {2}, grader_id: {3}".format(location, submission_id, submission_key, grader_id))
|
||||
return self._err_response('Could not connect to grading service')
|
||||
|
||||
def peer_grading(self, get = None):
|
||||
'''
|
||||
Show a peer grading interface
|
||||
'''
|
||||
|
||||
# call problem list service
|
||||
success = False
|
||||
error_text = ""
|
||||
problem_list = []
|
||||
try:
|
||||
problem_list_json = self.peer_gs.get_problem_list(self.system.course_id, self.system.anonymous_student_id)
|
||||
problem_list_dict = problem_list_json
|
||||
success = problem_list_dict['success']
|
||||
if 'error' in problem_list_dict:
|
||||
error_text = problem_list_dict['error']
|
||||
|
||||
problem_list = problem_list_dict['problem_list']
|
||||
|
||||
except GradingServiceError:
|
||||
error_text = "Error occured while contacting the grading service"
|
||||
success = False
|
||||
# catch error if if the json loads fails
|
||||
except ValueError:
|
||||
error_text = "Could not get problem list"
|
||||
success = False
|
||||
|
||||
ajax_url = self.ajax_url
|
||||
html = self.system.render_template('peer_grading/peer_grading.html', {
|
||||
'course_id': self.system.course_id,
|
||||
'ajax_url': ajax_url,
|
||||
'success': success,
|
||||
'problem_list': problem_list,
|
||||
'error_text': error_text,
|
||||
# Checked above
|
||||
'staff_access': False,
|
||||
'use_single_location' : self.use_for_single_location,
|
||||
})
|
||||
|
||||
return html
|
||||
|
||||
def peer_grading_problem(self, get = None):
|
||||
'''
|
||||
Show individual problem interface
|
||||
'''
|
||||
if get == None or get.get('location')==None:
|
||||
if not self.use_for_single_location:
|
||||
#This is an error case, because it must be set to use a single location to be called without get parameters
|
||||
return {'html' : "", 'success' : False}
|
||||
problem_location = self.link_to_location
|
||||
|
||||
elif get.get('location') is not None:
|
||||
problem_location = get.get('location')
|
||||
|
||||
ajax_url = self.ajax_url
|
||||
html = self.system.render_template('peer_grading/peer_grading_problem.html', {
|
||||
'view_html': '',
|
||||
'problem_location': problem_location,
|
||||
'course_id': self.system.course_id,
|
||||
'ajax_url': ajax_url,
|
||||
# Checked above
|
||||
'staff_access': False,
|
||||
'use_single_location' : self.use_for_single_location,
|
||||
})
|
||||
|
||||
return {'html' : html, 'success' : True}
|
||||
|
||||
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 = {
|
||||
'student_data_for_location' : self.student_data_for_location,
|
||||
}
|
||||
|
||||
return json.dumps(state)
|
||||
|
||||
class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
Module for adding combined open ended questions
|
||||
"""
|
||||
mako_template = "widgets/html-edit.html"
|
||||
module_class = PeerGradingModule
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = "peer_grading"
|
||||
|
||||
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,
|
||||
}
|
||||
"""
|
||||
log.debug("In definition")
|
||||
expected_children = []
|
||||
for child in expected_children:
|
||||
if len(xml_object.xpath(child)) == 0:
|
||||
raise ValueError("Peer grading 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 {}
|
||||
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
'''Return an xml element representing this definition.'''
|
||||
elt = etree.Element('peergrading')
|
||||
|
||||
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
|
||||
160
common/lib/xmodule/xmodule/peer_grading_service.py
Normal file
160
common/lib/xmodule/xmodule/peer_grading_service.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, ConnectionError, HTTPError
|
||||
import sys
|
||||
|
||||
#TODO: Settings import is needed now in order to specify the URL where to find the peer grading service.
|
||||
#Eventually, the goal is to replace the global django settings import with settings specifically
|
||||
#for this xmodule. There is no easy way to do this now, so piggybacking on the django settings
|
||||
#makes sense.
|
||||
from django.conf import settings
|
||||
|
||||
from combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError
|
||||
from lxml import etree
|
||||
from grading_service_module import GradingService, GradingServiceError
|
||||
|
||||
log=logging.getLogger(__name__)
|
||||
|
||||
class GradingServiceError(Exception):
|
||||
pass
|
||||
|
||||
class PeerGradingService(GradingService):
|
||||
"""
|
||||
Interface with the grading controller for peer grading
|
||||
"""
|
||||
def __init__(self, config, system):
|
||||
config['system'] = system
|
||||
super(PeerGradingService, self).__init__(config)
|
||||
self.get_next_submission_url = self.url + '/get_next_submission/'
|
||||
self.save_grade_url = self.url + '/save_grade/'
|
||||
self.is_student_calibrated_url = self.url + '/is_student_calibrated/'
|
||||
self.show_calibration_essay_url = self.url + '/show_calibration_essay/'
|
||||
self.save_calibration_essay_url = self.url + '/save_calibration_essay/'
|
||||
self.get_problem_list_url = self.url + '/get_problem_list/'
|
||||
self.get_notifications_url = self.url + '/get_notifications/'
|
||||
self.get_data_for_location_url = self.url + '/get_data_for_location/'
|
||||
self.system = system
|
||||
|
||||
def get_data_for_location(self, problem_location, student_id):
|
||||
response = self.get(self.get_data_for_location_url,
|
||||
{'location': problem_location, 'student_id': student_id})
|
||||
return self.try_to_decode(response)
|
||||
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
response = self.get(self.get_next_submission_url,
|
||||
{'location': problem_location, 'grader_id': grader_id})
|
||||
return self.try_to_decode(self._render_rubric(response))
|
||||
|
||||
def save_grade(self, location, grader_id, submission_id, score, feedback, submission_key, rubric_scores, submission_flagged):
|
||||
data = {'grader_id' : grader_id,
|
||||
'submission_id' : submission_id,
|
||||
'score' : score,
|
||||
'feedback' : feedback,
|
||||
'submission_key': submission_key,
|
||||
'location': location,
|
||||
'rubric_scores': rubric_scores,
|
||||
'rubric_scores_complete': True,
|
||||
'submission_flagged' : submission_flagged}
|
||||
return self.try_to_decode(self.post(self.save_grade_url, data))
|
||||
|
||||
def is_student_calibrated(self, problem_location, grader_id):
|
||||
params = {'problem_id' : problem_location, 'student_id': grader_id}
|
||||
return self.try_to_decode(self.get(self.is_student_calibrated_url, params))
|
||||
|
||||
def show_calibration_essay(self, problem_location, grader_id):
|
||||
params = {'problem_id' : problem_location, 'student_id': grader_id}
|
||||
response = self.get(self.show_calibration_essay_url, params)
|
||||
return self.try_to_decode(self._render_rubric(response))
|
||||
|
||||
def save_calibration_essay(self, problem_location, grader_id, calibration_essay_id, submission_key,
|
||||
score, feedback, rubric_scores):
|
||||
data = {'location': problem_location,
|
||||
'student_id': grader_id,
|
||||
'calibration_essay_id': calibration_essay_id,
|
||||
'submission_key': submission_key,
|
||||
'score': score,
|
||||
'feedback': feedback,
|
||||
'rubric_scores[]': rubric_scores,
|
||||
'rubric_scores_complete': True}
|
||||
return self.try_to_decode(self.post(self.save_calibration_essay_url, data))
|
||||
|
||||
def get_problem_list(self, course_id, grader_id):
|
||||
params = {'course_id': course_id, 'student_id': grader_id}
|
||||
response = self.get(self.get_problem_list_url, params)
|
||||
return self.try_to_decode(response)
|
||||
|
||||
def get_notifications(self, course_id, grader_id):
|
||||
params = {'course_id': course_id, 'student_id': grader_id}
|
||||
response = self.get(self.get_notifications_url, params)
|
||||
return self.try_to_decode(response)
|
||||
|
||||
def try_to_decode(self, text):
|
||||
try:
|
||||
text = json.loads(text)
|
||||
except:
|
||||
pass
|
||||
return text
|
||||
|
||||
"""
|
||||
This is a mock peer grading service that can be used for unit tests
|
||||
without making actual service calls to the grading controller
|
||||
"""
|
||||
class MockPeerGradingService(object):
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'submission_id':1,
|
||||
'submission_key': "",
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4})
|
||||
|
||||
def save_grade(self, location, grader_id, submission_id,
|
||||
score, feedback, submission_key):
|
||||
return json.dumps({'success': True})
|
||||
|
||||
def is_student_calibrated(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True, 'calibrated': True})
|
||||
|
||||
def show_calibration_essay(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'submission_id':1,
|
||||
'submission_key': '',
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4})
|
||||
|
||||
def save_calibration_essay(self, problem_location, grader_id,
|
||||
calibration_essay_id, submission_key, score, feedback):
|
||||
return {'success': True, 'actual_score': 2}
|
||||
|
||||
def get_problem_list(self, course_id, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'problem_list': [
|
||||
json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo1',
|
||||
'problem_name': "Problem 1", 'num_graded': 3, 'num_pending': 5}),
|
||||
json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo2',
|
||||
'problem_name': "Problem 2", 'num_graded': 1, 'num_pending': 5})
|
||||
]})
|
||||
|
||||
_service = None
|
||||
def peer_grading_service(system):
|
||||
"""
|
||||
Return a peer grading service instance--if settings.MOCK_PEER_GRADING is True,
|
||||
returns a mock one, otherwise a real one.
|
||||
|
||||
Caches the result, so changing the setting after the first call to this
|
||||
function will have no effect.
|
||||
"""
|
||||
global _service
|
||||
if _service is not None:
|
||||
return _service
|
||||
|
||||
if settings.MOCK_PEER_GRADING:
|
||||
_service = MockPeerGradingService()
|
||||
else:
|
||||
_service = PeerGradingService(settings.PEER_GRADING_INTERFACE, system)
|
||||
|
||||
return _service
|
||||
121
common/lib/xmodule/xmodule/randomize_module.py
Normal file
121
common/lib/xmodule/xmodule/randomize_module.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
|
||||
from xmodule.mako_module import MakoModuleDescriptor
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
class RandomizeModule(XModule):
|
||||
"""
|
||||
Chooses a random child module. Chooses the same one every time for each student.
|
||||
|
||||
Example:
|
||||
<randomize>
|
||||
<problem url_name="problem1" />
|
||||
<problem url_name="problem2" />
|
||||
<problem url_name="problem3" />
|
||||
</randomize>
|
||||
|
||||
User notes:
|
||||
|
||||
- If you're randomizing amongst graded modules, each of them MUST be worth the same
|
||||
number of points. Otherwise, the earth will be overrun by monsters from the
|
||||
deeps. You have been warned.
|
||||
|
||||
Technical notes:
|
||||
- There is more dark magic in this code than I'd like. The whole varying-children +
|
||||
grading interaction is a tangle between super and subclasses of descriptors and
|
||||
modules.
|
||||
"""
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
|
||||
# NOTE: calling self.get_children() creates a circular reference--
|
||||
# it calls get_child_descriptors() internally, but that doesn't work until
|
||||
# we've picked a choice
|
||||
num_choices = len(self.descriptor.get_children())
|
||||
|
||||
self.choice = None
|
||||
if instance_state is not None:
|
||||
state = json.loads(instance_state)
|
||||
self.choice = state.get('choice', None)
|
||||
if self.choice > num_choices:
|
||||
# Oops. Children changed. Reset.
|
||||
self.choice = None
|
||||
|
||||
if self.choice is None:
|
||||
# choose one based on the system seed, or randomly if that's not available
|
||||
if num_choices > 0:
|
||||
if system.seed is not None:
|
||||
self.choice = system.seed % num_choices
|
||||
else:
|
||||
self.choice = random.randrange(0, num_choices)
|
||||
|
||||
if self.choice is not None:
|
||||
self.child_descriptor = self.descriptor.get_children()[self.choice]
|
||||
# Now get_children() should return a list with one element
|
||||
log.debug("children of randomize module (should be only 1): %s",
|
||||
self.get_children())
|
||||
self.child = self.get_children()[0]
|
||||
else:
|
||||
self.child_descriptor = None
|
||||
self.child = None
|
||||
|
||||
|
||||
def get_instance_state(self):
|
||||
return json.dumps({'choice': self.choice})
|
||||
|
||||
|
||||
def get_child_descriptors(self):
|
||||
"""
|
||||
For grading--return just the chosen child.
|
||||
"""
|
||||
if self.child_descriptor is None:
|
||||
return []
|
||||
|
||||
return [self.child_descriptor]
|
||||
|
||||
|
||||
def get_html(self):
|
||||
if self.child is None:
|
||||
# raise error instead? In fact, could complain on descriptor load...
|
||||
return "<div>Nothing to randomize between</div>"
|
||||
|
||||
return self.child.get_html()
|
||||
|
||||
def get_icon_class(self):
|
||||
return self.child.get_icon_class() if self.child else 'other'
|
||||
|
||||
|
||||
class RandomizeDescriptor(SequenceDescriptor):
|
||||
# the editing interface can be the same as for sequences -- just a container
|
||||
module_class = RandomizeModule
|
||||
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
xml_object = etree.Element('randomize')
|
||||
for child in self.get_children():
|
||||
xml_object.append(
|
||||
etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
return xml_object
|
||||
|
||||
def has_dynamic_children(self):
|
||||
"""
|
||||
Grading needs to know that only one of the children is actually "real". This
|
||||
makes it use module.get_child_descriptors().
|
||||
"""
|
||||
return True
|
||||
|
||||
@@ -26,7 +26,7 @@ test_system = ModuleSystem(
|
||||
# "render" to just the context...
|
||||
render_template=lambda template, context: str(context),
|
||||
replace_urls=Mock(),
|
||||
user=Mock(),
|
||||
user=Mock(is_staff=False),
|
||||
filestore=Mock(),
|
||||
debug=True,
|
||||
xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue', 'waittime': 10},
|
||||
|
||||
215
common/lib/xmodule/xmodule/tests/test_capa_module.py
Normal file
215
common/lib/xmodule/xmodule/tests/test_capa_module.py
Normal file
@@ -0,0 +1,215 @@
|
||||
import datetime
|
||||
import json
|
||||
from mock import Mock
|
||||
from pprint import pprint
|
||||
import unittest
|
||||
|
||||
from xmodule.capa_module import CapaModule
|
||||
from xmodule.modulestore import Location
|
||||
from lxml import etree
|
||||
|
||||
from . import test_system
|
||||
|
||||
class CapaFactory(object):
|
||||
"""
|
||||
A helper class to create problem modules with various parameters for testing.
|
||||
"""
|
||||
|
||||
sample_problem_xml = """<?xml version="1.0"?>
|
||||
<problem>
|
||||
<text>
|
||||
<p>What is pi, to two decimal placs?</p>
|
||||
</text>
|
||||
<numericalresponse answer="3.14">
|
||||
<textline math="1" size="30"/>
|
||||
</numericalresponse>
|
||||
</problem>
|
||||
"""
|
||||
|
||||
num = 0
|
||||
@staticmethod
|
||||
def next_num():
|
||||
CapaFactory.num += 1
|
||||
return CapaFactory.num
|
||||
|
||||
@staticmethod
|
||||
def create(graceperiod=None,
|
||||
due=None,
|
||||
max_attempts=None,
|
||||
showanswer=None,
|
||||
rerandomize=None,
|
||||
force_save_button=None,
|
||||
attempts=None,
|
||||
problem_state=None,
|
||||
):
|
||||
"""
|
||||
All parameters are optional, and are added to the created problem if specified.
|
||||
|
||||
Arguments:
|
||||
graceperiod:
|
||||
due:
|
||||
max_attempts:
|
||||
showanswer:
|
||||
force_save_button:
|
||||
rerandomize: all strings, as specified in the policy for the problem
|
||||
|
||||
problem_state: a dict to to be serialized into the instance_state of the
|
||||
module.
|
||||
|
||||
attempts: also added to instance state. Will be converted to an int.
|
||||
"""
|
||||
definition = {'data': CapaFactory.sample_problem_xml,}
|
||||
location = Location(["i4x", "edX", "capa_test", "problem",
|
||||
"SampleProblem{0}".format(CapaFactory.next_num())])
|
||||
metadata = {}
|
||||
if graceperiod is not None:
|
||||
metadata['graceperiod'] = graceperiod
|
||||
if due is not None:
|
||||
metadata['due'] = due
|
||||
if max_attempts is not None:
|
||||
metadata['attempts'] = max_attempts
|
||||
if showanswer is not None:
|
||||
metadata['showanswer'] = showanswer
|
||||
if force_save_button is not None:
|
||||
metadata['force_save_button'] = force_save_button
|
||||
if rerandomize is not None:
|
||||
metadata['rerandomize'] = rerandomize
|
||||
|
||||
|
||||
descriptor = Mock(weight="1")
|
||||
instance_state_dict = {}
|
||||
if problem_state is not None:
|
||||
instance_state_dict = problem_state
|
||||
if attempts is not None:
|
||||
# converting to int here because I keep putting "0" and "1" in the tests
|
||||
# since everything else is a string.
|
||||
instance_state_dict['attempts'] = int(attempts)
|
||||
if len(instance_state_dict) > 0:
|
||||
instance_state = json.dumps(instance_state_dict)
|
||||
else:
|
||||
instance_state = None
|
||||
|
||||
module = CapaModule(test_system, location,
|
||||
definition, descriptor,
|
||||
instance_state, None, metadata=metadata)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
|
||||
class CapaModuleTest(unittest.TestCase):
|
||||
|
||||
|
||||
def setUp(self):
|
||||
now = datetime.datetime.now()
|
||||
day_delta = datetime.timedelta(days=1)
|
||||
self.yesterday_str = str(now - day_delta)
|
||||
self.today_str = str(now)
|
||||
self.tomorrow_str = str(now + day_delta)
|
||||
|
||||
# in the capa grace period format, not in time delta format
|
||||
self.two_day_delta_str = "2 days"
|
||||
|
||||
def test_import(self):
|
||||
module = CapaFactory.create()
|
||||
self.assertEqual(module.get_score()['score'], 0)
|
||||
|
||||
other_module = CapaFactory.create()
|
||||
self.assertEqual(module.get_score()['score'], 0)
|
||||
self.assertNotEqual(module.url_name, other_module.url_name,
|
||||
"Factory should be creating unique names for each problem")
|
||||
|
||||
def test_showanswer_default(self):
|
||||
"""
|
||||
Make sure the show answer logic does the right thing.
|
||||
"""
|
||||
# default, no due date, showanswer 'closed', so problem is open, and show_answer
|
||||
# not visible.
|
||||
problem = CapaFactory.create()
|
||||
self.assertFalse(problem.answer_available())
|
||||
|
||||
|
||||
def test_showanswer_attempted(self):
|
||||
problem = CapaFactory.create(showanswer='attempted')
|
||||
self.assertFalse(problem.answer_available())
|
||||
problem.attempts = 1
|
||||
self.assertTrue(problem.answer_available())
|
||||
|
||||
|
||||
def test_showanswer_closed(self):
|
||||
|
||||
# can see after attempts used up, even with due date in the future
|
||||
used_all_attempts = CapaFactory.create(showanswer='closed',
|
||||
max_attempts="1",
|
||||
attempts="1",
|
||||
due=self.tomorrow_str)
|
||||
self.assertTrue(used_all_attempts.answer_available())
|
||||
|
||||
|
||||
# can see after due date
|
||||
after_due_date = CapaFactory.create(showanswer='closed',
|
||||
max_attempts="1",
|
||||
attempts="0",
|
||||
due=self.yesterday_str)
|
||||
self.assertTrue(after_due_date.answer_available())
|
||||
|
||||
|
||||
# can't see because attempts left
|
||||
attempts_left_open = CapaFactory.create(showanswer='closed',
|
||||
max_attempts="1",
|
||||
attempts="0",
|
||||
due=self.tomorrow_str)
|
||||
self.assertFalse(attempts_left_open.answer_available())
|
||||
|
||||
# Can't see because grace period hasn't expired
|
||||
still_in_grace = CapaFactory.create(showanswer='closed',
|
||||
max_attempts="1",
|
||||
attempts="0",
|
||||
due=self.yesterday_str,
|
||||
graceperiod=self.two_day_delta_str)
|
||||
self.assertFalse(still_in_grace.answer_available())
|
||||
|
||||
|
||||
|
||||
def test_showanswer_past_due(self):
|
||||
"""
|
||||
With showanswer="past_due" should only show answer after the problem is closed
|
||||
for everyone--e.g. after due date + grace period.
|
||||
"""
|
||||
|
||||
# can see after attempts used up, even with due date in the future
|
||||
used_all_attempts = CapaFactory.create(showanswer='past_due',
|
||||
max_attempts="1",
|
||||
attempts="1",
|
||||
due=self.tomorrow_str)
|
||||
self.assertFalse(used_all_attempts.answer_available())
|
||||
|
||||
|
||||
# can see after due date
|
||||
past_due_date = CapaFactory.create(showanswer='past_due',
|
||||
max_attempts="1",
|
||||
attempts="0",
|
||||
due=self.yesterday_str)
|
||||
self.assertTrue(past_due_date.answer_available())
|
||||
|
||||
|
||||
# can't see because attempts left
|
||||
attempts_left_open = CapaFactory.create(showanswer='past_due',
|
||||
max_attempts="1",
|
||||
attempts="0",
|
||||
due=self.tomorrow_str)
|
||||
self.assertFalse(attempts_left_open.answer_available())
|
||||
|
||||
# Can't see because grace period hasn't expired, even though have no more
|
||||
# attempts.
|
||||
still_in_grace = CapaFactory.create(showanswer='past_due',
|
||||
max_attempts="1",
|
||||
attempts="1",
|
||||
due=self.yesterday_str,
|
||||
graceperiod=self.two_day_delta_str)
|
||||
self.assertFalse(still_in_grace.answer_available())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
119
common/lib/xmodule/xmodule/tests/test_conditional.py
Normal file
119
common/lib/xmodule/xmodule/tests/test_conditional.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
from path import path
|
||||
import unittest
|
||||
from fs.memoryfs import MemoryFS
|
||||
|
||||
from lxml import etree
|
||||
from mock import Mock, patch
|
||||
from collections import defaultdict
|
||||
|
||||
from xmodule.x_module import XMLParsingSystem, XModuleDescriptor
|
||||
from xmodule.xml_module import is_pointer_tag
|
||||
from xmodule.errortracker import make_error_tracker
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from .test_export import DATA_DIR
|
||||
|
||||
ORG = 'test_org'
|
||||
COURSE = 'conditional' # name of directory with course data
|
||||
|
||||
from . import test_system
|
||||
|
||||
class DummySystem(ImportSystem):
|
||||
|
||||
@patch('xmodule.modulestore.xml.OSFS', lambda dir: MemoryFS())
|
||||
def __init__(self, load_error_modules):
|
||||
|
||||
xmlstore = XMLModuleStore("data_dir", course_dirs=[], load_error_modules=load_error_modules)
|
||||
course_id = "/".join([ORG, COURSE, 'test_run'])
|
||||
course_dir = "test_dir"
|
||||
policy = {}
|
||||
error_tracker = Mock()
|
||||
parent_tracker = Mock()
|
||||
|
||||
super(DummySystem, self).__init__(
|
||||
xmlstore,
|
||||
course_id,
|
||||
course_dir,
|
||||
policy,
|
||||
error_tracker,
|
||||
parent_tracker,
|
||||
load_error_modules=load_error_modules,
|
||||
)
|
||||
|
||||
def render_template(self, template, context):
|
||||
raise Exception("Shouldn't be called")
|
||||
|
||||
|
||||
|
||||
class ConditionalModuleTest(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def get_system(load_error_modules=True):
|
||||
'''Get a dummy system'''
|
||||
return DummySystem(load_error_modules)
|
||||
|
||||
def get_course(self, name):
|
||||
"""Get a test course by directory name. If there's more than one, error."""
|
||||
print "Importing {0}".format(name)
|
||||
|
||||
modulestore = XMLModuleStore(DATA_DIR, course_dirs=[name])
|
||||
courses = modulestore.get_courses()
|
||||
self.modulestore = modulestore
|
||||
self.assertEquals(len(courses), 1)
|
||||
return courses[0]
|
||||
|
||||
def test_conditional_module(self):
|
||||
"""Make sure that conditional module works"""
|
||||
|
||||
print "Starting import"
|
||||
course = self.get_course('conditional')
|
||||
|
||||
print "Course: ", course
|
||||
print "id: ", course.id
|
||||
|
||||
instance_states = dict(problem=None)
|
||||
shared_state = None
|
||||
|
||||
def inner_get_module(descriptor):
|
||||
if isinstance(descriptor, Location):
|
||||
location = descriptor
|
||||
descriptor = self.modulestore.get_instance(course.id, location, depth=None)
|
||||
location = descriptor.location
|
||||
instance_state = instance_states.get(location.category,None)
|
||||
print "inner_get_module, location.category=%s, inst_state=%s" % (location.category, instance_state)
|
||||
return descriptor.xmodule_constructor(test_system)(instance_state, shared_state)
|
||||
|
||||
location = Location(["i4x", "edX", "cond_test", "conditional","condone"])
|
||||
module = inner_get_module(location)
|
||||
|
||||
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None):
|
||||
return text
|
||||
test_system.replace_urls = replace_urls
|
||||
test_system.get_module = inner_get_module
|
||||
|
||||
print "module: ", module
|
||||
|
||||
html = module.get_html()
|
||||
print "html type: ", type(html)
|
||||
print "html: ", html
|
||||
html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}"
|
||||
self.assertEqual(html, html_expect)
|
||||
|
||||
gdi = module.get_display_items()
|
||||
print "gdi=", gdi
|
||||
|
||||
ajax = json.loads(module.handle_ajax('',''))
|
||||
self.assertTrue('xmodule.conditional_module' in ajax['html'])
|
||||
print "ajax: ", ajax
|
||||
|
||||
# now change state of the capa problem to make it completed
|
||||
instance_states['problem'] = json.dumps({'attempts':1})
|
||||
|
||||
ajax = json.loads(module.handle_ajax('',''))
|
||||
self.assertTrue('This is a secret' in ajax['html'])
|
||||
print "post-attempt ajax: ", ajax
|
||||
|
||||
|
||||
55
common/lib/xmodule/xmodule/tests/test_randomize_module.py
Normal file
55
common/lib/xmodule/xmodule/tests/test_randomize_module.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import unittest
|
||||
from time import strptime
|
||||
from fs.memoryfs import MemoryFS
|
||||
|
||||
from mock import Mock, patch
|
||||
|
||||
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
|
||||
|
||||
|
||||
ORG = 'test_org'
|
||||
COURSE = 'test_course'
|
||||
|
||||
START = '2013-01-01T01:00:00'
|
||||
|
||||
|
||||
from test_course_module import DummySystem as DummyImportSystem
|
||||
from . import test_system
|
||||
|
||||
|
||||
class RandomizeModuleTestCase(unittest.TestCase):
|
||||
"""Make sure the randomize module works"""
|
||||
@staticmethod
|
||||
def get_dummy_course(start):
|
||||
"""Get a dummy course"""
|
||||
|
||||
system = DummyImportSystem(load_error_modules=True)
|
||||
|
||||
def to_attrb(n, v):
|
||||
return '' if v is None else '{0}="{1}"'.format(n, v).lower()
|
||||
|
||||
start_xml = '''
|
||||
<course org="{org}" course="{course}"
|
||||
graceperiod="1 day" url_name="test"
|
||||
start="{start}"
|
||||
>
|
||||
<chapter url="hi" url_name="ch" display_name="CH">
|
||||
<randomize url_name="my_randomize">
|
||||
<html url_name="a" display_name="A">Two houses, ...</html>
|
||||
<html url_name="b" display_name="B">Three houses, ...</html>
|
||||
</randomize>
|
||||
</chapter>
|
||||
</course>
|
||||
'''.format(org=ORG, course=COURSE, start=start)
|
||||
|
||||
return system.process_xml(start_xml)
|
||||
|
||||
def test_import(self):
|
||||
"""
|
||||
Just make sure descriptor loads without error
|
||||
"""
|
||||
descriptor = self.get_dummy_course(START)
|
||||
|
||||
# TODO: add tests that create a module and check. Passing state is a good way to
|
||||
# check that child access works...
|
||||
|
||||
@@ -48,3 +48,5 @@ class VerticalDescriptor(SequenceDescriptor):
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}
|
||||
js_module_name = "VerticalDescriptor"
|
||||
|
||||
# TODO (victor): Does this need its own definition_to_xml method? Otherwise it looks
|
||||
# like verticals will get exported as sequentials...
|
||||
|
||||
@@ -6,7 +6,7 @@ from pkg_resources import resource_string, resource_listdir
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.modulestore.mongo import MongoModuleStore
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
@@ -121,12 +121,12 @@ class VideoModule(XModule):
|
||||
return self.youtube
|
||||
|
||||
def get_html(self):
|
||||
if isinstance(modulestore(), MongoModuleStore) :
|
||||
caption_asset_path = StaticContent.get_base_url_path_for_course_assets(self.location) + '/subs_'
|
||||
else:
|
||||
if isinstance(modulestore(), XMLModuleStore) :
|
||||
# VS[compat]
|
||||
# cdodge: filesystem static content support.
|
||||
caption_asset_path = "/static/{0}/subs/".format(self.metadata['data_dir'])
|
||||
else:
|
||||
caption_asset_path = StaticContent.get_base_url_path_for_course_assets(self.location) + '/subs_'
|
||||
|
||||
return self.system.render_template('video.html', {
|
||||
'streams': self.video_list(),
|
||||
|
||||
@@ -585,6 +585,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
|
||||
self._inherited_metadata.add(attr)
|
||||
self.metadata[attr] = metadata[attr]
|
||||
|
||||
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 []
|
||||
|
||||
def get_children(self):
|
||||
"""Returns a list of XModuleDescriptor instances for the children of
|
||||
this module"""
|
||||
|
||||
3
common/test/data/conditional/README.md
Normal file
3
common/test/data/conditional/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
course for testing conditional module
|
||||
|
||||
|
||||
3
common/test/data/conditional/conditional/condone.xml
Normal file
3
common/test/data/conditional/conditional/condone.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<conditional condition="require_attempted" required="problem/choiceprob">
|
||||
<html url_name="secret_page" />
|
||||
</conditional>
|
||||
8
common/test/data/conditional/course.xml
Normal file
8
common/test/data/conditional/course.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<course name="Conditional Course" org="edX" course="cond_test" graceperiod="1 day 5 hours 59 minutes 59 seconds" slug="2012_Fall" start="2015-07-17T12:00">
|
||||
<chapter name="Problems with Condition">
|
||||
<sequential>
|
||||
<problem url_name="choiceprob" />
|
||||
<conditional url_name="condone"/>
|
||||
</sequential>
|
||||
</chapter>
|
||||
</course>
|
||||
4
common/test/data/conditional/html/secret_page.xml
Normal file
4
common/test/data/conditional/html/secret_page.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<html display_name="Secret Page">
|
||||
<p>This is a secret!</p>
|
||||
</html>
|
||||
|
||||
22
common/test/data/conditional/problem/choiceprob.xml
Normal file
22
common/test/data/conditional/problem/choiceprob.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<problem display_name="S3E2: Lorentz Force">
|
||||
|
||||
<startouttext/>
|
||||
<p>Consider a hypothetical magnetic field pointing out of your computer screen. Now imagine an electron traveling from right to left in the plane of your screen. A diagram of this situation is show below…</p>
|
||||
<center><img width="400" src="/static/images/LSQimages/LSQ_W01_8.png"/></center>
|
||||
|
||||
<p>a. The magnitude of the force experienced by the electron is proportional the product of which of the following? (Select all that apply.)</p>
|
||||
<endouttext/>
|
||||
<choiceresponse>
|
||||
<checkboxgroup>
|
||||
<!-- include ellipses to test non-ascii characters -->
|
||||
<choice correct="true"><text>Magnetic field strength…</text></choice>
|
||||
<choice correct="false"><text>Electric field strength…</text></choice>
|
||||
<choice correct="true"><text>Electric charge of the electron…</text></choice>
|
||||
<choice correct="false"><text>Radius of the electron…</text></choice>
|
||||
<choice correct="false"><text>Mass of the electron…</text></choice>
|
||||
<choice correct="true"><text>Velocity of the electron…</text></choice>
|
||||
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
|
||||
</problem>
|
||||
1
common/test/data/toy/peergrading/init.xml
Normal file
1
common/test/data/toy/peergrading/init.xml
Normal file
@@ -0,0 +1 @@
|
||||
<peergrading display_name = "Peer Grading" use_for_single_location="False" is_graded="False"/>
|
||||
@@ -141,6 +141,7 @@ That's basically all there is to the organizational structure. Read the next se
|
||||
|
||||
* `abtest` -- Support for A/B testing. TODO: add details..
|
||||
* `chapter` -- top level organization unit of a course. The courseware display code currently expects the top level `course` element to contain only chapters, though there is no philosophical reason why this is required, so we may change it to properly display non-chapters at the top level.
|
||||
* `conditional` -- conditional element, which shows one or more modules only if certain conditions are satisfied.
|
||||
* `course` -- top level tag. Contains everything else.
|
||||
* `customtag` -- render an html template, filling in some parameters, and return the resulting html. See below for details.
|
||||
* `discussion` -- Inline discussion forum
|
||||
@@ -163,6 +164,22 @@ Container tags include `chapter`, `sequential`, `videosequence`, `vertical`, and
|
||||
|
||||
`course` is also a container, and is similar, with one extra wrinkle: the top level pointer tag _must_ have `org` and `course` attributes specified--the organization name, and course name. Note that `course` is referring to the platonic ideal of this course (e.g. "6.002x"), not to any particular run of this course. The `url_name` should be the particular run of this course.
|
||||
|
||||
### `conditional`
|
||||
|
||||
`conditional` is as special kind of container tag as well. Here are two examples:
|
||||
|
||||
<conditional condition="require_completed" required="problem/choiceprob">
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
|
||||
<conditional condition="require_attempted" required="problem/choiceprob&problem/sumprob">
|
||||
<html url_name="secret_page" />
|
||||
</conditional>
|
||||
|
||||
The condition can be either `require_completed`, in which case the required modules must be completed, or `require_attempted`, in which case the required modules must have been attempted.
|
||||
|
||||
The required modules are specified as a set of `tag`/`url_name`, joined by an ampersand.
|
||||
|
||||
### `customtag`
|
||||
|
||||
When we see `<customtag impl="special" animal="unicorn" hat="blue"/>`, we will:
|
||||
@@ -251,6 +268,7 @@ Supported fields at the course level:
|
||||
|
||||
* "start" -- specify the start date for the course. Format-by-example: "2012-09-05T12:00".
|
||||
* "advertised_start" -- specify what you want displayed as the start date of the course in the course listing and course about pages. This can be useful if you want to let people in early before the formal start. Format-by-example: "2012-09-05T12:00".
|
||||
* "disable_policy_graph" -- set to true (or "Yes"), if the policy graph should be disabled (ie not shown).
|
||||
* "enrollment_start", "enrollment_end" -- when can students enroll? (if not specified, can enroll anytime). Same format as "start".
|
||||
* "end" -- specify the end date for the course. Format-by-example: "2012-11-05T12:00".
|
||||
* "end_of_course_survey_url" -- a url for an end of course survey -- shown after course is over, next to certificate download links.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# .coveragerc for lms
|
||||
[run]
|
||||
data_file = reports/lms/.coverage
|
||||
source = lms
|
||||
source = lms,common/djangoapps
|
||||
omit = lms/envs/*
|
||||
|
||||
[report]
|
||||
|
||||
@@ -19,7 +19,7 @@ from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.x_module import XModule
|
||||
from static_replace import replace_urls, try_staticfiles_lookup
|
||||
from static_replace import replace_static_urls
|
||||
from courseware.access import has_access
|
||||
import branding
|
||||
from courseware.models import StudentModuleCache
|
||||
@@ -83,13 +83,12 @@ def get_opt_course_with_access(user, course_id, action):
|
||||
return None
|
||||
return get_course_with_access(user, course_id, action)
|
||||
|
||||
|
||||
|
||||
def course_image_url(course):
|
||||
"""Try to look up the image url for the course. If it's not found,
|
||||
log an error and return the dead link"""
|
||||
if isinstance(modulestore(), XMLModuleStore):
|
||||
path = course.metadata['data_dir'] + "/images/course_image.jpg"
|
||||
return try_staticfiles_lookup(path)
|
||||
return '/static/' + course.metadata['data_dir'] + "/images/course_image.jpg"
|
||||
else:
|
||||
loc = course.location._replace(tag='c4x', category='asset', name='images_course_image.jpg')
|
||||
path = StaticContent.get_url_path_from_location(loc)
|
||||
@@ -224,8 +223,11 @@ def get_course_syllabus_section(course, section_key):
|
||||
dirs = [path("syllabus") / course.url_name, path("syllabus")]
|
||||
filepath = find_file(fs, dirs, section_key + ".html")
|
||||
with fs.open(filepath) as htmlFile:
|
||||
return replace_urls(htmlFile.read().decode('utf-8'),
|
||||
course.metadata['data_dir'], course_namespace=course.location)
|
||||
return replace_static_urls(
|
||||
htmlFile.read().decode('utf-8'),
|
||||
course.metadata['data_dir'],
|
||||
course_namespace=course.location
|
||||
)
|
||||
except ResourceNotFoundError:
|
||||
log.exception("Missing syllabus section {key} in course {url}".format(
|
||||
key=section_key, url=course.location.url()))
|
||||
|
||||
@@ -113,6 +113,9 @@ class StudentModuleCache(object):
|
||||
descriptor_filter=lambda descriptor: True,
|
||||
select_for_update=False):
|
||||
"""
|
||||
obtain and return cache for descriptor descendents (ie children) AND modules required by the descriptor,
|
||||
but which are not children of the module
|
||||
|
||||
course_id: the course in the context of which we want StudentModules.
|
||||
user: the django user for whom to load modules.
|
||||
descriptor: An XModuleDescriptor
|
||||
@@ -132,7 +135,7 @@ class StudentModuleCache(object):
|
||||
if depth is None or depth > 0:
|
||||
new_depth = depth - 1 if depth is not None else depth
|
||||
|
||||
for child in descriptor.get_children():
|
||||
for child in descriptor.get_children() + descriptor.get_required_module_descriptors():
|
||||
descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter))
|
||||
|
||||
return descriptors
|
||||
|
||||
@@ -2,6 +2,9 @@ import json
|
||||
import logging
|
||||
import pyparsing
|
||||
import sys
|
||||
import static_replace
|
||||
|
||||
from functools import partial
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
@@ -18,7 +21,6 @@ from courseware.access import has_access
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
from models import StudentModule, StudentModuleCache
|
||||
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
|
||||
from static_replace import replace_urls
|
||||
from student.models import unique_id_for_user
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
from xmodule.exceptions import NotFoundError
|
||||
@@ -244,7 +246,11 @@ def _get_module(user, request, descriptor, student_module_cache, course_id,
|
||||
# TODO (cpennington): This should be removed when all html from
|
||||
# a module is coming through get_html and is therefore covered
|
||||
# by the replace_static_urls code below
|
||||
replace_urls=replace_urls,
|
||||
replace_urls=partial(
|
||||
static_replace.replace_static_urls,
|
||||
data_directory=descriptor.metadata.get('data_dir', ''),
|
||||
course_namespace=descriptor.location._replace(category=None, name=None),
|
||||
),
|
||||
node_path=settings.NODE_PATH,
|
||||
anonymous_student_id=unique_id_for_user(user),
|
||||
course_id=course_id,
|
||||
@@ -280,7 +286,7 @@ def _get_module(user, request, descriptor, student_module_cache, course_id,
|
||||
|
||||
module.get_html = replace_static_urls(
|
||||
_get_html,
|
||||
module.metadata['data_dir'] if 'data_dir' in module.metadata else '',
|
||||
module.metadata.get('data_dir', ''),
|
||||
course_namespace = module.location._replace(category=None, name=None))
|
||||
|
||||
# Allow URLs of the form '/course/' refer to the root of multicourse directory
|
||||
|
||||
@@ -19,12 +19,10 @@ from django.core.urlresolvers import reverse
|
||||
from fs.errors import ResourceNotFoundError
|
||||
|
||||
from courseware.access import has_access
|
||||
from static_replace import replace_urls
|
||||
|
||||
from lxml.html import rewrite_links
|
||||
from module_render import get_module
|
||||
from courseware.access import has_access
|
||||
from static_replace import replace_urls
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
@@ -322,4 +320,4 @@ def get_static_tab_contents(request, cache, course, tab):
|
||||
if tab_module is not None:
|
||||
html = tab_module.get_html()
|
||||
|
||||
return html
|
||||
return html
|
||||
|
||||
@@ -12,6 +12,9 @@ import pystache_custom as pystache
|
||||
import urllib
|
||||
import os
|
||||
|
||||
# This method is used to pluralize the words "discussion" and "comment"
|
||||
# when referring to how many discussion threads or comments the user
|
||||
# has contributed to.
|
||||
def pluralize(singular_term, count):
|
||||
if int(count) >= 2 or int(count) == 0:
|
||||
return singular_term + 's'
|
||||
|
||||
@@ -5,6 +5,8 @@ import urllib
|
||||
import sys
|
||||
import inspect
|
||||
|
||||
# This method is used to pluralize the words "discussion" and "comment"
|
||||
# which is why you need to tack on an "s" for the case of 0 or two or more.
|
||||
def pluralize(content, text):
|
||||
num, word = text.split(' ')
|
||||
num = int(num or '0')
|
||||
|
||||
15
lms/djangoapps/django_comment_client/tests/test_helpers.py
Normal file
15
lms/djangoapps/django_comment_client/tests/test_helpers.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import string
|
||||
import random
|
||||
import collections
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from django_comment_client.helpers import pluralize
|
||||
|
||||
class PluralizeTestCase(TestCase):
|
||||
|
||||
def testPluralize(self):
|
||||
self.term = "cat"
|
||||
self.assertEqual(pluralize(self.term, 0), "cats")
|
||||
self.assertEqual(pluralize(self.term, 1), "cat")
|
||||
self.assertEqual(pluralize(self.term, 2), "cats")
|
||||
@@ -0,0 +1,28 @@
|
||||
import string
|
||||
import random
|
||||
import collections
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
import comment_client
|
||||
import django.http
|
||||
import django_comment_client.middleware as middleware
|
||||
|
||||
class AjaxExceptionTestCase(TestCase):
|
||||
|
||||
# TODO: check whether the correct error message is produced.
|
||||
# The error message should be the same as the argument to CommentClientError
|
||||
def setUp(self):
|
||||
self.a = middleware.AjaxExceptionMiddleware()
|
||||
self.request1 = django.http.HttpRequest()
|
||||
self.request0 = django.http.HttpRequest()
|
||||
self.exception1 = comment_client.CommentClientError('{}')
|
||||
self.exception0 = ValueError()
|
||||
self.request1.META['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest"
|
||||
self.request0.META['HTTP_X_REQUESTED_WITH'] = "SHADOWFAX"
|
||||
|
||||
def test_process_exception(self):
|
||||
self.assertIsInstance(self.a.process_exception(self.request1, self.exception1), middleware.JsonError)
|
||||
self.assertIsNone(self.a.process_exception(self.request1, self.exception0))
|
||||
self.assertIsNone(self.a.process_exception(self.request0, self.exception1))
|
||||
self.assertIsNone(self.a.process_exception(self.request0, self.exception0))
|
||||
@@ -0,0 +1,26 @@
|
||||
import string
|
||||
import random
|
||||
import collections
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
import django_comment_client.mustache_helpers as mustache_helpers
|
||||
|
||||
class PluralizeTestCase(TestCase):
|
||||
|
||||
def test_pluralize(self):
|
||||
self.text1 = '0 goat'
|
||||
self.text2 = '1 goat'
|
||||
self.text3 = '7 goat'
|
||||
self.content = 'unused argument'
|
||||
self.assertEqual(mustache_helpers.pluralize(self.content, self.text1), 'goats')
|
||||
self.assertEqual(mustache_helpers.pluralize(self.content, self.text2), 'goat')
|
||||
self.assertEqual(mustache_helpers.pluralize(self.content, self.text3), 'goats')
|
||||
|
||||
class CloseThreadTextTestCase(TestCase):
|
||||
|
||||
def test_close_thread_text(self):
|
||||
self.contentClosed = {'closed': True}
|
||||
self.contentOpen = {'closed': False}
|
||||
self.assertEqual(mustache_helpers.close_thread_text(self.contentClosed), 'Re-open thread')
|
||||
self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread')
|
||||
89
lms/djangoapps/django_comment_client/tests/test_utils.py
Normal file
89
lms/djangoapps/django_comment_client/tests/test_utils.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import string
|
||||
import random
|
||||
import collections
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
import factory
|
||||
from django.contrib.auth.models import User
|
||||
from student.models import UserProfile, CourseEnrollment
|
||||
from django_comment_client.models import Role, Permission
|
||||
|
||||
import django_comment_client.models as models
|
||||
import django_comment_client.utils as utils
|
||||
|
||||
import xmodule.modulestore.django as django
|
||||
|
||||
class UserFactory(factory.Factory):
|
||||
FACTORY_FOR = User
|
||||
username = 'robot'
|
||||
password = '123456'
|
||||
email = 'robot@edx.org'
|
||||
is_active = True
|
||||
is_staff = False
|
||||
|
||||
class CourseEnrollmentFactory(factory.Factory):
|
||||
FACTORY_FOR = CourseEnrollment
|
||||
user = factory.SubFactory(UserFactory)
|
||||
course_id = 'edX/toy/2012_Fall'
|
||||
|
||||
class RoleFactory(factory.Factory):
|
||||
FACTORY_FOR = Role
|
||||
name = 'Student'
|
||||
course_id = 'edX/toy/2012_Fall'
|
||||
|
||||
class PermissionFactory(factory.Factory):
|
||||
FACTORY_FOR = Permission
|
||||
name = 'create_comment'
|
||||
|
||||
class DictionaryTestCase(TestCase):
|
||||
def test_extract(self):
|
||||
d = {'cats': 'meow', 'dogs': 'woof'}
|
||||
k = ['cats', 'dogs', 'hamsters']
|
||||
expected = {'cats': 'meow', 'dogs': 'woof', 'hamsters': None}
|
||||
self.assertEqual(utils.extract(d, k), expected)
|
||||
|
||||
def test_strip_none(self):
|
||||
d = {'cats': 'meow', 'dogs': 'woof', 'hamsters': None}
|
||||
expected = {'cats': 'meow', 'dogs': 'woof'}
|
||||
self.assertEqual(utils.strip_none(d), expected)
|
||||
|
||||
def test_strip_blank(self):
|
||||
d = {'cats': 'meow', 'dogs': 'woof', 'hamsters': ' ', 'yetis': ''}
|
||||
expected = {'cats': 'meow', 'dogs': 'woof'}
|
||||
self.assertEqual(utils.strip_blank(d), expected)
|
||||
|
||||
def test_merge_dict(self):
|
||||
d1 ={'cats': 'meow', 'dogs': 'woof'}
|
||||
d2 ={'lions': 'roar','ducks': 'quack'}
|
||||
expected ={'cats': 'meow', 'dogs': 'woof','lions': 'roar','ducks': 'quack'}
|
||||
self.assertEqual(utils.merge_dict(d1, d2), expected)
|
||||
|
||||
class AccessUtilsTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.course_id = 'edX/toy/2012_Fall'
|
||||
self.student_role = RoleFactory(name='Student', course_id=self.course_id)
|
||||
self.moderator_role = RoleFactory(name='Moderator', course_id=self.course_id)
|
||||
self.student1 = UserFactory(username='student', email='student@edx.org')
|
||||
self.student1_enrollment = CourseEnrollmentFactory(user=self.student1)
|
||||
self.student_role.users.add(self.student1)
|
||||
self.student2 = UserFactory(username='student2', email='student2@edx.org')
|
||||
self.student2_enrollment = CourseEnrollmentFactory(user=self.student2)
|
||||
self.moderator = UserFactory(username='moderator', email='staff@edx.org', is_staff=True)
|
||||
self.moderator_enrollment = CourseEnrollmentFactory(user=self.moderator)
|
||||
self.moderator_role.users.add(self.moderator)
|
||||
|
||||
def test_get_role_ids(self):
|
||||
ret = utils.get_role_ids(self.course_id)
|
||||
expected = {u'Moderator': [3], u'Student': [1, 2], 'Staff': [3]}
|
||||
self.assertEqual(ret, expected)
|
||||
|
||||
def test_has_forum_access(self):
|
||||
ret = utils.has_forum_access('student', self.course_id, 'Student')
|
||||
self.assertTrue(ret)
|
||||
|
||||
ret = utils.has_forum_access('not_a_student', self.course_id, 'Student')
|
||||
self.assertFalse(ret)
|
||||
|
||||
ret = utils.has_forum_access('student', self.course_id, 'NotARole')
|
||||
self.assertFalse(ret)
|
||||
@@ -35,6 +35,7 @@ def strip_blank(dic):
|
||||
return isinstance(v, str) and len(v.strip()) == 0
|
||||
return dict([(k, v) for k, v in dic.iteritems() if not _is_blank(v)])
|
||||
|
||||
# TODO should we be checking if d1 and d2 have the same keys with different values?
|
||||
def merge_dict(dic1, dic2):
|
||||
return dict(dic1.items() + dic2.items())
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, ConnectionError, HTTPError
|
||||
import sys
|
||||
from grading_service import GradingService
|
||||
from grading_service import GradingServiceError
|
||||
from xmodule.grading_service_module import GradingService, GradingServiceError
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, Http404
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,11 +17,14 @@ class ControllerQueryService(GradingService):
|
||||
Interface to staff grading backend.
|
||||
"""
|
||||
def __init__(self, config):
|
||||
config['system'] = ModuleSystem(None,None,None,render_to_string,None)
|
||||
super(ControllerQueryService, self).__init__(config)
|
||||
self.check_eta_url = self.url + '/get_submission_eta/'
|
||||
self.is_unique_url = self.url + '/is_name_unique/'
|
||||
self.combined_notifications_url = self.url + '/combined_notifications/'
|
||||
self.grading_status_list_url = self.url + '/get_grading_status_list/'
|
||||
self.flagged_problem_list_url = self.url + '/get_flagged_problem_list/'
|
||||
self.take_action_on_flags_url = self.url + '/take_action_on_flags/'
|
||||
|
||||
def check_if_name_is_unique(self, location, problem_id, course_id):
|
||||
params = {
|
||||
@@ -57,3 +61,23 @@ class ControllerQueryService(GradingService):
|
||||
|
||||
response = self.get(self.grading_status_list_url, params)
|
||||
return response
|
||||
|
||||
def get_flagged_problem_list(self, course_id):
|
||||
params = {
|
||||
'course_id' : course_id,
|
||||
}
|
||||
|
||||
response = self.get(self.flagged_problem_list_url, params)
|
||||
return response
|
||||
|
||||
def take_action_on_flags(self, course_id, student_id, submission_id, action_type):
|
||||
params = {
|
||||
'course_id' : course_id,
|
||||
'student_id' : student_id,
|
||||
'submission_id' : submission_id,
|
||||
'action_type' : action_type
|
||||
}
|
||||
|
||||
response = self.post(self.take_action_on_flags_url, params)
|
||||
return response
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.conf import settings
|
||||
from staff_grading_service import StaffGradingService
|
||||
from peer_grading_service import PeerGradingService
|
||||
from open_ended_grading.controller_query_service import ControllerQueryService
|
||||
from xmodule import peer_grading_service
|
||||
import json
|
||||
from student.models import unique_id_for_user
|
||||
import open_ended_util
|
||||
@@ -10,6 +10,9 @@ import logging
|
||||
from courseware.access import has_access
|
||||
from util.cache import cache
|
||||
import datetime
|
||||
from xmodule import peer_grading_service
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
|
||||
log=logging.getLogger(__name__)
|
||||
|
||||
@@ -19,7 +22,8 @@ KEY_PREFIX = "open_ended_"
|
||||
NOTIFICATION_TYPES = (
|
||||
('student_needs_to_peer_grade', 'peer_grading', 'Peer Grading'),
|
||||
('staff_needs_to_grade', 'staff_grading', 'Staff Grading'),
|
||||
('new_student_grading_to_view', 'open_ended_problems', 'Problems you have submitted')
|
||||
('new_student_grading_to_view', 'open_ended_problems', 'Problems you have submitted'),
|
||||
('flagged_submissions_exist', 'open_ended_flagged_problems', 'Flagged Submissions')
|
||||
)
|
||||
|
||||
def staff_grading_notifications(course, user):
|
||||
@@ -54,7 +58,8 @@ def staff_grading_notifications(course, user):
|
||||
return notification_dict
|
||||
|
||||
def peer_grading_notifications(course, user):
|
||||
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE)
|
||||
system = ModuleSystem(None,None,None,render_to_string,None)
|
||||
peer_gs = peer_grading_service.PeerGradingService(settings.PEER_GRADING_INTERFACE, system)
|
||||
pending_grading=False
|
||||
img_path= ""
|
||||
course_id = course.id
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
"""
|
||||
This module provides an interface on the grading-service backend
|
||||
for peer grading
|
||||
|
||||
Use peer_grading_service() to get the version specified
|
||||
in settings.PEER_GRADING_INTERFACE
|
||||
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, ConnectionError, HTTPError
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, Http404
|
||||
from grading_service import GradingService
|
||||
from grading_service import GradingServiceError
|
||||
|
||||
from courseware.access import has_access
|
||||
from util.json_request import expect_json
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.combined_open_ended_rubric import CombinedOpenEndedRubric
|
||||
from student.models import unique_id_for_user
|
||||
from lxml import etree
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
This is a mock peer grading service that can be used for unit tests
|
||||
without making actual service calls to the grading controller
|
||||
"""
|
||||
class MockPeerGradingService(object):
|
||||
# TODO: get this rubric parsed and working
|
||||
rubric = """<rubric>
|
||||
<category>
|
||||
<description>Description</description>
|
||||
<option>First option</option>
|
||||
<option>Second option</option>
|
||||
</category>
|
||||
</rubric>"""
|
||||
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'submission_id':1,
|
||||
'submission_key': "",
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4})
|
||||
|
||||
def save_grade(self, location, grader_id, submission_id,
|
||||
score, feedback, submission_key, rubric_scores):
|
||||
return json.dumps({'success': True})
|
||||
|
||||
def is_student_calibrated(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True, 'calibrated': True})
|
||||
|
||||
def show_calibration_essay(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'submission_id':1,
|
||||
'submission_key': '',
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4})
|
||||
|
||||
def save_calibration_essay(self, problem_location, grader_id,
|
||||
calibration_essay_id, submission_key, score, feedback, rubric_scores):
|
||||
return json.dumps({'success': True, 'actual_score': 2})
|
||||
|
||||
def get_problem_list(self, course_id, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'problem_list': [
|
||||
json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo1',
|
||||
'problem_name': "Problem 1", 'num_graded': 3, 'num_pending': 5, 'num_required': 7}),
|
||||
json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo2',
|
||||
'problem_name': "Problem 2", 'num_graded': 1, 'num_pending': 5, 'num_required': 8})
|
||||
]})
|
||||
|
||||
class PeerGradingService(GradingService):
|
||||
"""
|
||||
Interface with the grading controller for peer grading
|
||||
"""
|
||||
def __init__(self, config):
|
||||
super(PeerGradingService, self).__init__(config)
|
||||
self.get_next_submission_url = self.url + '/get_next_submission/'
|
||||
self.save_grade_url = self.url + '/save_grade/'
|
||||
self.is_student_calibrated_url = self.url + '/is_student_calibrated/'
|
||||
self.show_calibration_essay_url = self.url + '/show_calibration_essay/'
|
||||
self.save_calibration_essay_url = self.url + '/save_calibration_essay/'
|
||||
self.get_problem_list_url = self.url + '/get_problem_list/'
|
||||
self.get_notifications_url = self.url + '/get_notifications/'
|
||||
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
response = self.get(self.get_next_submission_url,
|
||||
{'location': problem_location, 'grader_id': grader_id})
|
||||
return json.dumps(self._render_rubric(response))
|
||||
|
||||
def save_grade(self, location, grader_id, submission_id, score, feedback, submission_key, rubric_scores):
|
||||
data = {'grader_id' : grader_id,
|
||||
'submission_id' : submission_id,
|
||||
'score' : score,
|
||||
'feedback' : feedback,
|
||||
'submission_key': submission_key,
|
||||
'location': location,
|
||||
'rubric_scores': rubric_scores,
|
||||
'rubric_scores_complete': True}
|
||||
return self.post(self.save_grade_url, data)
|
||||
|
||||
def is_student_calibrated(self, problem_location, grader_id):
|
||||
params = {'problem_id' : problem_location, 'student_id': grader_id}
|
||||
return self.get(self.is_student_calibrated_url, params)
|
||||
|
||||
def show_calibration_essay(self, problem_location, grader_id):
|
||||
params = {'problem_id' : problem_location, 'student_id': grader_id}
|
||||
response = self.get(self.show_calibration_essay_url, params)
|
||||
return json.dumps(self._render_rubric(response))
|
||||
|
||||
def save_calibration_essay(self, problem_location, grader_id, calibration_essay_id, submission_key,
|
||||
score, feedback, rubric_scores):
|
||||
data = {'location': problem_location,
|
||||
'student_id': grader_id,
|
||||
'calibration_essay_id': calibration_essay_id,
|
||||
'submission_key': submission_key,
|
||||
'score': score,
|
||||
'feedback': feedback,
|
||||
'rubric_scores[]': rubric_scores,
|
||||
'rubric_scores_complete': True}
|
||||
return self.post(self.save_calibration_essay_url, data)
|
||||
|
||||
def get_problem_list(self, course_id, grader_id):
|
||||
params = {'course_id': course_id, 'student_id': grader_id}
|
||||
response = self.get(self.get_problem_list_url, params)
|
||||
return response
|
||||
|
||||
def get_notifications(self, course_id, grader_id):
|
||||
params = {'course_id': course_id, 'student_id': grader_id}
|
||||
response = self.get(self.get_notifications_url, params)
|
||||
return response
|
||||
|
||||
|
||||
_service = None
|
||||
def peer_grading_service():
|
||||
"""
|
||||
Return a peer grading service instance--if settings.MOCK_PEER_GRADING is True,
|
||||
returns a mock one, otherwise a real one.
|
||||
|
||||
Caches the result, so changing the setting after the first call to this
|
||||
function will have no effect.
|
||||
"""
|
||||
global _service
|
||||
if _service is not None:
|
||||
return _service
|
||||
|
||||
if settings.MOCK_PEER_GRADING:
|
||||
_service = MockPeerGradingService()
|
||||
else:
|
||||
_service = PeerGradingService(settings.PEER_GRADING_INTERFACE)
|
||||
|
||||
return _service
|
||||
|
||||
def _err_response(msg):
|
||||
"""
|
||||
Return a HttpResponse with a json dump with success=False, and the given error message.
|
||||
"""
|
||||
return HttpResponse(json.dumps({'success': False, 'error': msg}),
|
||||
mimetype="application/json")
|
||||
|
||||
def _check_required(request, required):
|
||||
actual = set(request.POST.keys())
|
||||
missing = required - actual
|
||||
if len(missing) > 0:
|
||||
return False, "Missing required keys: {0}".format(', '.join(missing))
|
||||
else:
|
||||
return True, ""
|
||||
|
||||
def _check_post(request):
|
||||
if request.method != 'POST':
|
||||
raise Http404
|
||||
|
||||
|
||||
def get_next_submission(request, course_id):
|
||||
"""
|
||||
Makes a call to the grading controller for the next essay that should be graded
|
||||
Returns a json dict with the following keys:
|
||||
|
||||
'success': bool
|
||||
|
||||
'submission_id': a unique identifier for the submission, to be passed back
|
||||
with the grade.
|
||||
|
||||
'submission': the submission, rendered as read-only html for grading
|
||||
|
||||
'rubric': the rubric, also rendered as html.
|
||||
|
||||
'submission_key': a key associated with the submission for validation reasons
|
||||
|
||||
'error': if success is False, will have an error message with more info.
|
||||
"""
|
||||
_check_post(request)
|
||||
required = set(['location'])
|
||||
success, message = _check_required(request, required)
|
||||
if not success:
|
||||
return _err_response(message)
|
||||
grader_id = unique_id_for_user(request.user)
|
||||
p = request.POST
|
||||
location = p['location']
|
||||
|
||||
try:
|
||||
response = peer_grading_service().get_next_submission(location, grader_id)
|
||||
return HttpResponse(response,
|
||||
mimetype="application/json")
|
||||
except GradingServiceError:
|
||||
log.exception("Error getting next submission. server url: {0} location: {1}, grader_id: {2}"
|
||||
.format(peer_grading_service().url, location, grader_id))
|
||||
return json.dumps({'success': False,
|
||||
'error': 'Could not connect to grading service'})
|
||||
|
||||
def save_grade(request, course_id):
|
||||
"""
|
||||
Saves the grade of a given submission.
|
||||
Input:
|
||||
The request should have the following keys:
|
||||
location - problem location
|
||||
submission_id - id associated with this submission
|
||||
submission_key - submission key given for validation purposes
|
||||
score - the grade that was given to the submission
|
||||
feedback - the feedback from the student
|
||||
Returns
|
||||
A json object with the following keys:
|
||||
success: bool indicating whether the save was a success
|
||||
error: if there was an error in the submission, this is the error message
|
||||
"""
|
||||
_check_post(request)
|
||||
required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]'])
|
||||
success, message = _check_required(request, required)
|
||||
if not success:
|
||||
return _err_response(message)
|
||||
grader_id = unique_id_for_user(request.user)
|
||||
p = request.POST
|
||||
location = p['location']
|
||||
submission_id = p['submission_id']
|
||||
score = p['score']
|
||||
feedback = p['feedback']
|
||||
submission_key = p['submission_key']
|
||||
rubric_scores = p.getlist('rubric_scores[]')
|
||||
try:
|
||||
response = peer_grading_service().save_grade(location, grader_id, submission_id,
|
||||
score, feedback, submission_key, rubric_scores)
|
||||
return HttpResponse(response, mimetype="application/json")
|
||||
except GradingServiceError:
|
||||
log.exception("""Error saving grade. server url: {0}, location: {1}, submission_id:{2},
|
||||
submission_key: {3}, score: {4}"""
|
||||
.format(peer_grading_service().url,
|
||||
location, submission_id, submission_key, score)
|
||||
)
|
||||
return json.dumps({'success': False,
|
||||
'error': 'Could not connect to grading service'})
|
||||
|
||||
|
||||
|
||||
def is_student_calibrated(request, course_id):
|
||||
"""
|
||||
Calls the grading controller to see if the given student is calibrated
|
||||
on the given problem
|
||||
|
||||
Input:
|
||||
In the request, we need the following arguments:
|
||||
location - problem location
|
||||
|
||||
Returns:
|
||||
Json object with the following keys
|
||||
success - bool indicating whether or not the call was successful
|
||||
calibrated - true if the grader has fully calibrated and can now move on to grading
|
||||
- false if the grader is still working on calibration problems
|
||||
total_calibrated_on_so_far - the number of calibration essays for this problem
|
||||
that this grader has graded
|
||||
"""
|
||||
_check_post(request)
|
||||
required = set(['location'])
|
||||
success, message = _check_required(request, required)
|
||||
if not success:
|
||||
return _err_response(message)
|
||||
grader_id = unique_id_for_user(request.user)
|
||||
p = request.POST
|
||||
location = p['location']
|
||||
|
||||
try:
|
||||
response = peer_grading_service().is_student_calibrated(location, grader_id)
|
||||
return HttpResponse(response, mimetype="application/json")
|
||||
except GradingServiceError:
|
||||
log.exception("Error from grading service. server url: {0}, grader_id: {0}, location: {1}"
|
||||
.format(peer_grading_service().url, grader_id, location))
|
||||
return json.dumps({'success': False,
|
||||
'error': 'Could not connect to grading service'})
|
||||
|
||||
|
||||
|
||||
def show_calibration_essay(request, course_id):
|
||||
"""
|
||||
Fetch the next calibration essay from the grading controller and return it
|
||||
Inputs:
|
||||
In the request
|
||||
location - problem location
|
||||
|
||||
Returns:
|
||||
A json dict with the following keys
|
||||
'success': bool
|
||||
|
||||
'submission_id': a unique identifier for the submission, to be passed back
|
||||
with the grade.
|
||||
|
||||
'submission': the submission, rendered as read-only html for grading
|
||||
|
||||
'rubric': the rubric, also rendered as html.
|
||||
|
||||
'submission_key': a key associated with the submission for validation reasons
|
||||
|
||||
'error': if success is False, will have an error message with more info.
|
||||
|
||||
"""
|
||||
_check_post(request)
|
||||
|
||||
required = set(['location'])
|
||||
success, message = _check_required(request, required)
|
||||
if not success:
|
||||
return _err_response(message)
|
||||
|
||||
grader_id = unique_id_for_user(request.user)
|
||||
p = request.POST
|
||||
location = p['location']
|
||||
try:
|
||||
response = peer_grading_service().show_calibration_essay(location, grader_id)
|
||||
return HttpResponse(response, mimetype="application/json")
|
||||
except GradingServiceError:
|
||||
log.exception("Error from grading service. server url: {0}, location: {0}"
|
||||
.format(peer_grading_service().url, location))
|
||||
return json.dumps({'success': False,
|
||||
'error': 'Could not connect to grading service'})
|
||||
# if we can't parse the rubric into HTML,
|
||||
except etree.XMLSyntaxError:
|
||||
log.exception("Cannot parse rubric string. Raw string: {0}"
|
||||
.format(rubric))
|
||||
return json.dumps({'success': False,
|
||||
'error': 'Error displaying submission'})
|
||||
|
||||
|
||||
def save_calibration_essay(request, course_id):
|
||||
"""
|
||||
Saves the grader's grade of a given calibration.
|
||||
Input:
|
||||
The request should have the following keys:
|
||||
location - problem location
|
||||
submission_id - id associated with this submission
|
||||
submission_key - submission key given for validation purposes
|
||||
score - the grade that was given to the submission
|
||||
feedback - the feedback from the student
|
||||
Returns
|
||||
A json object with the following keys:
|
||||
success: bool indicating whether the save was a success
|
||||
error: if there was an error in the submission, this is the error message
|
||||
actual_score: the score that the instructor gave to this calibration essay
|
||||
|
||||
"""
|
||||
_check_post(request)
|
||||
|
||||
required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]'])
|
||||
success, message = _check_required(request, required)
|
||||
if not success:
|
||||
return _err_response(message)
|
||||
grader_id = unique_id_for_user(request.user)
|
||||
p = request.POST
|
||||
location = p['location']
|
||||
calibration_essay_id = p['submission_id']
|
||||
submission_key = p['submission_key']
|
||||
score = p['score']
|
||||
feedback = p['feedback']
|
||||
rubric_scores = p.getlist('rubric_scores[]')
|
||||
|
||||
try:
|
||||
response = peer_grading_service().save_calibration_essay(location, grader_id, calibration_essay_id,
|
||||
submission_key, score, feedback, rubric_scores)
|
||||
return HttpResponse(response, mimetype="application/json")
|
||||
except GradingServiceError:
|
||||
log.exception("Error saving calibration grade, location: {0}, submission_id: {1}, submission_key: {2}, grader_id: {3}".format(location, submission_id, submission_key, grader_id))
|
||||
return _err_response('Could not connect to grading service')
|
||||
@@ -7,8 +7,7 @@ import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, ConnectionError, HTTPError
|
||||
import sys
|
||||
from grading_service import GradingService
|
||||
from grading_service import GradingServiceError
|
||||
from xmodule.grading_service_module import GradingService, GradingServiceError
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, Http404
|
||||
@@ -22,8 +21,6 @@ from mitxmako.shortcuts import render_to_string
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
class MockStaffGradingService(object):
|
||||
"""
|
||||
A simple mockup of a staff grading service, testing.
|
||||
@@ -64,6 +61,7 @@ class StaffGradingService(GradingService):
|
||||
Interface to staff grading backend.
|
||||
"""
|
||||
def __init__(self, config):
|
||||
config['system'] = ModuleSystem(None,None,None,render_to_string,None)
|
||||
super(StaffGradingService, self).__init__(config)
|
||||
self.get_next_url = self.url + '/get_next_submission/'
|
||||
self.save_grade_url = self.url + '/save_grade/'
|
||||
|
||||
@@ -6,7 +6,7 @@ django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/open
|
||||
|
||||
from django.test import TestCase
|
||||
from open_ended_grading import staff_grading_service
|
||||
from open_ended_grading import peer_grading_service
|
||||
from xmodule import peer_grading_service, peer_grading_module
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
@@ -17,10 +17,13 @@ import xmodule.modulestore.django
|
||||
from nose import SkipTest
|
||||
from mock import patch, Mock
|
||||
import json
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
from override_settings import override_settings
|
||||
from django.http import QueryDict
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE)
|
||||
@@ -98,6 +101,7 @@ class TestStaffGradingService(ct.PageLoader):
|
||||
'submission_id': '123',
|
||||
'location': self.location,
|
||||
'rubric_scores[]': ['1', '2']}
|
||||
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
self.assertTrue(d['success'], str(d))
|
||||
@@ -136,19 +140,21 @@ class TestPeerGradingService(ct.PageLoader):
|
||||
|
||||
self.course_id = "edX/toy/2012_Fall"
|
||||
self.toy = modulestore().get_course(self.course_id)
|
||||
location = "i4x://edX/toy/peergrading/init"
|
||||
|
||||
self.mock_service = peer_grading_service.peer_grading_service()
|
||||
self.mock_service = peer_grading_service.MockPeerGradingService()
|
||||
self.system = ModuleSystem(location, None, None, render_to_string, None)
|
||||
self.descriptor = peer_grading_module.PeerGradingDescriptor(self.system)
|
||||
|
||||
self.peer_module = peer_grading_module.PeerGradingModule(self.system,location,"<peergrading/>",self.descriptor)
|
||||
self.peer_module.peer_gs = self.mock_service
|
||||
self.logout()
|
||||
|
||||
def test_get_next_submission_success(self):
|
||||
self.login(self.student, self.password)
|
||||
|
||||
url = reverse('peer_grading_get_next_submission', kwargs={'course_id': self.course_id})
|
||||
data = {'location': self.location}
|
||||
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
r = self.peer_module.get_next_submission(data)
|
||||
d = json.loads(r)
|
||||
self.assertTrue(d['success'])
|
||||
self.assertIsNotNone(d['submission_id'])
|
||||
self.assertIsNotNone(d['prompt'])
|
||||
@@ -156,62 +162,48 @@ class TestPeerGradingService(ct.PageLoader):
|
||||
self.assertIsNotNone(d['max_score'])
|
||||
|
||||
def test_get_next_submission_missing_location(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_get_next_submission', kwargs={'course_id': self.course_id})
|
||||
data = {}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
r = self.peer_module.get_next_submission(data)
|
||||
d = r
|
||||
self.assertFalse(d['success'])
|
||||
self.assertEqual(d['error'], "Missing required keys: location")
|
||||
|
||||
def test_save_grade_success(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_save_grade', kwargs={'course_id': self.course_id})
|
||||
data = {'location': self.location,
|
||||
'submission_id': '1',
|
||||
'submission_key': 'fake key',
|
||||
'score': '2',
|
||||
'feedback': 'This is feedback',
|
||||
'rubric_scores[]': [1, 2]}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
raise SkipTest()
|
||||
data = 'rubric_scores[]=1|rubric_scores[]=2|location=' + self.location + '|submission_id=1|submission_key=fake key|score=2|feedback=feedback|submission_flagged=False'
|
||||
qdict = QueryDict(data.replace("|","&"))
|
||||
r = self.peer_module.save_grade(qdict)
|
||||
d = r
|
||||
self.assertTrue(d['success'])
|
||||
|
||||
def test_save_grade_missing_keys(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_save_grade', kwargs={'course_id': self.course_id})
|
||||
data = {}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
r = self.peer_module.save_grade(data)
|
||||
d = r
|
||||
self.assertFalse(d['success'])
|
||||
self.assertTrue(d['error'].find('Missing required keys:') > -1)
|
||||
|
||||
def test_is_calibrated_success(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_is_student_calibrated', kwargs={'course_id': self.course_id})
|
||||
data = {'location': self.location}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
r = self.peer_module.is_student_calibrated(data)
|
||||
d = json.loads(r)
|
||||
self.assertTrue(d['success'])
|
||||
self.assertTrue('calibrated' in d)
|
||||
|
||||
def test_is_calibrated_failure(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_is_student_calibrated', kwargs={'course_id': self.course_id})
|
||||
data = {}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
r = self.peer_module.is_student_calibrated(data)
|
||||
d = r
|
||||
self.assertFalse(d['success'])
|
||||
self.assertFalse('calibrated' in d)
|
||||
|
||||
def test_show_calibration_essay_success(self):
|
||||
self.login(self.student, self.password)
|
||||
|
||||
url = reverse('peer_grading_show_calibration_essay', kwargs={'course_id': self.course_id})
|
||||
data = {'location': self.location}
|
||||
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
r = self.peer_module.show_calibration_essay(data)
|
||||
d = json.loads(r)
|
||||
log.debug(d)
|
||||
log.debug(type(d))
|
||||
self.assertTrue(d['success'])
|
||||
self.assertIsNotNone(d['submission_id'])
|
||||
self.assertIsNotNone(d['prompt'])
|
||||
@@ -219,37 +211,27 @@ class TestPeerGradingService(ct.PageLoader):
|
||||
self.assertIsNotNone(d['max_score'])
|
||||
|
||||
def test_show_calibration_essay_missing_key(self):
|
||||
self.login(self.student, self.password)
|
||||
|
||||
url = reverse('peer_grading_show_calibration_essay', kwargs={'course_id': self.course_id})
|
||||
data = {}
|
||||
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
r = self.peer_module.show_calibration_essay(data)
|
||||
d = r
|
||||
|
||||
self.assertFalse(d['success'])
|
||||
self.assertEqual(d['error'], "Missing required keys: location")
|
||||
|
||||
def test_save_calibration_essay_success(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_save_calibration_essay', kwargs={'course_id': self.course_id})
|
||||
data = {'location': self.location,
|
||||
'submission_id': '1',
|
||||
'submission_key': 'fake key',
|
||||
'score': '2',
|
||||
'feedback': 'This is feedback',
|
||||
'rubric_scores[]': [1, 2]}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
raise SkipTest()
|
||||
data = 'rubric_scores[]=1|rubric_scores[]=2|location=' + self.location + '|submission_id=1|submission_key=fake key|score=2|feedback=feedback|submission_flagged=False'
|
||||
qdict = QueryDict(data.replace("|","&"))
|
||||
r = self.peer_module.save_calibration_essay(qdict)
|
||||
d = r
|
||||
self.assertTrue(d['success'])
|
||||
self.assertTrue('actual_score' in d)
|
||||
|
||||
def test_save_calibration_essay_missing_keys(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_save_calibration_essay', kwargs={'course_id': self.course_id})
|
||||
data = {}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
r = self.peer_module.save_calibration_essay(data)
|
||||
d = r
|
||||
self.assertFalse(d['success'])
|
||||
self.assertTrue(d['error'].find('Missing required keys:') > -1)
|
||||
self.assertFalse('actual_score' in d)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import urllib
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from django.views.decorators.cache import cache_control
|
||||
@@ -11,10 +12,8 @@ from django.core.urlresolvers import reverse
|
||||
from student.models import unique_id_for_user
|
||||
from courseware.courses import get_course_with_access
|
||||
|
||||
from peer_grading_service import PeerGradingService
|
||||
from peer_grading_service import MockPeerGradingService
|
||||
from controller_query_service import ControllerQueryService
|
||||
from grading_service import GradingServiceError
|
||||
from xmodule.grading_service_module import GradingServiceError
|
||||
import json
|
||||
from .staff_grading import StaffGrading
|
||||
from student.models import unique_id_for_user
|
||||
@@ -25,13 +24,11 @@ import open_ended_notifications
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import search
|
||||
|
||||
from django.http import HttpResponse, Http404, HttpResponseRedirect
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
template_imports = {'urllib': urllib}
|
||||
if settings.MOCK_PEER_GRADING:
|
||||
peer_gs = MockPeerGradingService()
|
||||
else:
|
||||
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE)
|
||||
|
||||
controller_url = open_ended_util.get_controller_url()
|
||||
controller_qs = ControllerQueryService(controller_url)
|
||||
@@ -54,12 +51,14 @@ def _reverse_without_slash(url_name, course_id):
|
||||
DESCRIPTION_DICT = {
|
||||
'Peer Grading': "View all problems that require peer assessment in this particular course.",
|
||||
'Staff Grading': "View ungraded submissions submitted by students for the open ended problems in the course.",
|
||||
'Problems you have submitted': "View open ended problems that you have previously submitted for grading."
|
||||
'Problems you have submitted': "View open ended problems that you have previously submitted for grading.",
|
||||
'Flagged Submissions' : "View submissions that have been flagged by students as inappropriate."
|
||||
}
|
||||
ALERT_DICT = {
|
||||
'Peer Grading': "New submissions to grade",
|
||||
'Staff Grading': "New submissions to grade",
|
||||
'Problems you have submitted': "New grades have been returned"
|
||||
'Problems you have submitted': "New grades have been returned",
|
||||
'Flagged Submissions' : "Submissions have been flagged for review"
|
||||
}
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def staff_grading(request, course_id):
|
||||
@@ -77,66 +76,44 @@ def staff_grading(request, course_id):
|
||||
# Checked above
|
||||
'staff_access': True, })
|
||||
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def peer_grading(request, course_id):
|
||||
'''
|
||||
Show a peer grading interface
|
||||
'''
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
# call problem list service
|
||||
success = False
|
||||
error_text = ""
|
||||
problem_list = []
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
course_id_parts = course.id.split("/")
|
||||
course_id_norun = "/".join(course_id_parts[0:2])
|
||||
pg_location = "i4x://" + course_id_norun + "/peergrading/init"
|
||||
|
||||
base_course_url = reverse('courses')
|
||||
try:
|
||||
problem_list_json = peer_gs.get_problem_list(course_id, unique_id_for_user(request.user))
|
||||
problem_list_dict = json.loads(problem_list_json)
|
||||
success = problem_list_dict['success']
|
||||
if 'error' in problem_list_dict:
|
||||
error_text = problem_list_dict['error']
|
||||
problem_url_parts = search.path_to_location(modulestore(), course.id, pg_location)
|
||||
problem_url = generate_problem_url(problem_url_parts, base_course_url)
|
||||
|
||||
problem_list = problem_list_dict['problem_list']
|
||||
return HttpResponseRedirect(problem_url)
|
||||
except:
|
||||
error_message = "Error with initializing peer grading. Centralized module does not exist. Please contact course staff."
|
||||
log.exception(error_message + "Current course is: {0}".format(course_id))
|
||||
return HttpResponse(error_message)
|
||||
|
||||
except GradingServiceError:
|
||||
error_text = "Error occured while contacting the grading service"
|
||||
success = False
|
||||
# catch error if if the json loads fails
|
||||
except ValueError:
|
||||
error_text = "Could not get problem list"
|
||||
success = False
|
||||
def generate_problem_url(problem_url_parts, base_course_url):
|
||||
"""
|
||||
From a list of problem url parts generated by search.path_to_location and a base course url, generates a url to a problem
|
||||
@param problem_url_parts: Output of search.path_to_location
|
||||
@param base_course_url: Base url of a given course
|
||||
@return: A path to the problem
|
||||
"""
|
||||
problem_url = base_course_url + "/"
|
||||
for z in xrange(0,len(problem_url_parts)):
|
||||
part = problem_url_parts[z]
|
||||
if part is not None:
|
||||
if z==1:
|
||||
problem_url += "courseware/"
|
||||
problem_url += part + "/"
|
||||
return problem_url
|
||||
|
||||
ajax_url = _reverse_with_slash('peer_grading', course_id)
|
||||
|
||||
return render_to_response('peer_grading/peer_grading.html', {
|
||||
'course': course,
|
||||
'course_id': course_id,
|
||||
'ajax_url': ajax_url,
|
||||
'success': success,
|
||||
'problem_list': problem_list,
|
||||
'error_text': error_text,
|
||||
# Checked above
|
||||
'staff_access': False, })
|
||||
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def peer_grading_problem(request, course_id):
|
||||
'''
|
||||
Show individual problem interface
|
||||
'''
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
problem_location = request.GET.get("location")
|
||||
|
||||
ajax_url = _reverse_with_slash('peer_grading', course_id)
|
||||
|
||||
return render_to_response('peer_grading/peer_grading_problem.html', {
|
||||
'view_html': '',
|
||||
'course': course,
|
||||
'problem_location': problem_location,
|
||||
'course_id': course_id,
|
||||
'ajax_url': ajax_url,
|
||||
# Checked above
|
||||
'staff_access': False, })
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def student_problem_list(request, course_id):
|
||||
@@ -152,27 +129,22 @@ def student_problem_list(request, course_id):
|
||||
problem_list = []
|
||||
base_course_url = reverse('courses')
|
||||
|
||||
try:
|
||||
problem_list_json = controller_qs.get_grading_status_list(course_id, unique_id_for_user(request.user))
|
||||
problem_list_dict = json.loads(problem_list_json)
|
||||
success = problem_list_dict['success']
|
||||
if 'error' in problem_list_dict:
|
||||
error_text = problem_list_dict['error']
|
||||
|
||||
#try:
|
||||
problem_list_json = controller_qs.get_grading_status_list(course_id, unique_id_for_user(request.user))
|
||||
problem_list_dict = json.loads(problem_list_json)
|
||||
success = problem_list_dict['success']
|
||||
if 'error' in problem_list_dict:
|
||||
error_text = problem_list_dict['error']
|
||||
problem_list = []
|
||||
else:
|
||||
problem_list = problem_list_dict['problem_list']
|
||||
|
||||
for i in xrange(0,len(problem_list)):
|
||||
problem_url_parts = search.path_to_location(modulestore(), course.id, problem_list[i]['location'])
|
||||
problem_url = base_course_url + "/"
|
||||
for z in xrange(0,len(problem_url_parts)):
|
||||
part = problem_url_parts[z]
|
||||
if part is not None:
|
||||
if z==1:
|
||||
problem_url += "courseware/"
|
||||
problem_url += part + "/"
|
||||
|
||||
problem_list[i].update({'actual_url' : problem_url})
|
||||
for i in xrange(0,len(problem_list)):
|
||||
problem_url_parts = search.path_to_location(modulestore(), course.id, problem_list[i]['location'])
|
||||
problem_url = generate_problem_url(problem_url_parts, base_course_url)
|
||||
problem_list[i].update({'actual_url' : problem_url})
|
||||
|
||||
"""
|
||||
except GradingServiceError:
|
||||
error_text = "Error occured while contacting the grading service"
|
||||
success = False
|
||||
@@ -180,6 +152,7 @@ def student_problem_list(request, course_id):
|
||||
except ValueError:
|
||||
error_text = "Could not get problem list"
|
||||
success = False
|
||||
"""
|
||||
|
||||
ajax_url = _reverse_with_slash('open_ended_problems', course_id)
|
||||
|
||||
@@ -193,12 +166,59 @@ def student_problem_list(request, course_id):
|
||||
# Checked above
|
||||
'staff_access': False, })
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def flagged_problem_list(request, course_id):
|
||||
'''
|
||||
Show a student problem list
|
||||
'''
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
student_id = unique_id_for_user(request.user)
|
||||
|
||||
# call problem list service
|
||||
success = False
|
||||
error_text = ""
|
||||
problem_list = []
|
||||
base_course_url = reverse('courses')
|
||||
|
||||
try:
|
||||
problem_list_json = controller_qs.get_flagged_problem_list(course_id)
|
||||
problem_list_dict = json.loads(problem_list_json)
|
||||
success = problem_list_dict['success']
|
||||
if 'error' in problem_list_dict:
|
||||
error_text = problem_list_dict['error']
|
||||
problem_list=[]
|
||||
else:
|
||||
problem_list = problem_list_dict['flagged_submissions']
|
||||
|
||||
except GradingServiceError:
|
||||
error_text = "Error occured while contacting the grading service"
|
||||
success = False
|
||||
# catch error if if the json loads fails
|
||||
except ValueError:
|
||||
error_text = "Could not get problem list"
|
||||
success = False
|
||||
|
||||
ajax_url = _reverse_with_slash('open_ended_flagged_problems', course_id)
|
||||
context = {
|
||||
'course': course,
|
||||
'course_id': course_id,
|
||||
'ajax_url': ajax_url,
|
||||
'success': success,
|
||||
'problem_list': problem_list,
|
||||
'error_text': error_text,
|
||||
# Checked above
|
||||
'staff_access': True,
|
||||
}
|
||||
return render_to_response('open_ended_problems/open_ended_flagged_problems.html', context)
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def combined_notifications(request, course_id):
|
||||
"""
|
||||
Gets combined notifications from the grading controller and displays them
|
||||
"""
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
user = request.user
|
||||
notifications = open_ended_notifications.combined_notifications(course, user)
|
||||
log.debug(notifications)
|
||||
response = notifications['response']
|
||||
notification_tuples=open_ended_notifications.NOTIFICATION_TYPES
|
||||
|
||||
@@ -243,5 +263,35 @@ def combined_notifications(request, course_id):
|
||||
return render_to_response('open_ended_problems/combined_notifications.html',
|
||||
combined_dict
|
||||
)
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def take_action_on_flags(request, course_id):
|
||||
"""
|
||||
Takes action on student flagged submissions.
|
||||
Currently, only support unflag and ban actions.
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
raise Http404
|
||||
|
||||
|
||||
required = ['submission_id', 'action_type', 'student_id']
|
||||
for key in required:
|
||||
if key not in request.POST:
|
||||
return HttpResponse(json.dumps({'success': False, 'error': 'Missing key {0}'.format(key)}),
|
||||
mimetype="application/json")
|
||||
|
||||
p = request.POST
|
||||
submission_id = p['submission_id']
|
||||
action_type = p['action_type']
|
||||
student_id = p['student_id']
|
||||
student_id = student_id.strip(' \t\n\r')
|
||||
submission_id = submission_id.strip(' \t\n\r')
|
||||
action_type = action_type.lower().strip(' \t\n\r')
|
||||
try:
|
||||
response = controller_qs.take_action_on_flags(course_id, student_id, submission_id, action_type)
|
||||
return HttpResponse(response, mimetype="application/json")
|
||||
except GradingServiceError:
|
||||
log.exception("Error saving calibration grade, submission_id: {0}, submission_key: {1}, grader_id: {2}".format(submission_id, submission_key, grader_id))
|
||||
return _err_response('Could not connect to grading service')
|
||||
|
||||
|
||||
|
||||
@@ -266,24 +266,6 @@ STATICFILES_DIRS = [
|
||||
COMMON_ROOT / "static",
|
||||
PROJECT_ROOT / "static",
|
||||
]
|
||||
if os.path.isdir(DATA_DIR):
|
||||
# Add the full course repo if there is no static directory
|
||||
STATICFILES_DIRS += [
|
||||
# TODO (cpennington): When courses are stored in a database, this
|
||||
# should no longer be added to STATICFILES
|
||||
(course_dir, DATA_DIR / course_dir)
|
||||
for course_dir in os.listdir(DATA_DIR)
|
||||
if (os.path.isdir(DATA_DIR / course_dir) and
|
||||
not os.path.isdir(DATA_DIR / course_dir / 'static'))
|
||||
]
|
||||
# Otherwise, add only the static directory from the course dir
|
||||
STATICFILES_DIRS += [
|
||||
# TODO (cpennington): When courses are stored in a database, this
|
||||
# should no longer be added to STATICFILES
|
||||
(course_dir, DATA_DIR / course_dir / 'static')
|
||||
for course_dir in os.listdir(DATA_DIR)
|
||||
if (os.path.isdir(DATA_DIR / course_dir / 'static'))
|
||||
]
|
||||
|
||||
# Locale/Internationalization
|
||||
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
||||
@@ -437,7 +419,7 @@ main_vendor_js = [
|
||||
|
||||
discussion_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/discussion/**/*.coffee'))
|
||||
staff_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/staff_grading/**/*.coffee'))
|
||||
peer_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static','coffee/src/peer_grading/**/*.coffee'))
|
||||
open_ended_js = sorted(rooted_glob(PROJECT_ROOT / 'static','coffee/src/open_ended/**/*.coffee'))
|
||||
|
||||
PIPELINE_CSS = {
|
||||
'application': {
|
||||
@@ -468,7 +450,7 @@ PIPELINE_JS = {
|
||||
'source_filenames': sorted(
|
||||
set(rooted_glob(COMMON_ROOT / 'static', 'coffee/src/**/*.coffee') +
|
||||
rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/**/*.coffee')) -
|
||||
set(courseware_js + discussion_js + staff_grading_js + peer_grading_js)
|
||||
set(courseware_js + discussion_js + staff_grading_js + open_ended_js)
|
||||
) + [
|
||||
'js/form.ext.js',
|
||||
'js/my_courses_dropdown.js',
|
||||
@@ -498,9 +480,9 @@ PIPELINE_JS = {
|
||||
'source_filenames': staff_grading_js,
|
||||
'output_filename': 'js/staff_grading.js'
|
||||
},
|
||||
'peer_grading' : {
|
||||
'source_filenames': peer_grading_js,
|
||||
'output_filename': 'js/peer_grading.js'
|
||||
'open_ended' : {
|
||||
'source_filenames': open_ended_js,
|
||||
'output_filename': 'js/open_ended.js'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,6 +543,7 @@ INSTALLED_APPS = (
|
||||
# For asset pipelining
|
||||
'pipeline',
|
||||
'staticfiles',
|
||||
'static_replace',
|
||||
|
||||
# Our courseware
|
||||
'circuit',
|
||||
|
||||
@@ -106,6 +106,27 @@ VIRTUAL_UNIVERSITIES = []
|
||||
|
||||
COMMENTS_SERVICE_KEY = "PUT_YOUR_API_KEY_HERE"
|
||||
|
||||
############################## Course static files ##########################
|
||||
if os.path.isdir(DATA_DIR):
|
||||
# Add the full course repo if there is no static directory
|
||||
STATICFILES_DIRS += [
|
||||
# TODO (cpennington): When courses are stored in a database, this
|
||||
# should no longer be added to STATICFILES
|
||||
(course_dir, DATA_DIR / course_dir)
|
||||
for course_dir in os.listdir(DATA_DIR)
|
||||
if (os.path.isdir(DATA_DIR / course_dir) and
|
||||
not os.path.isdir(DATA_DIR / course_dir / 'static'))
|
||||
]
|
||||
# Otherwise, add only the static directory from the course dir
|
||||
STATICFILES_DIRS += [
|
||||
# TODO (cpennington): When courses are stored in a database, this
|
||||
# should no longer be added to STATICFILES
|
||||
(course_dir, DATA_DIR / course_dir / 'static')
|
||||
for course_dir in os.listdir(DATA_DIR)
|
||||
if (os.path.isdir(DATA_DIR / course_dir / 'static'))
|
||||
]
|
||||
|
||||
|
||||
################################# mitx revision string #####################
|
||||
|
||||
MITX_VERSION_STRING = os.popen('cd %s; git describe' % REPO_ROOT).read().strip()
|
||||
|
||||
65
lms/static/coffee/src/open_ended/open_ended.coffee
Normal file
65
lms/static/coffee/src/open_ended/open_ended.coffee
Normal file
@@ -0,0 +1,65 @@
|
||||
# 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 OpenEnded
|
||||
constructor: (ajax_url) ->
|
||||
@ajax_url = 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_list = $('.problem-list')
|
||||
|
||||
@ban_button = $('.ban-button')
|
||||
@unflag_button = $('.unflag-button')
|
||||
@ban_button.click @ban
|
||||
@unflag_button.click @unflag
|
||||
|
||||
unflag: (event) =>
|
||||
event.preventDefault()
|
||||
parent_tr = $(event.target).parent().parent()
|
||||
tr_children = parent_tr.children()
|
||||
action_type = "unflag"
|
||||
submission_id = parent_tr.data('submission-id')
|
||||
student_id = parent_tr.data('student-id')
|
||||
callback_func = @after_action_wrapper($(event.target), action_type)
|
||||
@post('take_action_on_flags', {'submission_id' : submission_id, 'student_id' : student_id, 'action_type' : action_type}, callback_func)
|
||||
|
||||
ban: (event) =>
|
||||
event.preventDefault()
|
||||
parent_tr = $(event.target).parent().parent()
|
||||
tr_children = parent_tr.children()
|
||||
action_type = "ban"
|
||||
submission_id = parent_tr.data('submission-id')
|
||||
student_id = parent_tr.data('student-id')
|
||||
callback_func = @after_action_wrapper($(event.target), action_type)
|
||||
@post('take_action_on_flags', {'submission_id' : submission_id, 'student_id' : student_id, 'action_type' : action_type}, callback_func)
|
||||
|
||||
post: (cmd, data, callback) ->
|
||||
# if this post request fails, the error callback will catch it
|
||||
$.post(@ajax_url + cmd, data, callback)
|
||||
.error => callback({success: false, error: "Error occured while performing this operation"})
|
||||
|
||||
after_action_wrapper: (target, action_type) ->
|
||||
tr_parent = target.parent().parent()
|
||||
tr_children = tr_parent.children()
|
||||
action_taken = tr_children[4].firstElementChild
|
||||
action_taken.innerText = "#{action_type} done for student."
|
||||
return @handle_after_action
|
||||
|
||||
handle_after_action: (data) ->
|
||||
if !data.success
|
||||
@gentle_alert data.error
|
||||
|
||||
gentle_alert: (msg) =>
|
||||
if $('.message-container').length
|
||||
$('.message-container').remove()
|
||||
alert_elem = "<div class='message-container'>" + msg + "</div>"
|
||||
$('.error-container').after(alert_elem)
|
||||
$('.message-container').css(opacity: 0).animate(opacity: 1, 700)
|
||||
|
||||
ajax_url = $('.open-ended-problems').data('ajax_url')
|
||||
$(document).ready(() -> new OpenEnded(ajax_url))
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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: () ->
|
||||
@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_list = $('.problem-list')
|
||||
@construct_progress_bar()
|
||||
|
||||
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})
|
||||
)
|
||||
|
||||
|
||||
$(document).ready(() -> new PeerGrading())
|
||||
@@ -120,7 +120,7 @@ div.peer-grading{
|
||||
margin-right:20px;
|
||||
> div
|
||||
{
|
||||
padding: 10px;
|
||||
padding: 2px;
|
||||
margin: 0px;
|
||||
background: #eee;
|
||||
height: 10em;
|
||||
|
||||
1
lms/templates/conditional_ajax.html
Normal file
1
lms/templates/conditional_ajax.html
Normal file
@@ -0,0 +1 @@
|
||||
<div id="conditional_${element_id}" class="conditional-wrapper" data-problem-id="${id}" data-url="${ajax_url}"></div>
|
||||
15
lms/templates/conditional_module.html
Normal file
15
lms/templates/conditional_module.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<%
|
||||
from django.core.urlresolvers import reverse
|
||||
reqm = module.required_modules[0]
|
||||
course_id = module.system.course_id
|
||||
condition = module.condition
|
||||
%>
|
||||
|
||||
<p><a href="${reverse('jump_to',kwargs=dict(course_id=course_id, location=reqm.location.url()))}">${reqm.display_name}</a>
|
||||
must be
|
||||
% if 'attempted' in condition:
|
||||
attempted
|
||||
% else:
|
||||
completed
|
||||
% endif
|
||||
before this will become visible.</p>
|
||||
@@ -32,7 +32,9 @@ ${progress_graph.body(grade_summary, course.grade_cutoffs, "grade-detail-graph",
|
||||
<h1>Course Progress</h1>
|
||||
</header>
|
||||
|
||||
<div id="grade-detail-graph"></div>
|
||||
%if not course.metadata.get('disable_progress_graph',False):
|
||||
<div id="grade-detail-graph"></div>
|
||||
%endif
|
||||
|
||||
<ol class="chapters">
|
||||
%for chapter in courseware_summary:
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<%inherit file="/main.html" />
|
||||
<%block name="bodyclass">${course.css_class}</%block>
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
|
||||
<%block name="headextra">
|
||||
<%static:css group='course'/>
|
||||
</%block>
|
||||
|
||||
<%block name="title"><title>${course.number} Flagged Open Ended Problems</title></%block>
|
||||
|
||||
<%include file="/courseware/course_navigation.html" args="active_page='open_ended_flagged_problems'" />
|
||||
|
||||
<%block name="js_extra">
|
||||
<%static:js group='open_ended'/>
|
||||
</%block>
|
||||
|
||||
<section class="container">
|
||||
<div class="open-ended-problems" data-ajax_url="${ajax_url}">
|
||||
<div class="error-container">${error_text}</div>
|
||||
<h1>Flagged Open Ended Problems</h1>
|
||||
<h2>Instructions</h2>
|
||||
<p>Here are a list of open ended problems for this course that have been flagged by students as potentially inappropriate.</p>
|
||||
% if success:
|
||||
% if len(problem_list) == 0:
|
||||
<div class="message-container">
|
||||
No flagged problems exist.
|
||||
</div>
|
||||
%else:
|
||||
<table class="problem-list">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Response</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
%for problem in problem_list:
|
||||
<tr data-submission-id="${problem['submission_id']}" data-student-id="${problem['student_id']}">
|
||||
<td>
|
||||
${problem['problem_name']}
|
||||
</td>
|
||||
<td>
|
||||
${problem['student_response']}
|
||||
</td>
|
||||
<td>
|
||||
<a href="#unflag" class="unflag-button action-button" data-action-type="unflag">Unflag</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="#ban" class="ban-button action-button" data-action-type="ban">Ban</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-taken"></div>
|
||||
</td>
|
||||
</tr>
|
||||
%endfor
|
||||
</table>
|
||||
%endif
|
||||
%endif
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,21 +1,5 @@
|
||||
<%inherit file="/main.html" />
|
||||
<%block name="bodyclass">${course.css_class}</%block>
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
|
||||
<%block name="headextra">
|
||||
<%static:css group='course'/>
|
||||
</%block>
|
||||
|
||||
<%block name="title"><title>${course.number} Peer Grading</title></%block>
|
||||
|
||||
<%include file="/courseware/course_navigation.html" args="active_page='peer_grading'" />
|
||||
|
||||
<%block name="js_extra">
|
||||
<%static:js group='peer_grading'/>
|
||||
</%block>
|
||||
|
||||
<section class="container">
|
||||
<div class="peer-grading" data-ajax_url="${ajax_url}">
|
||||
<section class="container peer-grading-container">
|
||||
<div class="peer-grading" data-ajax-url="${ajax_url}" data-use-single-location="${use_single_location}">
|
||||
<div class="error-container">${error_text}</div>
|
||||
<h1>Peer Grading</h1>
|
||||
<h2>Instructions</h2>
|
||||
@@ -38,7 +22,7 @@
|
||||
%for problem in problem_list:
|
||||
<tr data-graded="${problem['num_graded']}" data-required="${problem['num_required']}">
|
||||
<td class="problem-name">
|
||||
<a href="${ajax_url}problem?location=${problem['location']}">${problem['problem_name']}</a>
|
||||
<a href="#problem" data-location="${problem['location']}" class="problem-button">${problem['problem_name']}</a>
|
||||
</td>
|
||||
<td>
|
||||
${problem['num_graded']}
|
||||
|
||||
@@ -1,23 +1,5 @@
|
||||
|
||||
<%inherit file="/main.html" />
|
||||
<%block name="bodyclass">${course.css_class}</%block>
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
|
||||
<%block name="headextra">
|
||||
<%static:css group='course'/>
|
||||
</%block>
|
||||
|
||||
<%block name="title"><title>${course.number} Peer Grading.</title></%block>
|
||||
|
||||
<%include file="/courseware/course_navigation.html" args="active_page='peer_grading'" />
|
||||
|
||||
<%block name="js_extra">
|
||||
<%static:js group='peer_grading'/>
|
||||
</%block>
|
||||
|
||||
|
||||
<section class="container">
|
||||
<div class="peer-grading" data-ajax_url="${ajax_url}" data-location="${problem_location}">
|
||||
<section class="container peer-grading-container">
|
||||
<div class="peer-grading" data-ajax-url="${ajax_url}" data-location="${problem_location}" data-use-single-location="${use_single_location}">
|
||||
<div class="error-container"></div>
|
||||
|
||||
<section class="content-panel">
|
||||
@@ -72,6 +54,7 @@
|
||||
</p>
|
||||
<textarea name="feedback" placeholder="Feedback for student (optional)"
|
||||
class="feedback-area" cols="70" ></textarea>
|
||||
<p class="flag-student-container">Flag this submission for review by course staff (use if the submission contains inappropriate content): <input type="checkbox" class="flag-checkbox" value="student_is_flagged"></p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
26
lms/urls.py
26
lms/urls.py
@@ -268,26 +268,15 @@ if settings.COURSEWARE_ENABLED:
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/staff_grading/get_problem_list$',
|
||||
'open_ended_grading.staff_grading_service.get_problem_list', name='staff_grading_get_problem_list'),
|
||||
|
||||
|
||||
# Peer Grading
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading$',
|
||||
'open_ended_grading.views.peer_grading', name='peer_grading'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/problem$',
|
||||
'open_ended_grading.views.peer_grading_problem', name='peer_grading_problem'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/get_next_submission$',
|
||||
'open_ended_grading.peer_grading_service.get_next_submission', name='peer_grading_get_next_submission'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/show_calibration_essay$',
|
||||
'open_ended_grading.peer_grading_service.show_calibration_essay', name='peer_grading_show_calibration_essay'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/is_student_calibrated$',
|
||||
'open_ended_grading.peer_grading_service.is_student_calibrated', name='peer_grading_is_student_calibrated'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/save_grade$',
|
||||
'open_ended_grading.peer_grading_service.save_grade', name='peer_grading_save_grade'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/save_calibration_essay$',
|
||||
'open_ended_grading.peer_grading_service.save_calibration_essay', name='peer_grading_save_calibration_essay'),
|
||||
|
||||
# Open Ended problem list
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/open_ended_problems$',
|
||||
'open_ended_grading.views.student_problem_list', name='open_ended_problems'),
|
||||
|
||||
# Open Ended flagged problem list
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/open_ended_flagged_problems$',
|
||||
'open_ended_grading.views.flagged_problem_list', name='open_ended_flagged_problems'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/open_ended_flagged_problems/take_action_on_flags$',
|
||||
'open_ended_grading.views.take_action_on_flags', name='open_ended_flagged_problems_take_action'),
|
||||
|
||||
# Cohorts management
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts$',
|
||||
@@ -311,6 +300,9 @@ if settings.COURSEWARE_ENABLED:
|
||||
# Open Ended Notifications
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/open_ended_notifications$',
|
||||
'open_ended_grading.views.combined_notifications', name='open_ended_notifications'),
|
||||
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading$',
|
||||
'open_ended_grading.views.peer_grading', name='peer_grading'),
|
||||
)
|
||||
|
||||
# discussion forums live within courseware, so courseware must be enabled first
|
||||
|
||||
Reference in New Issue
Block a user