Commit 7596deb4 by David Ormsbee

Merge pull request #1094 from MITx/feature/victor/instructor-grading

Feature/victor/instructor grading
parents 98fefd14 44a8f31d
...@@ -36,7 +36,7 @@ file and check it in at the same time as your model changes. To do that, ...@@ -36,7 +36,7 @@ file and check it in at the same time as your model changes. To do that,
3. Add the migration file created in mitx/common/djangoapps/student/migrations/ 3. Add the migration file created in mitx/common/djangoapps/student/migrations/
""" """
from datetime import datetime from datetime import datetime
from hashlib import sha1 import hashlib
import json import json
import logging import logging
import uuid import uuid
...@@ -197,14 +197,13 @@ def unique_id_for_user(user): ...@@ -197,14 +197,13 @@ def unique_id_for_user(user):
""" """
Return a unique id for a user, suitable for inserting into Return a unique id for a user, suitable for inserting into
e.g. personalized survey links. e.g. personalized survey links.
Currently happens to be implemented as a sha1 hash of the username
(and thus assumes that usernames don't change).
""" """
# Using the user id as the salt because it's sort of random, and is already # include the secret key as a salt, and to make the ids unique accross
# in the db. # different LMS installs.
salt = str(user.id) h = hashlib.md5()
return sha1(salt + user.username).hexdigest() h.update(settings.SECRET_KEY)
h.update(str(user.id))
return h.hexdigest()
## TODO: Should be renamed to generic UserGroup, and possibly ## TODO: Should be renamed to generic UserGroup, and possibly
......
...@@ -4,6 +4,11 @@ import json ...@@ -4,6 +4,11 @@ import json
def expect_json(view_function): def expect_json(view_function):
"""
View decorator for simplifying handing of requests that expect json. If the request's
CONTENT_TYPE is application/json, parses the json dict from request.body, and updates
request.POST with the contents.
"""
@wraps(view_function) @wraps(view_function)
def expect_json_with_cloned_request(request, *args, **kwargs): def expect_json_with_cloned_request(request, *args, **kwargs):
# cdodge: fix postback errors in CMS. The POST 'content-type' header can include additional information # cdodge: fix postback errors in CMS. The POST 'content-type' header can include additional information
......
...@@ -53,7 +53,7 @@ response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) ...@@ -53,7 +53,7 @@ response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__])
solution_tags = ['solution'] solution_tags = ['solution']
# these get captured as student responses # these get captured as student responses
response_properties = ["codeparam", "responseparam", "answer"] response_properties = ["codeparam", "responseparam", "answer", "openendedparam"]
# special problem tags which should be turned into innocuous HTML # special problem tags which should be turned into innocuous HTML
html_transforms = {'problem': {'tag': 'div'}, html_transforms = {'problem': {'tag': 'div'},
...@@ -72,7 +72,7 @@ global_context = {'random': random, ...@@ -72,7 +72,7 @@ global_context = {'random': random,
'miller': chem.miller} 'miller': chem.miller}
# These should be removed from HTML output, including all subelements # These should be removed from HTML output, including all subelements
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup"] html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam","openendedrubric"]
log = logging.getLogger('mitx.' + __name__) log = logging.getLogger('mitx.' + __name__)
......
...@@ -733,3 +733,53 @@ class ChemicalEquationInput(InputTypeBase): ...@@ -733,3 +733,53 @@ class ChemicalEquationInput(InputTypeBase):
return {'previewer': '/static/js/capa/chemical_equation_preview.js',} return {'previewer': '/static/js/capa/chemical_equation_preview.js',}
registry.register(ChemicalEquationInput) registry.register(ChemicalEquationInput)
#-----------------------------------------------------------------------------
class OpenEndedInput(InputTypeBase):
"""
A text area input for code--uses codemirror, does syntax highlighting, special tab handling,
etc.
"""
template = "openendedinput.html"
tags = ['openendedinput']
# pulled out for testing
submitted_msg = ("Feedback not yet available. Reload to check again. "
"Once the problem is graded, this message will be "
"replaced with the grader's feedback")
@classmethod
def get_attributes(cls):
"""
Convert options to a convenient format.
"""
return [Attribute('rows', '30'),
Attribute('cols', '80'),
Attribute('hidden', ''),
]
def setup(self):
"""
Implement special logic: handle queueing state, and default input.
"""
# if no student input yet, then use the default input given by the problem
if not self.value:
self.value = self.xml.text
# Check if problem has been queued
self.queue_len = 0
# Flag indicating that the problem has been queued, 'msg' is length of queue
if self.status == 'incomplete':
self.status = 'queued'
self.queue_len = self.msg
self.msg = self.submitted_msg
def _extra_context(self):
"""Defined queue_len, add it """
return {'queue_len': self.queue_len,}
registry.register(OpenEndedInput)
#-----------------------------------------------------------------------------
<section id="openended_${id}" class="openended">
<textarea rows="${rows}" cols="${cols}" name="input_${id}" id="input_${id}"
% if hidden:
style="display:none;"
% endif
>${value|h}</textarea>
<div class="grader-status">
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
% elif status == 'correct':
<span class="correct" id="status_${id}">Correct</span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}">Incorrect</span>
% elif status == 'queued':
<span class="grading" id="status_${id}">Submitted for grading</span>
% endif
% if hidden:
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
% endif
</div>
<span id="answer_${id}"></span>
<div class="external-grader-message">
${msg|n}
</div>
</section>
...@@ -65,3 +65,25 @@ def is_file(file_to_test): ...@@ -65,3 +65,25 @@ def is_file(file_to_test):
Duck typing to check if 'file_to_test' is a File object Duck typing to check if 'file_to_test' is a File object
''' '''
return all(hasattr(file_to_test, method) for method in ['read', 'name']) return all(hasattr(file_to_test, method) for method in ['read', 'name'])
def find_with_default(node, path, default):
"""
Look for a child of node using , and return its text if found.
Otherwise returns default.
Arguments:
node: lxml node
path: xpath search expression
default: value to return if nothing found
Returns:
node.find(path).text if the find succeeds, default otherwise.
"""
v = node.find(path)
if v is not None:
return v.text
else:
return default
...@@ -49,6 +49,7 @@ def parse_xreply(xreply): ...@@ -49,6 +49,7 @@ def parse_xreply(xreply):
return_code = xreply['return_code'] return_code = xreply['return_code']
content = xreply['content'] content = xreply['content']
return (return_code, content) return (return_code, content)
...@@ -80,7 +81,11 @@ class XQueueInterface(object): ...@@ -80,7 +81,11 @@ class XQueueInterface(object):
# Log in, then try again # Log in, then try again
if error and (msg == 'login_required'): if error and (msg == 'login_required'):
self._login() (error, content) = self._login()
if error != 0:
# when the login fails
log.debug("Failed to login to queue: %s", content)
return (error, content)
if files_to_upload is not None: if files_to_upload is not None:
# Need to rewind file pointers # Need to rewind file pointers
for f in files_to_upload: for f in files_to_upload:
......
...@@ -146,6 +146,11 @@ class CapaModule(XModule): ...@@ -146,6 +146,11 @@ class CapaModule(XModule):
else: else:
self.seed = None self.seed = None
# Need the problem location in openendedresponse to send out. Adding
# it to the system here seems like the least clunky way to get it
# there.
self.system.set('location', self.location.url())
try: try:
# TODO (vshnayder): move as much as possible of this work and error # TODO (vshnayder): move as much as possible of this work and error
# checking to descriptor load time # checking to descriptor load time
......
...@@ -121,16 +121,6 @@ section.problem { ...@@ -121,16 +121,6 @@ section.problem {
} }
} }
&.processing {
p.status {
@include inline-block();
background: url('../images/spinner.gif') center center no-repeat;
height: 20px;
width: 20px;
text-indent: -9999px;
}
}
&.correct, &.ui-icon-check { &.correct, &.ui-icon-check {
p.status { p.status {
@include inline-block(); @include inline-block();
...@@ -266,6 +256,11 @@ section.problem { ...@@ -266,6 +256,11 @@ section.problem {
margin: -7px 7px 0 0; margin: -7px 7px 0 0;
} }
.grading {
text-indent: 0px;
margin: 0px 7px 0 0;
}
p { p {
line-height: 20px; line-height: 20px;
text-transform: capitalize; text-transform: capitalize;
...@@ -685,6 +680,21 @@ section.problem { ...@@ -685,6 +680,21 @@ section.problem {
color: #B00; color: #B00;
} }
} }
.markup-text{
margin: 5px;
padding: 20px 0px 15px 50px;
border-top: 1px solid #DDD;
border-left: 20px solid #FAFAFA;
bs {
color: #BB0000;
}
bg {
color: #BDA046;
}
}
} }
} }
} }
......
...@@ -339,6 +339,12 @@ class ModuleStore(object): ...@@ -339,6 +339,12 @@ class ModuleStore(object):
''' '''
raise NotImplementedError raise NotImplementedError
def get_course(self, course_id):
'''
Look for a specific course id. Returns the course descriptor, or None if not found.
'''
raise NotImplementedError
def get_parent_locations(self, location): def get_parent_locations(self, location):
'''Find all locations that are the parents of this location. Needed '''Find all locations that are the parents of this location. Needed
for path_to_location(). for path_to_location().
...@@ -399,3 +405,10 @@ class ModuleStoreBase(ModuleStore): ...@@ -399,3 +405,10 @@ class ModuleStoreBase(ModuleStore):
errorlog = self._get_errorlog(location) errorlog = self._get_errorlog(location)
return errorlog.errors return errorlog.errors
def get_course(self, course_id):
"""Default impl--linear search through course list"""
for c in self.get_courses():
if c.id == course_id:
return c
return None
...@@ -373,6 +373,14 @@ class SelfAssessmentModule(XModule): ...@@ -373,6 +373,14 @@ class SelfAssessmentModule(XModule):
def save_answer(self, get): def save_answer(self, get):
""" """
After the answer is submitted, show the rubric. After the answer is submitted, show the rubric.
Args:
get: the GET dictionary passed to the ajax request. Should contain
a key 'student_answer'
Returns:
Dictionary with keys 'success' and either 'error' (if not success),
or 'rubric_html' (if success).
""" """
# Check to see if attempts are less than max # Check to see if attempts are less than max
if self.attempts > self.max_attempts: if self.attempts > self.max_attempts:
......
...@@ -10,7 +10,7 @@ from xmodule.progress import Progress ...@@ -10,7 +10,7 @@ from xmodule.progress import Progress
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from pkg_resources import resource_string from pkg_resources import resource_string
log = logging.getLogger("mitx.common.lib.seq_module") log = logging.getLogger(__name__)
# HACK: This shouldn't be hard-coded to two types # HACK: This shouldn't be hard-coded to two types
# OBSOLETE: This obsoletes 'type' # OBSOLETE: This obsoletes 'type'
......
...@@ -809,7 +809,8 @@ class ModuleSystem(object): ...@@ -809,7 +809,8 @@ class ModuleSystem(object):
debug=False, debug=False,
xqueue=None, xqueue=None,
node_path="", node_path="",
anonymous_student_id=''): anonymous_student_id='',
course_id=None):
''' '''
Create a closure around the system environment. Create a closure around the system environment.
...@@ -844,6 +845,8 @@ class ModuleSystem(object): ...@@ -844,6 +845,8 @@ class ModuleSystem(object):
ajax results. ajax results.
anonymous_student_id - Used for tracking modules with student id anonymous_student_id - Used for tracking modules with student id
course_id - the course_id containing this module
''' '''
self.ajax_url = ajax_url self.ajax_url = ajax_url
self.xqueue = xqueue self.xqueue = xqueue
...@@ -856,6 +859,7 @@ class ModuleSystem(object): ...@@ -856,6 +859,7 @@ class ModuleSystem(object):
self.replace_urls = replace_urls self.replace_urls = replace_urls
self.node_path = node_path self.node_path = node_path
self.anonymous_student_id = anonymous_student_id self.anonymous_student_id = anonymous_student_id
self.course_id = course_id
self.user_is_staff = user is not None and user.is_staff self.user_is_staff = user is not None and user.is_staff
def get(self, attr): def get(self, attr):
......
...@@ -67,6 +67,15 @@ To run a single nose test: ...@@ -67,6 +67,15 @@ To run a single nose test:
Very handy: if you uncomment the `--pdb` argument in `NOSE_ARGS` in `lms/envs/test.py`, it will drop you into pdb on error. This lets you go up and down the stack and see what the values of the variables are. Check out http://docs.python.org/library/pdb.html Very handy: if you uncomment the `--pdb` argument in `NOSE_ARGS` in `lms/envs/test.py`, it will drop you into pdb on error. This lets you go up and down the stack and see what the values of the variables are. Check out http://docs.python.org/library/pdb.html
## Testing using queue servers
When testing problems that use a queue server on AWS (e.g. sandbox-xqueue.edx.org), you'll need to run your server on your public IP, like so.
`django-admin.py runserver --settings=lms.envs.dev --pythonpath=. 0.0.0.0:8000`
When you connect to the LMS, you need to use the public ip. Use `ifconfig` to figure out the numnber, and connect e.g. to `http://18.3.4.5:8000/`
## Content development ## Content development
If you change course content, while running the LMS in dev mode, it is unnecessary to restart to refresh the modulestore. If you change course content, while running the LMS in dev mode, it is unnecessary to restart to refresh the modulestore.
......
...@@ -418,6 +418,10 @@ If you want to customize the courseware tabs displayed for your course, specify ...@@ -418,6 +418,10 @@ If you want to customize the courseware tabs displayed for your course, specify
* "external_link". Parameters "name", "link". * "external_link". Parameters "name", "link".
* "textbooks". No parameters--generates tab names from book titles. * "textbooks". No parameters--generates tab names from book titles.
* "progress". Parameter "name". * "progress". Parameter "name".
* "static_tab". Parameters "name", 'url_slug'--will look for tab contents in
'tabs/{course_url_name}/{tab url_slug}.html'
* "staff_grading". No parameters. If specified, displays the staff grading tab for instructors.
# Tips for content developers # Tips for content developers
...@@ -429,9 +433,7 @@ before the week 1 material to make it easy to find in the file. ...@@ -429,9 +433,7 @@ before the week 1 material to make it easy to find in the file.
* Come up with a consistent pattern for url_names, so that it's easy to know where to look for any piece of content. It will also help to come up with a standard way of splitting your content files. As a point of departure, we suggest splitting chapters, sequences, html, and problems into separate files. * Come up with a consistent pattern for url_names, so that it's easy to know where to look for any piece of content. It will also help to come up with a standard way of splitting your content files. As a point of departure, we suggest splitting chapters, sequences, html, and problems into separate files.
* A heads up: our content management system will allow you to develop content through a web browser, but will be backed by this same xml at first. Once that happens, every element will be in its own file to make access and updates faster. * Prefer the most "semantic" name for containers: e.g., use problemset rather than sequential for a problem set. That way, if we decide to display problem sets differently, we don't have to change the xml.
* Prefer the most "semantic" name for containers: e.g., use problemset rather than vertical for a problem set. That way, if we decide to display problem sets differently, we don't have to change the xml.
# Other file locations (info and about) # Other file locations (info and about)
......
...@@ -34,7 +34,8 @@ def has_access(user, obj, action): ...@@ -34,7 +34,8 @@ def has_access(user, obj, action):
user: a Django user object. May be anonymous. user: a Django user object. May be anonymous.
obj: The object to check access for. For now, a module or descriptor. obj: The object to check access for. A module, descriptor, location, or
certain special strings (e.g. 'global')
action: A string specifying the action that the client is trying to perform. action: A string specifying the action that the client is trying to perform.
......
import hashlib
import json import json
import logging import logging
import pyparsing import pyparsing
...@@ -20,6 +19,7 @@ from mitxmako.shortcuts import render_to_string ...@@ -20,6 +19,7 @@ from mitxmako.shortcuts import render_to_string
from models import StudentModule, StudentModuleCache from models import StudentModule, StudentModuleCache
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
from static_replace import replace_urls from static_replace import replace_urls
from student.models import unique_id_for_user
from xmodule.errortracker import exc_info_to_str from xmodule.errortracker import exc_info_to_str
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from xmodule.modulestore import Location from xmodule.modulestore import Location
...@@ -152,12 +152,6 @@ def _get_module(user, request, location, student_module_cache, course_id, positi ...@@ -152,12 +152,6 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
if not has_access(user, descriptor, 'load'): if not has_access(user, descriptor, 'load'):
return None return None
# Anonymized student identifier
h = hashlib.md5()
h.update(settings.SECRET_KEY)
h.update(str(user.id))
anonymous_student_id = h.hexdigest()
# Only check the cache if this module can possibly have state # Only check the cache if this module can possibly have state
instance_module = None instance_module = None
shared_module = None shared_module = None
...@@ -230,7 +224,8 @@ def _get_module(user, request, location, student_module_cache, course_id, positi ...@@ -230,7 +224,8 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
# by the replace_static_urls code below # by the replace_static_urls code below
replace_urls=replace_urls, replace_urls=replace_urls,
node_path=settings.NODE_PATH, node_path=settings.NODE_PATH,
anonymous_student_id=anonymous_student_id, anonymous_student_id=unique_id_for_user(user),
course_id=course_id,
) )
# pass position specified in URL to module through ModuleSystem # pass position specified in URL to module through ModuleSystem
system.set('position', position) system.set('position', position)
......
...@@ -36,7 +36,7 @@ CourseTab = namedtuple('CourseTab', 'name link is_active') ...@@ -36,7 +36,7 @@ CourseTab = namedtuple('CourseTab', 'name link is_active')
# wrong. (e.g. "is there a 'name' field?). Validators can assume # wrong. (e.g. "is there a 'name' field?). Validators can assume
# that the type field is valid. # that the type field is valid.
# #
# - a function that takes a config, a user, and a course, and active_page and # - a function that takes a config, a user, and a course, an active_page and
# return a list of CourseTabs. (e.g. "return a CourseTab with specified # return a list of CourseTabs. (e.g. "return a CourseTab with specified
# name, link to courseware, and is_active=True/False"). The function can # name, link to courseware, and is_active=True/False"). The function can
# assume that it is only called with configs of the appropriate type that # assume that it is only called with configs of the appropriate type that
...@@ -97,6 +97,14 @@ def _textbooks(tab, user, course, active_page): ...@@ -97,6 +97,14 @@ def _textbooks(tab, user, course, active_page):
for index, textbook in enumerate(course.textbooks)] for index, textbook in enumerate(course.textbooks)]
return [] return []
def _staff_grading(tab, user, course, active_page):
if has_access(user, course, 'staff'):
link = reverse('staff_grading', args=[course.id])
return [CourseTab('Staff grading', link, active_page == "staff_grading")]
return []
#### Validators #### Validators
...@@ -132,6 +140,7 @@ VALID_TAB_TYPES = { ...@@ -132,6 +140,7 @@ VALID_TAB_TYPES = {
'textbooks': TabImpl(null_validator, _textbooks), 'textbooks': TabImpl(null_validator, _textbooks),
'progress': TabImpl(need_name, _progress), 'progress': TabImpl(need_name, _progress),
'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab), 'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab),
'staff_grading': TabImpl(null_validator, _staff_grading),
} }
......
...@@ -193,13 +193,27 @@ class PageLoader(ActivateLoginTestCase): ...@@ -193,13 +193,27 @@ class PageLoader(ActivateLoginTestCase):
def check_for_get_code(self, code, url): def check_for_get_code(self, code, url):
""" """
Check that we got the expected code. Hacks around our broken 404 Check that we got the expected code when accessing url via GET.
handling. Returns the response.
""" """
resp = self.client.get(url) resp = self.client.get(url)
self.assertEqual(resp.status_code, code, self.assertEqual(resp.status_code, code,
"got code {0} for url '{1}'. Expected code {2}" "got code {0} for url '{1}'. Expected code {2}"
.format(resp.status_code, url, code)) .format(resp.status_code, url, code))
return resp
def check_for_post_code(self, code, url, data={}):
"""
Check that we got the expected code when accessing url via POST.
Returns the response.
"""
resp = self.client.post(url, data)
self.assertEqual(resp.status_code, code,
"got code {0} for url '{1}'. Expected code {2}"
.format(resp.status_code, url, code))
return resp
def check_pages_load(self, course_name, data_dir, modstore): def check_pages_load(self, course_name, data_dir, modstore):
...@@ -286,14 +300,10 @@ class TestNavigation(PageLoader): ...@@ -286,14 +300,10 @@ class TestNavigation(PageLoader):
def setUp(self): def setUp(self):
xmodule.modulestore.django._MODULESTORES = {} xmodule.modulestore.django._MODULESTORES = {}
courses = modulestore().get_courses()
def find_course(course_id):
"""Assumes the course is present"""
return [c for c in courses if c.id==course_id][0]
self.full = find_course("edX/full/6.002_Spring_2012") # Assume courses are there
self.toy = find_course("edX/toy/2012_Fall") self.full = modulestore().get_course("edX/full/6.002_Spring_2012")
self.toy = modulestore().get_course("edX/toy/2012_Fall")
# Create two accounts # Create two accounts
self.student = 'view@test.com' self.student = 'view@test.com'
...@@ -344,14 +354,9 @@ class TestViewAuth(PageLoader): ...@@ -344,14 +354,9 @@ class TestViewAuth(PageLoader):
def setUp(self): def setUp(self):
xmodule.modulestore.django._MODULESTORES = {} xmodule.modulestore.django._MODULESTORES = {}
courses = modulestore().get_courses()
def find_course(course_id): self.full = modulestore().get_course("edX/full/6.002_Spring_2012")
"""Assumes the course is present""" self.toy = modulestore().get_course("edX/toy/2012_Fall")
return [c for c in courses if c.id==course_id][0]
self.full = find_course("edX/full/6.002_Spring_2012")
self.toy = find_course("edX/toy/2012_Fall")
# Create two accounts # Create two accounts
self.student = 'view@test.com' self.student = 'view@test.com'
...@@ -629,46 +634,46 @@ class TestCourseGrader(PageLoader): ...@@ -629,46 +634,46 @@ class TestCourseGrader(PageLoader):
return [c for c in courses if c.id==course_id][0] return [c for c in courses if c.id==course_id][0]
self.graded_course = find_course("edX/graded/2012_Fall") self.graded_course = find_course("edX/graded/2012_Fall")
# create a test student # create a test student
self.student = 'view@test.com' self.student = 'view@test.com'
self.password = 'foo' self.password = 'foo'
self.create_account('u1', self.student, self.password) self.create_account('u1', self.student, self.password)
self.activate_user(self.student) self.activate_user(self.student)
self.enroll(self.graded_course) self.enroll(self.graded_course)
self.student_user = user(self.student) self.student_user = user(self.student)
self.factory = RequestFactory() self.factory = RequestFactory()
def get_grade_summary(self): def get_grade_summary(self):
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
self.graded_course.id, self.student_user, self.graded_course) self.graded_course.id, self.student_user, self.graded_course)
fake_request = self.factory.get(reverse('progress', fake_request = self.factory.get(reverse('progress',
kwargs={'course_id': self.graded_course.id})) kwargs={'course_id': self.graded_course.id}))
return grades.grade(self.student_user, fake_request, return grades.grade(self.student_user, fake_request,
self.graded_course, student_module_cache) self.graded_course, student_module_cache)
def get_homework_scores(self): def get_homework_scores(self):
return self.get_grade_summary()['totaled_scores']['Homework'] return self.get_grade_summary()['totaled_scores']['Homework']
def get_progress_summary(self): def get_progress_summary(self):
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
self.graded_course.id, self.student_user, self.graded_course) self.graded_course.id, self.student_user, self.graded_course)
fake_request = self.factory.get(reverse('progress', fake_request = self.factory.get(reverse('progress',
kwargs={'course_id': self.graded_course.id})) kwargs={'course_id': self.graded_course.id}))
progress_summary = grades.progress_summary(self.student_user, fake_request, progress_summary = grades.progress_summary(self.student_user, fake_request,
self.graded_course, student_module_cache) self.graded_course, student_module_cache)
return progress_summary return progress_summary
def check_grade_percent(self, percent): def check_grade_percent(self, percent):
grade_summary = self.get_grade_summary() grade_summary = self.get_grade_summary()
self.assertEqual(percent, grade_summary['percent']) self.assertEqual(grade_summary['percent'], percent)
def submit_question_answer(self, problem_url_name, responses): def submit_question_answer(self, problem_url_name, responses):
""" """
The field names of a problem are hard to determine. This method only works The field names of a problem are hard to determine. This method only works
...@@ -678,96 +683,96 @@ class TestCourseGrader(PageLoader): ...@@ -678,96 +683,96 @@ class TestCourseGrader(PageLoader):
input_i4x-edX-graded-problem-H1P3_2_2 input_i4x-edX-graded-problem-H1P3_2_2
""" """
problem_location = "i4x://edX/graded/problem/{0}".format(problem_url_name) problem_location = "i4x://edX/graded/problem/{0}".format(problem_url_name)
modx_url = reverse('modx_dispatch', modx_url = reverse('modx_dispatch',
kwargs={ kwargs={
'course_id' : self.graded_course.id, 'course_id' : self.graded_course.id,
'location' : problem_location, 'location' : problem_location,
'dispatch' : 'problem_check', } 'dispatch' : 'problem_check', }
) )
resp = self.client.post(modx_url, { resp = self.client.post(modx_url, {
'input_i4x-edX-graded-problem-{0}_2_1'.format(problem_url_name): responses[0], 'input_i4x-edX-graded-problem-{0}_2_1'.format(problem_url_name): responses[0],
'input_i4x-edX-graded-problem-{0}_2_2'.format(problem_url_name): responses[1], 'input_i4x-edX-graded-problem-{0}_2_2'.format(problem_url_name): responses[1],
}) })
print "modx_url" , modx_url, "responses" , responses print "modx_url" , modx_url, "responses" , responses
print "resp" , resp print "resp" , resp
return resp return resp
def problem_location(self, problem_url_name): def problem_location(self, problem_url_name):
return "i4x://edX/graded/problem/{0}".format(problem_url_name) return "i4x://edX/graded/problem/{0}".format(problem_url_name)
def reset_question_answer(self, problem_url_name): def reset_question_answer(self, problem_url_name):
problem_location = self.problem_location(problem_url_name) problem_location = self.problem_location(problem_url_name)
modx_url = reverse('modx_dispatch', modx_url = reverse('modx_dispatch',
kwargs={ kwargs={
'course_id' : self.graded_course.id, 'course_id' : self.graded_course.id,
'location' : problem_location, 'location' : problem_location,
'dispatch' : 'problem_reset', } 'dispatch' : 'problem_reset', }
) )
resp = self.client.post(modx_url) resp = self.client.post(modx_url)
return resp return resp
def test_get_graded(self): def test_get_graded(self):
#### Check that the grader shows we have 0% in the course #### Check that the grader shows we have 0% in the course
self.check_grade_percent(0) self.check_grade_percent(0)
#### Submit the answers to a few problems as ajax calls #### Submit the answers to a few problems as ajax calls
def earned_hw_scores(): def earned_hw_scores():
"""Global scores, each Score is a Problem Set""" """Global scores, each Score is a Problem Set"""
return [s.earned for s in self.get_homework_scores()] return [s.earned for s in self.get_homework_scores()]
def score_for_hw(hw_url_name): def score_for_hw(hw_url_name):
hw_section = [section for section hw_section = [section for section
in self.get_progress_summary()[0]['sections'] in self.get_progress_summary()[0]['sections']
if section.get('url_name') == hw_url_name][0] if section.get('url_name') == hw_url_name][0]
return [s.earned for s in hw_section['scores']] return [s.earned for s in hw_section['scores']]
# Only get half of the first problem correct # Only get half of the first problem correct
self.submit_question_answer('H1P1', ['Correct', 'Incorrect']) self.submit_question_answer('H1P1', ['Correct', 'Incorrect'])
self.check_grade_percent(0.06) self.check_grade_percent(0.06)
self.assertEqual(earned_hw_scores(), [1.0, 0, 0]) # Order matters self.assertEqual(earned_hw_scores(), [1.0, 0, 0]) # Order matters
self.assertEqual(score_for_hw('Homework1'), [1.0, 0.0]) self.assertEqual(score_for_hw('Homework1'), [1.0, 0.0])
# Get both parts of the first problem correct # Get both parts of the first problem correct
self.reset_question_answer('H1P1') self.reset_question_answer('H1P1')
self.submit_question_answer('H1P1', ['Correct', 'Correct']) self.submit_question_answer('H1P1', ['Correct', 'Correct'])
self.check_grade_percent(0.13) self.check_grade_percent(0.13)
self.assertEqual(earned_hw_scores(), [2.0, 0, 0]) self.assertEqual(earned_hw_scores(), [2.0, 0, 0])
self.assertEqual(score_for_hw('Homework1'), [2.0, 0.0]) self.assertEqual(score_for_hw('Homework1'), [2.0, 0.0])
# This problem is shown in an ABTest # This problem is shown in an ABTest
self.submit_question_answer('H1P2', ['Correct', 'Correct']) self.submit_question_answer('H1P2', ['Correct', 'Correct'])
self.check_grade_percent(0.25) self.check_grade_percent(0.25)
self.assertEqual(earned_hw_scores(), [4.0, 0.0, 0]) self.assertEqual(earned_hw_scores(), [4.0, 0.0, 0])
self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0]) self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0])
# This problem is hidden in an ABTest. Getting it correct doesn't change total grade # This problem is hidden in an ABTest. Getting it correct doesn't change total grade
self.submit_question_answer('H1P3', ['Correct', 'Correct']) self.submit_question_answer('H1P3', ['Correct', 'Correct'])
self.check_grade_percent(0.25) self.check_grade_percent(0.25)
self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0]) self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0])
# On the second homework, we only answer half of the questions. # On the second homework, we only answer half of the questions.
# Then it will be dropped when homework three becomes the higher percent # Then it will be dropped when homework three becomes the higher percent
# This problem is also weighted to be 4 points (instead of default of 2) # This problem is also weighted to be 4 points (instead of default of 2)
# If the problem was unweighted the percent would have been 0.38 so we # If the problem was unweighted the percent would have been 0.38 so we
# know it works. # know it works.
self.submit_question_answer('H2P1', ['Correct', 'Correct']) self.submit_question_answer('H2P1', ['Correct', 'Correct'])
self.check_grade_percent(0.42) self.check_grade_percent(0.42)
self.assertEqual(earned_hw_scores(), [4.0, 4.0, 0]) self.assertEqual(earned_hw_scores(), [4.0, 4.0, 0])
# Third homework # Third homework
self.submit_question_answer('H3P1', ['Correct', 'Correct']) self.submit_question_answer('H3P1', ['Correct', 'Correct'])
self.check_grade_percent(0.42) # Score didn't change self.check_grade_percent(0.42) # Score didn't change
self.assertEqual(earned_hw_scores(), [4.0, 4.0, 2.0]) self.assertEqual(earned_hw_scores(), [4.0, 4.0, 2.0])
self.submit_question_answer('H3P2', ['Correct', 'Correct']) self.submit_question_answer('H3P2', ['Correct', 'Correct'])
self.check_grade_percent(0.5) # Now homework2 dropped. Score changes self.check_grade_percent(0.5) # Now homework2 dropped. Score changes
self.assertEqual(earned_hw_scores(), [4.0, 4.0, 4.0]) self.assertEqual(earned_hw_scores(), [4.0, 4.0, 4.0])
# Now we answer the final question (worth half of the grade) # Now we answer the final question (worth half of the grade)
self.submit_question_answer('FinalQuestion', ['Correct', 'Correct']) self.submit_question_answer('FinalQuestion', ['Correct', 'Correct'])
self.check_grade_percent(1.0) # Hooray! We got 100% self.check_grade_percent(1.0) # Hooray! We got 100%
......
"""
LMS part of instructor grading:
- views + ajax handling
- calls the instructor grading service
"""
import json
import logging
log = logging.getLogger(__name__)
class StaffGrading(object):
"""
Wrap up functionality for staff grading of submissions--interface exposes get_html, ajax views.
"""
def __init__(self, course):
self.course = course
def get_html(self):
return "<b>Instructor grading!</b>"
# context = {}
# return render_to_string('courseware/instructor_grading_view.html', context)
"""
This module provides views that proxy to the staff grading backend service.
"""
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 courseware.access import has_access
from util.json_request import expect_json
from xmodule.course_module import CourseDescriptor
log = logging.getLogger(__name__)
class GradingServiceError(Exception):
pass
class MockStaffGradingService(object):
"""
A simple mockup of a staff grading service, testing.
"""
def __init__(self):
self.cnt = 0
def get_next(self, course_id, grader_id):
self.cnt += 1
return json.dumps({'success': True,
'submission_id': self.cnt,
'submission': 'Test submission {cnt}'.format(cnt=self.cnt),
'max_score': 2 + self.cnt % 3,
'rubric': 'A rubric'})
def save_grade(self, course_id, grader_id, submission_id, score, feedback):
return self.get_next(course_id, grader_id)
class StaffGradingService(object):
"""
Interface to staff grading backend.
"""
def __init__(self, config):
self.username = config['username']
self.password = config['password']
self.url = config['url']
self.login_url = self.url + '/login/'
self.get_next_url = self.url + '/get_next_submission/'
self.save_grade_url = self.url + '/save_grade/'
self.session = requests.session()
def _login(self):
"""
Log into the staff grading service.
Raises requests.exceptions.HTTPError if something goes wrong.
Returns the decoded json dict of the response.
"""
response = self.session.post(self.login_url,
{'username': self.username,
'password': self.password,})
response.raise_for_status()
return response.json
def _try_with_login(self, operation):
"""
Call operation(), which should return a requests response object. If
the request fails with a 'login_required' error, call _login() and try
the operation again.
Returns the result of operation(). Does not catch exceptions.
"""
response = operation()
if (response.json
and response.json.get('success') == False
and response.json.get('error') == 'login_required'):
# apparrently we aren't logged in. Try to fix that.
r = self._login()
if r and not r.get('success'):
log.warning("Couldn't log into staff_grading backend. Response: %s",
r)
# try again
return operation()
return response
def get_next(self, course_id, grader_id):
"""
Get the next thing to grade.
Args:
course_id: course id to get submission for
grader_id: who is grading this? The anonymous user_id of the grader.
Returns:
json string with the response from the service. (Deliberately not
writing out the fields here--see the docs on the staff_grading view
in the grading_controller repo)
Raises:
GradingServiceError: something went wrong with the connection.
"""
op = lambda: self.session.get(self.get_next_url,
allow_redirects=False,
params={'course_id': course_id,
'grader_id': grader_id})
try:
r = self._try_with_login(op)
except (RequestException, ConnectionError, HTTPError) as err:
# reraise as promised GradingServiceError, but preserve stacktrace.
raise GradingServiceError, str(err), sys.exc_info()[2]
return r.text
def save_grade(self, course_id, grader_id, submission_id, score, feedback):
"""
Save a score and feedback for a submission.
Returns:
json dict with keys
'success': bool
'error': error msg, if something went wrong.
Raises:
GradingServiceError if there's a problem connecting.
"""
try:
data = {'course_id': course_id,
'submission_id': submission_id,
'score': score,
'feedback': feedback,
'grader_id': grader_id}
op = lambda: self.session.post(self.save_grade_url, data=data,
allow_redirects=False)
r = self._try_with_login(op)
except (RequestException, ConnectionError, HTTPError) as err:
# reraise as promised GradingServiceError, but preserve stacktrace.
raise GradingServiceError, str(err), sys.exc_info()[2]
return r.text
# don't initialize until grading_service() is called--means that just
# importing this file doesn't create objects that may not have the right config
_service = None
def grading_service():
"""
Return a staff grading service instance--if settings.MOCK_STAFF_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_STAFF_GRADING:
_service = MockStaffGradingService()
else:
_service = StaffGradingService(settings.STAFF_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_access(user, course_id):
"""
Raise 404 if user doesn't have staff access to course_id
"""
course_location = CourseDescriptor.id_to_location(course_id)
if not has_access(user, course_location, 'staff'):
raise Http404
return
def get_next(request, course_id):
"""
Get the next thing to grade for course_id.
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.
'message': if there was no submission available, but nothing went wrong,
there will be a message field.
'error': if success is False, will have an error message with more info.
"""
_check_access(request.user, course_id)
return HttpResponse(_get_next(course_id, request.user.id),
mimetype="application/json")
def _get_next(course_id, grader_id):
"""
Implementation of get_next (also called from save_grade) -- returns a json string
"""
try:
return grading_service().get_next(course_id, grader_id)
except GradingServiceError:
log.exception("Error from grading service. server url: {0}"
.format(grading_service().url))
return json.dumps({'success': False,
'error': 'Could not connect to grading service'})
@expect_json
def save_grade(request, course_id):
"""
Save the grade and feedback for a submission, and, if all goes well, return
the next thing to grade.
Expects the following POST parameters:
'score': int
'feedback': string
'submission_id': int
Returns the same thing as get_next, except that additional error messages
are possible if something goes wrong with saving the grade.
"""
_check_access(request.user, course_id)
if request.method != 'POST':
raise Http404
required = set('score', 'feedback', 'submission_id')
actual = set(request.POST.keys())
missing = required - actual
if len(missing) != 0:
return _err_response('Missing required keys {0}'.format(
', '.join(missing)))
grader_id = request.user.id
p = request.POST
try:
result_json = grading_service().save_grade(course_id,
grader_id,
p['submission_id'],
p['score'],
p['feedback'])
except GradingServiceError:
log.exception("Error saving grade")
return _err_response('Could not connect to grading service')
try:
result = json.loads(result_json)
except ValueError:
log.exception("save_grade returned broken json: %s", result_json)
return _err_response('Grading service returned mal-formatted data.')
if not result.get('success', False):
log.warning('Got success=False from grading service. Response: %s', result_json)
return _err_response('Grading service failed')
# Ok, save_grade seemed to work. Get the next submission to grade.
return HttpResponse(_get_next(course_id, grader_id),
mimetype="application/json")
...@@ -8,15 +8,24 @@ Notes for running by hand: ...@@ -8,15 +8,24 @@ Notes for running by hand:
django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/instructor django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/instructor
""" """
import courseware.tests.tests as ct
import json
from nose import SkipTest
from mock import patch, Mock
from override_settings import override_settings from override_settings import override_settings
from django.contrib.auth.models import \ # Need access to internal func to put users in the right group
Group # Need access to internal func to put users in the right group from django.contrib.auth.models import Group
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django_comment_client.models import Role, FORUM_ROLE_ADMINISTRATOR, \ from django_comment_client.models import Role, FORUM_ROLE_ADMINISTRATOR, \
FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT
from django_comment_client.utils import has_forum_access from django_comment_client.utils import has_forum_access
from instructor import staff_grading_service
from courseware.access import _course_staff_group_name from courseware.access import _course_staff_group_name
import courseware.tests.tests as ct import courseware.tests.tests as ct
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -31,14 +40,9 @@ class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader): ...@@ -31,14 +40,9 @@ class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader):
def setUp(self): def setUp(self):
xmodule.modulestore.django._MODULESTORES = {} xmodule.modulestore.django._MODULESTORES = {}
courses = modulestore().get_courses()
def find_course(name):
"""Assumes the course is present"""
return [c for c in courses if c.location.course==name][0]
self.full = find_course("full") self.full = modulestore().get_course("edX/full/6.002_Spring_2012")
self.toy = find_course("toy") self.toy = modulestore().get_course("edX/toy/2012_Fall")
# Create two accounts # Create two accounts
self.student = 'view@test.com' self.student = 'view@test.com'
...@@ -49,9 +53,12 @@ class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader): ...@@ -49,9 +53,12 @@ class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader):
self.activate_user(self.student) self.activate_user(self.student)
self.activate_user(self.instructor) self.activate_user(self.instructor)
group_name = _course_staff_group_name(self.toy.location) def make_instructor(course):
g = Group.objects.create(name=group_name) group_name = _course_staff_group_name(course.location)
g.user_set.add(ct.user(self.instructor)) g = Group.objects.create(name=group_name)
g.user_set.add(ct.user(self.instructor))
make_instructor(self.toy)
self.logout() self.logout()
self.login(self.instructor, self.password) self.login(self.instructor, self.password)
...@@ -67,18 +74,21 @@ class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader): ...@@ -67,18 +74,21 @@ class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader):
self.assertEqual(response['Content-Type'],'text/csv',msg) self.assertEqual(response['Content-Type'],'text/csv',msg)
cdisp = response['Content-Disposition'].replace('TT_2012','2012') # jenkins course_id is TT_2012_Fall instead of 2012_Fall? cdisp = response['Content-Disposition']
msg += "cdisp = '{0}'\n".format(cdisp) msg += "Content-Disposition = '%s'\n" % cdisp
self.assertEqual(cdisp,'attachment; filename=grades_edX/toy/2012_Fall.csv',msg) self.assertEqual(cdisp, 'attachment; filename=grades_{0}.csv'.format(course.id), msg)
body = response.content.replace('\r','') body = response.content.replace('\r','')
msg += "body = '{0}'\n".format(body) msg += "body = '{0}'\n".format(body)
# All the not-actually-in-the-course hw and labs come from the
# default grading policy string in graders.py
expected_body = '''"ID","Username","Full Name","edX email","External email","HW 01","HW 02","HW 03","HW 04","HW 05","HW 06","HW 07","HW 08","HW 09","HW 10","HW 11","HW 12","HW Avg","Lab 01","Lab 02","Lab 03","Lab 04","Lab 05","Lab 06","Lab 07","Lab 08","Lab 09","Lab 10","Lab 11","Lab 12","Lab Avg","Midterm","Final" expected_body = '''"ID","Username","Full Name","edX email","External email","HW 01","HW 02","HW 03","HW 04","HW 05","HW 06","HW 07","HW 08","HW 09","HW 10","HW 11","HW 12","HW Avg","Lab 01","Lab 02","Lab 03","Lab 04","Lab 05","Lab 06","Lab 07","Lab 08","Lab 09","Lab 10","Lab 11","Lab 12","Lab Avg","Midterm","Final"
"2","u2","Fred Weasley","view2@test.com","","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0.0","0.0" "2","u2","Fred Weasley","view2@test.com","","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0.0","0.0"
''' '''
self.assertEqual(body, expected_body, msg) self.assertEqual(body, expected_body, msg)
FORUM_ROLES = [ FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA ] FORUM_ROLES = [ FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA ]
FORUM_ADMIN_ACTION_SUFFIX = { FORUM_ROLE_ADMINISTRATOR : 'admin', FORUM_ROLE_MODERATOR : 'moderator', FORUM_ROLE_COMMUNITY_TA : 'community TA'} FORUM_ADMIN_ACTION_SUFFIX = { FORUM_ROLE_ADMINISTRATOR : 'admin', FORUM_ROLE_MODERATOR : 'moderator', FORUM_ROLE_COMMUNITY_TA : 'community TA'}
FORUM_ADMIN_USER = { FORUM_ROLE_ADMINISTRATOR : 'forumadmin', FORUM_ROLE_MODERATOR : 'forummoderator', FORUM_ROLE_COMMUNITY_TA : 'forummoderator'} FORUM_ADMIN_USER = { FORUM_ROLE_ADMINISTRATOR : 'forumadmin', FORUM_ROLE_MODERATOR : 'forummoderator', FORUM_ROLE_COMMUNITY_TA : 'forummoderator'}
...@@ -89,22 +99,22 @@ def action_name(operation, rolename): ...@@ -89,22 +99,22 @@ def action_name(operation, rolename):
else: else:
return '{0} forum {1}'.format(operation, FORUM_ADMIN_ACTION_SUFFIX[rolename]) return '{0} forum {1}'.format(operation, FORUM_ADMIN_ACTION_SUFFIX[rolename])
_mock_service = staff_grading_service.MockStaffGradingService()
@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE) @override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE)
class TestInstructorDashboardForumAdmin(ct.PageLoader): class TestInstructorDashboardForumAdmin(ct.PageLoader):
''' '''
Check for change in forum admin role memberships Check for change in forum admin role memberships
''' '''
def setUp(self): def setUp(self):
xmodule.modulestore.django._MODULESTORES = {} xmodule.modulestore.django._MODULESTORES = {}
courses = modulestore().get_courses() courses = modulestore().get_courses()
def find_course(name):
"""Assumes the course is present"""
return [c for c in courses if c.location.course==name][0]
self.full = find_course("full") self.course_id = "edX/toy/2012_Fall"
self.toy = find_course("toy") self.toy = modulestore().get_course(self.course_id)
# Create two accounts # Create two accounts
self.student = 'view@test.com' self.student = 'view@test.com'
...@@ -123,6 +133,8 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader): ...@@ -123,6 +133,8 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader):
self.login(self.instructor, self.password) self.login(self.instructor, self.password)
self.enroll(self.toy) self.enroll(self.toy)
def initialize_roles(self, course_id): def initialize_roles(self, course_id):
self.admin_role = Role.objects.get_or_create(name=FORUM_ROLE_ADMINISTRATOR, course_id=course_id)[0] self.admin_role = Role.objects.get_or_create(name=FORUM_ROLE_ADMINISTRATOR, course_id=course_id)[0]
self.moderator_role = Role.objects.get_or_create(name=FORUM_ROLE_MODERATOR, course_id=course_id)[0] self.moderator_role = Role.objects.get_or_create(name=FORUM_ROLE_MODERATOR, course_id=course_id)[0]
...@@ -209,3 +221,74 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader): ...@@ -209,3 +221,74 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader):
added_roles.sort() added_roles.sort()
roles = ', '.join(added_roles) roles = ', '.join(added_roles)
self.assertTrue(response.content.find('<td>{0}</td>'.format(roles))>=0, 'not finding roles "{0}"'.format(roles)) self.assertTrue(response.content.find('<td>{0}</td>'.format(roles))>=0, 'not finding roles "{0}"'.format(roles))
@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE)
class TestStaffGradingService(ct.PageLoader):
'''
Check that staff grading service proxy works. Basically just checking the
access control and error handling logic -- all the actual work is on the
backend.
'''
def setUp(self):
xmodule.modulestore.django._MODULESTORES = {}
self.student = 'view@test.com'
self.instructor = 'view2@test.com'
self.password = 'foo'
self.create_account('u1', self.student, self.password)
self.create_account('u2', self.instructor, self.password)
self.activate_user(self.student)
self.activate_user(self.instructor)
self.course_id = "edX/toy/2012_Fall"
self.toy = modulestore().get_course(self.course_id)
def make_instructor(course):
group_name = _course_staff_group_name(course.location)
g = Group.objects.create(name=group_name)
g.user_set.add(ct.user(self.instructor))
make_instructor(self.toy)
self.mock_service = staff_grading_service.grading_service()
self.logout()
def test_access(self):
"""
Make sure only staff have access.
"""
self.login(self.student, self.password)
# both get and post should return 404
for view_name in ('staff_grading_get_next', 'staff_grading_save_grade'):
url = reverse(view_name, kwargs={'course_id': self.course_id})
self.check_for_get_code(404, url)
self.check_for_post_code(404, url)
def test_get_next(self):
self.login(self.instructor, self.password)
url = reverse('staff_grading_get_next', kwargs={'course_id': self.course_id})
r = self.check_for_get_code(200, url)
d = json.loads(r.content)
self.assertTrue(d['success'])
self.assertEquals(d['submission_id'], self.mock_service.cnt)
def test_save_grade(self):
self.login(self.instructor, self.password)
url = reverse('staff_grading_save_grade', kwargs={'course_id': self.course_id})
data = {'score': '12',
'feedback': 'great!',
'submission_id': '123'}
r = self.check_for_post_code(200, url, data)
d = json.loads(r.content)
self.assertTrue(d['success'], str(d))
self.assertEquals(d['submission_id'], self.mock_service.cnt)
...@@ -12,6 +12,7 @@ from django.http import HttpResponse ...@@ -12,6 +12,7 @@ from django.http import HttpResponse
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control from django.views.decorators.cache import cache_control
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from django.core.urlresolvers import reverse
from courseware import grades from courseware import grades
from courseware.access import has_access, get_access_group_name from courseware.access import has_access, get_access_group_name
...@@ -27,7 +28,10 @@ from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundErr ...@@ -27,7 +28,10 @@ from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundErr
from xmodule.modulestore.search import path_to_location from xmodule.modulestore.search import path_to_location
import track.views import track.views
log = logging.getLogger("mitx.courseware") from .grading import StaffGrading
log = logging.getLogger(__name__)
template_imports = {'urllib': urllib} template_imports = {'urllib': urllib}
...@@ -87,7 +91,7 @@ def instructor_dashboard(request, course_id): ...@@ -87,7 +91,7 @@ def instructor_dashboard(request, course_id):
try: try:
group = Group.objects.get(name=staffgrp) group = Group.objects.get(name=staffgrp)
except Group.DoesNotExist: except Group.DoesNotExist:
group = Group(name=staffgrp) # create the group group = Group(name=staffgrp) # create the group
group.save() group.save()
return group return group
...@@ -377,7 +381,7 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True, ...@@ -377,7 +381,7 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True,
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).prefetch_related("groups").order_by('username') enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).prefetch_related("groups").order_by('username')
header = ['ID', 'Username', 'Full Name', 'edX email', 'External email'] header = ['ID', 'Username', 'Full Name', 'edX email', 'External email']
if get_grades: if get_grades and enrolled_students.count() > 0:
# just to construct the header # just to construct the header
gradeset = grades.grade(enrolled_students[0], request, course, keep_raw_scores=get_raw_scores) gradeset = grades.grade(enrolled_students[0], request, course, keep_raw_scores=get_raw_scores)
# log.debug('student {0} gradeset {1}'.format(enrolled_students[0], gradeset)) # log.debug('student {0} gradeset {1}'.format(enrolled_students[0], gradeset))
...@@ -409,6 +413,29 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True, ...@@ -409,6 +413,29 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True,
return datatable return datatable
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def staff_grading(request, course_id):
"""
Show the instructor grading interface.
"""
course = get_course_with_access(request.user, course_id, 'staff')
grading = StaffGrading(course)
ajax_url = reverse('staff_grading', kwargs={'course_id': course_id})
if not ajax_url.endswith('/'):
ajax_url += '/'
return render_to_response('instructor/staff_grading.html', {
'view_html': grading.get_html(),
'course': course,
'course_id': course_id,
'ajax_url': ajax_url,
# Checked above
'staff_access': True, })
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
def gradebook(request, course_id): def gradebook(request, course_id):
""" """
......
...@@ -76,5 +76,8 @@ DATABASES = AUTH_TOKENS['DATABASES'] ...@@ -76,5 +76,8 @@ DATABASES = AUTH_TOKENS['DATABASES']
XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE'] XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE']
STAFF_GRADING_INTERFACE = AUTH_TOKENS.get('STAFF_GRADING_INTERFACE')
PEARSON_TEST_USER = "pearsontest" PEARSON_TEST_USER = "pearsontest"
PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD") PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD")
...@@ -322,6 +322,13 @@ WIKI_USE_BOOTSTRAP_SELECT_WIDGET = False ...@@ -322,6 +322,13 @@ WIKI_USE_BOOTSTRAP_SELECT_WIDGET = False
WIKI_LINK_LIVE_LOOKUPS = False WIKI_LINK_LIVE_LOOKUPS = False
WIKI_LINK_DEFAULT_LEVEL = 2 WIKI_LINK_DEFAULT_LEVEL = 2
################################# Staff grading config #####################
STAFF_GRADING_INTERFACE = None
# Used for testing, debugging
MOCK_STAFF_GRADING = False
################################# Jasmine ################################### ################################# Jasmine ###################################
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
...@@ -406,6 +413,9 @@ main_vendor_js = [ ...@@ -406,6 +413,9 @@ main_vendor_js = [
discussion_js = sorted(glob2.glob(PROJECT_ROOT / 'static/coffee/src/discussion/**/*.coffee')) discussion_js = sorted(glob2.glob(PROJECT_ROOT / 'static/coffee/src/discussion/**/*.coffee'))
staff_grading_js = sorted(glob2.glob(PROJECT_ROOT / 'static/coffee/src/staff_grading/**/*.coffee'))
# Load javascript from all of the available xmodules, and # Load javascript from all of the available xmodules, and
# prep it for use in pipeline js # prep it for use in pipeline js
from xmodule.x_module import XModuleDescriptor from xmodule.x_module import XModuleDescriptor
...@@ -468,7 +478,8 @@ with open(module_styles_path, 'w') as module_styles: ...@@ -468,7 +478,8 @@ with open(module_styles_path, 'w') as module_styles:
PIPELINE_JS = { PIPELINE_JS = {
'application': { 'application': {
# Application will contain all paths not in courseware_only_js # Application will contain all paths not in courseware_only_js or
# discussion_js or staff_grading_js
'source_filenames': [ 'source_filenames': [
pth.replace(COMMON_ROOT / 'static/', '') pth.replace(COMMON_ROOT / 'static/', '')
for pth for pth
...@@ -476,7 +487,9 @@ PIPELINE_JS = { ...@@ -476,7 +487,9 @@ PIPELINE_JS = {
] + [ ] + [
pth.replace(PROJECT_ROOT / 'static/', '') pth.replace(PROJECT_ROOT / 'static/', '')
for pth in sorted(glob2.glob(PROJECT_ROOT / 'static/coffee/src/**/*.coffee'))\ for pth in sorted(glob2.glob(PROJECT_ROOT / 'static/coffee/src/**/*.coffee'))\
if pth not in courseware_only_js and pth not in discussion_js if (pth not in courseware_only_js and
pth not in discussion_js and
pth not in staff_grading_js)
] + [ ] + [
'js/form.ext.js', 'js/form.ext.js',
'js/my_courses_dropdown.js', 'js/my_courses_dropdown.js',
...@@ -505,7 +518,12 @@ PIPELINE_JS = { ...@@ -505,7 +518,12 @@ PIPELINE_JS = {
'discussion' : { 'discussion' : {
'source_filenames': [pth.replace(PROJECT_ROOT / 'static/', '') for pth in discussion_js], 'source_filenames': [pth.replace(PROJECT_ROOT / 'static/', '') for pth in discussion_js],
'output_filename': 'js/discussion.js' 'output_filename': 'js/discussion.js'
},
'staff_grading' : {
'source_filenames': [pth.replace(PROJECT_ROOT / 'static/', '') for pth in staff_grading_js],
'output_filename': 'js/staff_grading.js'
} }
} }
PIPELINE_DISABLE_WRAPPER = True PIPELINE_DISABLE_WRAPPER = True
......
...@@ -39,7 +39,7 @@ DATABASES = { ...@@ -39,7 +39,7 @@ DATABASES = {
} }
CACHES = { CACHES = {
# This is the cache used for most things. # This is the cache used for most things.
# In staging/prod envs, the sessions also live here. # In staging/prod envs, the sessions also live here.
'default': { 'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
...@@ -102,7 +102,13 @@ SUBDOMAIN_BRANDING = { ...@@ -102,7 +102,13 @@ SUBDOMAIN_BRANDING = {
COMMENTS_SERVICE_KEY = "PUT_YOUR_API_KEY_HERE" COMMENTS_SERVICE_KEY = "PUT_YOUR_API_KEY_HERE"
################################# Staff grading config #####################
STAFF_GRADING_INTERFACE = {
'url': 'http://127.0.0.1:3033/staff_grading',
'username': 'lms',
'password': 'abcd',
}
################################ LMS Migration ################################# ################################ LMS Migration #################################
MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True
......
...@@ -65,6 +65,10 @@ XQUEUE_INTERFACE = { ...@@ -65,6 +65,10 @@ XQUEUE_INTERFACE = {
} }
XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds
# Don't rely on a real staff grading backend
MOCK_STAFF_GRADING = True
# TODO (cpennington): We need to figure out how envs/test.py can inject things # TODO (cpennington): We need to figure out how envs/test.py can inject things
# into common.py so that we don't have to repeat this sort of thing # into common.py so that we don't have to repeat this sort of thing
STATICFILES_DIRS = [ STATICFILES_DIRS = [
...@@ -99,7 +103,7 @@ DATABASES = { ...@@ -99,7 +103,7 @@ DATABASES = {
} }
CACHES = { CACHES = {
# This is the cache used for most things. # This is the cache used for most things.
# In staging/prod envs, the sessions also live here. # In staging/prod envs, the sessions also live here.
'default': { 'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
......
/* IE 6 & 7 */
/* Proper fixed width for dashboard in IE6 */
.dashboard #content {
*width: 768px;
}
.dashboard #content-main {
*width: 535px;
}
/* IE 6 ONLY */
/* Keep header from flowing off the page */
#container {
_position: static;
}
/* Put the right sidebars back on the page */
.colMS #content-related {
_margin-right: 0;
_margin-left: 10px;
_position: static;
}
/* Put the left sidebars back on the page */
.colSM #content-related {
_margin-right: 10px;
_margin-left: -115px;
_position: static;
}
.form-row {
_height: 1%;
}
/* Fix right margin for changelist filters in IE6 */
#changelist-filter ul {
_margin-right: -10px;
}
/* IE ignores min-height, but treats height as if it were min-height */
.change-list .filtered {
_height: 400px;
}
/* IE doesn't know alpha transparency in PNGs */
.inline-deletelink {
background: transparent url(../img/inline-delete-8bit.png) no-repeat;
}
/* IE7 doesn't support inline-block */
.change-list ul.toplinks li {
zoom: 1;
*display: inline;
}
\ No newline at end of file
# wrap everything in a class in case we want to use inside xmodules later
get_random_int: (min, max) ->
return Math.floor(Math.random() * (max - min + 1)) + min
# states
state_grading = "grading"
state_graded = "graded"
state_no_data = "no_data"
state_error = "error"
class StaffGradingBackend
constructor: (ajax_url, mock_backend) ->
@ajax_url = ajax_url
@mock_backend = mock_backend
if @mock_backend
@mock_cnt = 0
mock: (cmd, data) ->
# Return a mock response to cmd and data
# should take a location as an argument
if cmd == 'get_next'
@mock_cnt++
response =
success: true
problem_name: 'Problem 1'
num_left: 3
num_total: 5
prompt: 'This is a fake prompt'
submission: 'submission! ' + @mock_cnt
rubric: 'A rubric! ' + @mock_cnt
submission_id: @mock_cnt
max_score: 2 + @mock_cnt % 3
ml_error_info : 'ML accuracy info: ' + @mock_cnt
else if cmd == 'save_grade'
console.log("eval: #{data.score} pts, Feedback: #{data.feedback}")
response =
@mock('get_next', {})
# get_probblem_list
# sends in a course_id and a grader_id
# should get back a list of problem_ids, problem_names, num_left, num_total
else if cmd == 'get_problem_list'
response =
success: true
problem_list: [
{location: 'i4x://MITx/3.091x/problem/open_ended_demo', \
problem_name: "Problem 1", num_left: 3, num_total: 5},
{location: 'i4x://MITx/3.091x/problem/open_ended_demo', \
problem_name: "Problem 2", num_left: 1, num_total: 5}
]
else
response =
success: false
error: 'Unknown command ' + cmd
if @mock_cnt % 5 == 0
response =
success: true
message: 'No more submissions'
if @mock_cnt % 7 == 0
response =
success: false
error: 'An error for testing'
return response
post: (cmd, data, callback) ->
if @mock_backend
callback(@mock(cmd, data))
else
# TODO: replace with postWithPrefix when that's loaded
$.post(@ajax_url + cmd, data, callback)
class StaffGrading
constructor: (backend) ->
@backend = backend
# all the jquery selectors
@error_container = $('.error-container')
@message_container = $('.message-container')
@prompt_container = $('.prompt-container')
@prompt_wrapper = $('.prompt-wrapper')
@submission_container = $('.submission-container')
@submission_wrapper = $('.submission-wrapper')
@rubric_container = $('.rubric-container')
@rubric_wrapper = $('.rubric-wrapper')
@feedback_area = $('.feedback-area')
@score_selection_container = $('.score-selection-container')
@submit_button = $('.submit-button')
@ml_error_info_container = $('.ml-error-info-container')
# model state
@state = state_no_data
@submission_id = null
@prompt = ''
@submission = ''
@rubric = ''
@error_msg = ''
@message = ''
@max_score = 0
@ml_error_info= ''
@score = null
# action handlers
@submit_button.click @submit
# render intial state
@render_view()
# send initial request automatically
@get_next_submission()
setup_score_selection: =>
# first, get rid of all the old inputs, if any.
@score_selection_container.html('Choose score: ')
# Now create new labels and inputs for each possible score.
for score in [0..@max_score]
id = 'score-' + score
label = """<label for="#{id}">#{score}</label>"""
input = """
<input type="radio" name="score-selection" id="#{id}" value="#{score}"/>
""" # " fix broken parsing in emacs
@score_selection_container.append(input + label)
# And now hook up an event handler again
$("input[name='score-selection']").change @graded_callback
set_button_text: (text) =>
@submit_button.attr('value', text)
graded_callback: (event) =>
@score = event.target.value
@state = state_graded
@render_view()
ajax_callback: (response) =>
# always clear out errors and messages on transition.
@error_msg = ''
@message = ''
if response.success
if response.submission
@data_loaded(response.prompt, response.submission, response.rubric, response.submission_id, response.max_score, response.ml_error_info)
else
@no_more(response.message)
else
@error(response.error)
@render_view()
get_next_submission: () ->
@backend.post('get_next', {}, @ajax_callback)
submit_and_get_next: () ->
data =
score: @score
feedback: @feedback_area.val()
submission_id: @submission_id
@backend.post('save_grade', data, @ajax_callback)
error: (msg) ->
@error_msg = msg
@state = state_error
data_loaded: (prompt, submission, rubric, submission_id, max_score, ml_error_info) ->
@prompt = prompt
@submission = submission
@rubric = rubric
@submission_id = submission_id
@feedback_area.val('')
@max_score = max_score
@score = null
@ml_error_info=ml_error_info
@state = state_grading
if not @max_score?
@error("No max score specified for submission.")
no_more: (message) ->
@prompt = null
@submission = null
@rubric = null
@ml_error_info = null
@submission_id = null
@message = message
@score = null
@max_score = 0
@state = state_no_data
render_view: () ->
# make the view elements match the state. Idempotent.
show_grading_elements = false
show_submit_button = true
@message_container.html(@message)
if @backend.mock_backend
@message_container.append("<p>NOTE: Mocking backend.</p>")
@error_container.html(@error_msg)
if @state == state_error
@set_button_text('Try loading again')
else if @state == state_grading
@ml_error_info_container.html(@ml_error_info)
@prompt_container.html(@prompt)
@submission_container.html(@submission)
@rubric_container.html(@rubric)
show_grading_elements = true
# no submit button until user picks grade.
show_submit_button = false
@setup_score_selection()
else if @state == state_graded
show_grading_elements = true
@set_button_text('Submit')
else if @state == state_no_data
@message_container.html(@message)
@set_button_text('Re-check for submissions')
else
@error('System got into invalid state ' + @state)
@submit_button.toggle(show_submit_button)
@prompt_wrapper.toggle(show_grading_elements)
@submission_wrapper.toggle(show_grading_elements)
@rubric_wrapper.toggle(show_grading_elements)
@ml_error_info_container.toggle(show_grading_elements)
submit: (event) =>
event.preventDefault()
if @state == state_error
@get_next_submission()
else if @state == state_graded
@submit_and_get_next()
else if @state == state_no_data
@get_next_submission()
else
@error('System got into invalid state for submission: ' + @state)
# for now, just create an instance and load it...
mock_backend = false
ajax_url = $('.staff-grading').data('ajax_url')
backend = new StaffGradingBackend(ajax_url, mock_backend)
$(document).ready(() -> new StaffGrading(backend))
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML//EN">
<html> <head>
<title></title>
<!-- <script src="http://code.jquery.com/jquery-latest.js"></script> -->
<script src="../../../admin/js/jquery.min.js"></script>
<script type="text/javascript" src="staff_grading.js"></script>
</head>
<body>
<div class="staff-grading" data-ajax_url="/some_url/">
<h1>Staff grading</h1>
<div class="error-container">
</div>
<div class="message-container">
</div>
<section class="submission-wrapper">
<h3>Submission</h3>
<div class="submission-container">
</div>
</section>
<section class="rubric-wrapper">
<h3>Rubric</h3>
<div class="rubric-container">
</div>
<div class="evaluation">
<textarea name="feedback" placeholder="Feedback for student..."
class="feedback-area" cols="70" rows="10"></textarea>
<p class="score-selection-container">
</p>
</div>
</section>
<div class="submission">
<input type="button" value="Submit" class="submit-button" name="show"/>
</div>
</div>
</body> </html>
...@@ -43,6 +43,7 @@ ...@@ -43,6 +43,7 @@
@import "course/profile"; @import "course/profile";
@import "course/gradebook"; @import "course/gradebook";
@import "course/tabs"; @import "course/tabs";
@import "course/staff_grading";
// instructor // instructor
@import "course/instructor/instructor"; @import "course/instructor/instructor";
......
div.staff-grading {
textarea.feedback-area {
height: 100px;
margin: 20px;
}
div {
margin: 10px;
}
label {
margin: 10px;
padding: 5px;
display: inline-block;
min-width: 50px;
background-color: #CCC;
text-size: 1.5em;
}
/* Toggled State */
input[type=radio]:checked + label {
background: #666;
color: white;
}
input[name='score-selection'] {
display: none;
}
}
<%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} Staff Grading</title></%block>
<%include file="/courseware/course_navigation.html" args="active_page='staff_grading'" />
<%block name="js_extra">
<%static:js group='staff_grading'/>
</%block>
<section class="container">
<div class="staff-grading" data-ajax_url="${ajax_url}">
<h1>Staff grading</h1>
<div class="error-container">
</div>
<div class="message-container">
</div>
<div class="ml-error-info-container">
</div>
<section class="prompt-wrapper">
<h3>Question prompt</h3>
<div class="prompt-container">
</div>
</section>
<section class="submission-wrapper">
<h3>Submission</h3>
<div class="submission-container">
</div>
</section>
<section class="rubric-wrapper">
<h3>Rubric</h3>
<div class="rubric-container">
</div>
<div class="evaluation">
<textarea name="feedback" placeholder="Feedback for student..."
class="feedback-area" cols="70" rows="10"></textarea>
<p class="score-selection-container">
</p>
</div>
</section>
<div class="submission">
<input type="button" value="Submit" class="submit-button" name="show"/>
</div>
</div>
</section>
<section>
<div class="shortform">
<div class="result-errors">
There was an error with your submission. Please contact course staff.
</div>
</div>
<div class="longform">
<div class="result-errors">
${errors}
</div>
</div>
</section>
\ No newline at end of file
<section>
<header>Feedback</header>
<div class="shortform">
<div class="result-output">
<p>Score: ${score}</p>
% if grader_type == "ML":
<p>Check below for full feedback:</p>
% endif
</div>
</div>
<div class="longform">
<div class="result-output">
${ feedback | n}
</div>
</div>
</section>
\ No newline at end of file
...@@ -165,7 +165,7 @@ if settings.COURSEWARE_ENABLED: ...@@ -165,7 +165,7 @@ if settings.COURSEWARE_ENABLED:
# input types system so that previews can be context-specific. # input types system so that previews can be context-specific.
# Unfortunately, we don't have time to think through the right way to do # Unfortunately, we don't have time to think through the right way to do
# that (and implement it), and it's not a terrible thing to provide a # that (and implement it), and it's not a terrible thing to provide a
# generic chemican-equation rendering service. # generic chemical-equation rendering service.
url(r'^preview/chemcalc', 'courseware.module_render.preview_chemcalc', url(r'^preview/chemcalc', 'courseware.module_render.preview_chemcalc',
name='preview_chemcalc'), name='preview_chemcalc'),
...@@ -234,6 +234,12 @@ if settings.COURSEWARE_ENABLED: ...@@ -234,6 +234,12 @@ if settings.COURSEWARE_ENABLED:
'instructor.views.grade_summary', name='grade_summary'), 'instructor.views.grade_summary', name='grade_summary'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/enroll_students$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/enroll_students$',
'instructor.views.enroll_students', name='enroll_students'), 'instructor.views.enroll_students', name='enroll_students'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/staff_grading$',
'instructor.views.staff_grading', name='staff_grading'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/staff_grading/get_next$',
'instructor.staff_grading_service.get_next', name='staff_grading_get_next'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/staff_grading/save_grade$',
'instructor.staff_grading_service.save_grade', name='staff_grading_save_grade'),
) )
# discussion forums live within courseware, so courseware must be enabled first # discussion forums live within courseware, so courseware must be enabled first
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment