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,
3. Add the migration file created in mitx/common/djangoapps/student/migrations/
"""
from datetime import datetime
from hashlib import sha1
import hashlib
import json
import logging
import uuid
......@@ -197,14 +197,13 @@ def unique_id_for_user(user):
"""
Return a unique id for a user, suitable for inserting into
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
# in the db.
salt = str(user.id)
return sha1(salt + user.username).hexdigest()
# include the secret key as a salt, and to make the ids unique accross
# different LMS installs.
h = hashlib.md5()
h.update(settings.SECRET_KEY)
h.update(str(user.id))
return h.hexdigest()
## TODO: Should be renamed to generic UserGroup, and possibly
......
......@@ -4,6 +4,11 @@ import json
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)
def expect_json_with_cloned_request(request, *args, **kwargs):
# 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__])
solution_tags = ['solution']
# 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
html_transforms = {'problem': {'tag': 'div'},
......@@ -72,7 +72,7 @@ global_context = {'random': random,
'miller': chem.miller}
# 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__)
......
......@@ -733,3 +733,53 @@ class ChemicalEquationInput(InputTypeBase):
return {'previewer': '/static/js/capa/chemical_equation_preview.js',}
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):
Duck typing to check if 'file_to_test' is a File object
'''
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):
return_code = xreply['return_code']
content = xreply['content']
return (return_code, content)
......@@ -80,7 +81,11 @@ class XQueueInterface(object):
# Log in, then try again
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:
# Need to rewind file pointers
for f in files_to_upload:
......
......@@ -146,6 +146,11 @@ class CapaModule(XModule):
else:
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:
# TODO (vshnayder): move as much as possible of this work and error
# checking to descriptor load time
......
......@@ -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 {
p.status {
@include inline-block();
......@@ -266,6 +256,11 @@ section.problem {
margin: -7px 7px 0 0;
}
.grading {
text-indent: 0px;
margin: 0px 7px 0 0;
}
p {
line-height: 20px;
text-transform: capitalize;
......@@ -685,6 +680,21 @@ section.problem {
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):
'''
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):
'''Find all locations that are the parents of this location. Needed
for path_to_location().
......@@ -399,3 +405,10 @@ class ModuleStoreBase(ModuleStore):
errorlog = self._get_errorlog(location)
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):
def save_answer(self, get):
"""
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
if self.attempts > self.max_attempts:
......
......@@ -10,7 +10,7 @@ from xmodule.progress import Progress
from xmodule.exceptions import NotFoundError
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
# OBSOLETE: This obsoletes 'type'
......
......@@ -809,7 +809,8 @@ class ModuleSystem(object):
debug=False,
xqueue=None,
node_path="",
anonymous_student_id=''):
anonymous_student_id='',
course_id=None):
'''
Create a closure around the system environment.
......@@ -844,6 +845,8 @@ class ModuleSystem(object):
ajax results.
anonymous_student_id - Used for tracking modules with student id
course_id - the course_id containing this module
'''
self.ajax_url = ajax_url
self.xqueue = xqueue
......@@ -856,6 +859,7 @@ class ModuleSystem(object):
self.replace_urls = replace_urls
self.node_path = node_path
self.anonymous_student_id = anonymous_student_id
self.course_id = course_id
self.user_is_staff = user is not None and user.is_staff
def get(self, attr):
......
......@@ -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
## 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
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
* "external_link". Parameters "name", "link".
* "textbooks". No parameters--generates tab names from book titles.
* "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
......@@ -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.
* 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 vertical 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 sequential 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)
......
......@@ -34,7 +34,8 @@ def has_access(user, obj, action):
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.
......
import hashlib
import json
import logging
import pyparsing
......@@ -20,6 +19,7 @@ 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
from xmodule.modulestore import Location
......@@ -152,12 +152,6 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
if not has_access(user, descriptor, 'load'):
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
instance_module = None
shared_module = None
......@@ -230,7 +224,8 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
# by the replace_static_urls code below
replace_urls=replace_urls,
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
system.set('position', position)
......
......@@ -36,7 +36,7 @@ CourseTab = namedtuple('CourseTab', 'name link is_active')
# wrong. (e.g. "is there a 'name' field?). Validators can assume
# 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
# 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
......@@ -97,6 +97,14 @@ def _textbooks(tab, user, course, active_page):
for index, textbook in enumerate(course.textbooks)]
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
......@@ -132,6 +140,7 @@ VALID_TAB_TYPES = {
'textbooks': TabImpl(null_validator, _textbooks),
'progress': TabImpl(need_name, _progress),
'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab),
'staff_grading': TabImpl(null_validator, _staff_grading),
}
......
......@@ -193,13 +193,27 @@ class PageLoader(ActivateLoginTestCase):
def check_for_get_code(self, code, url):
"""
Check that we got the expected code. Hacks around our broken 404
handling.
Check that we got the expected code when accessing url via GET.
Returns the response.
"""
resp = self.client.get(url)
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_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):
......@@ -286,14 +300,10 @@ class TestNavigation(PageLoader):
def setUp(self):
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")
self.toy = find_course("edX/toy/2012_Fall")
# Assume courses are there
self.full = modulestore().get_course("edX/full/6.002_Spring_2012")
self.toy = modulestore().get_course("edX/toy/2012_Fall")
# Create two accounts
self.student = 'view@test.com'
......@@ -344,14 +354,9 @@ class TestViewAuth(PageLoader):
def setUp(self):
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")
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
self.student = 'view@test.com'
......@@ -667,7 +672,7 @@ class TestCourseGrader(PageLoader):
def check_grade_percent(self, percent):
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):
"""
......
"""
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:
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 django.contrib.auth.models import \
Group # Need access to internal func to put users in the right 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_comment_client.models import Role, FORUM_ROLE_ADMINISTRATOR, \
FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT
from django_comment_client.utils import has_forum_access
from instructor import staff_grading_service
from courseware.access import _course_staff_group_name
import courseware.tests.tests as ct
from xmodule.modulestore.django import modulestore
......@@ -31,14 +40,9 @@ class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader):
def setUp(self):
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.toy = find_course("toy")
self.full = modulestore().get_course("edX/full/6.002_Spring_2012")
self.toy = modulestore().get_course("edX/toy/2012_Fall")
# Create two accounts
self.student = 'view@test.com'
......@@ -49,10 +53,13 @@ class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader):
self.activate_user(self.student)
self.activate_user(self.instructor)
group_name = _course_staff_group_name(self.toy.location)
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.logout()
self.login(self.instructor, self.password)
self.enroll(self.toy)
......@@ -67,18 +74,21 @@ class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader):
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?
msg += "cdisp = '{0}'\n".format(cdisp)
self.assertEqual(cdisp,'attachment; filename=grades_edX/toy/2012_Fall.csv',msg)
cdisp = response['Content-Disposition']
msg += "Content-Disposition = '%s'\n" % cdisp
self.assertEqual(cdisp, 'attachment; filename=grades_{0}.csv'.format(course.id), msg)
body = response.content.replace('\r','')
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"
"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)
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_USER = { FORUM_ROLE_ADMINISTRATOR : 'forumadmin', FORUM_ROLE_MODERATOR : 'forummoderator', FORUM_ROLE_COMMUNITY_TA : 'forummoderator'}
......@@ -89,6 +99,9 @@ def action_name(operation, rolename):
else:
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)
class TestInstructorDashboardForumAdmin(ct.PageLoader):
'''
......@@ -99,12 +112,9 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader):
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.toy = find_course("toy")
self.course_id = "edX/toy/2012_Fall"
self.toy = modulestore().get_course(self.course_id)
# Create two accounts
self.student = 'view@test.com'
......@@ -123,6 +133,8 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader):
self.login(self.instructor, self.password)
self.enroll(self.toy)
def initialize_roles(self, course_id):
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]
......@@ -209,3 +221,74 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader):
added_roles.sort()
roles = ', '.join(added_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
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
from mitxmako.shortcuts import render_to_response
from django.core.urlresolvers import reverse
from courseware import grades
from courseware.access import has_access, get_access_group_name
......@@ -27,7 +28,10 @@ from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundErr
from xmodule.modulestore.search import path_to_location
import track.views
log = logging.getLogger("mitx.courseware")
from .grading import StaffGrading
log = logging.getLogger(__name__)
template_imports = {'urllib': urllib}
......@@ -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')
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
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))
......@@ -409,6 +413,29 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True,
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)
def gradebook(request, course_id):
"""
......
......@@ -76,5 +76,8 @@ DATABASES = AUTH_TOKENS['DATABASES']
XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE']
STAFF_GRADING_INTERFACE = AUTH_TOKENS.get('STAFF_GRADING_INTERFACE')
PEARSON_TEST_USER = "pearsontest"
PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD")
......@@ -322,6 +322,13 @@ WIKI_USE_BOOTSTRAP_SELECT_WIDGET = False
WIKI_LINK_LIVE_LOOKUPS = False
WIKI_LINK_DEFAULT_LEVEL = 2
################################# Staff grading config #####################
STAFF_GRADING_INTERFACE = None
# Used for testing, debugging
MOCK_STAFF_GRADING = False
################################# Jasmine ###################################
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
......@@ -406,6 +413,9 @@ main_vendor_js = [
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
# prep it for use in pipeline js
from xmodule.x_module import XModuleDescriptor
......@@ -468,7 +478,8 @@ with open(module_styles_path, 'w') as module_styles:
PIPELINE_JS = {
'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': [
pth.replace(COMMON_ROOT / 'static/', '')
for pth
......@@ -476,7 +487,9 @@ PIPELINE_JS = {
] + [
pth.replace(PROJECT_ROOT / 'static/', '')
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/my_courses_dropdown.js',
......@@ -505,7 +518,12 @@ PIPELINE_JS = {
'discussion' : {
'source_filenames': [pth.replace(PROJECT_ROOT / 'static/', '') for pth in 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
......
......@@ -102,7 +102,13 @@ SUBDOMAIN_BRANDING = {
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 #################################
MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True
......
......@@ -65,6 +65,10 @@ XQUEUE_INTERFACE = {
}
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
# into common.py so that we don't have to repeat this sort of thing
STATICFILES_DIRS = [
......
/* 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 @@
@import "course/profile";
@import "course/gradebook";
@import "course/tabs";
@import "course/staff_grading";
// 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:
# input types system so that previews can be context-specific.
# 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
# generic chemican-equation rendering service.
# generic chemical-equation rendering service.
url(r'^preview/chemcalc', 'courseware.module_render.preview_chemcalc',
name='preview_chemcalc'),
......@@ -234,6 +234,12 @@ if settings.COURSEWARE_ENABLED:
'instructor.views.grade_summary', name='grade_summary'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/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
......
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