staff_grading_service.py 15.7 KB
Newer Older
1 2 3 4 5
"""
This module provides views that proxy to the staff grading backend service.
"""

import json
6
import logging
7

8
from django.conf import settings
9
from django.http import HttpResponse, Http404
10
from django.utils.translation import ugettext as _
11

12
from xmodule.course_module import CourseDescriptor
Calen Pennington committed
13
from xmodule.open_ended_grading_classes.grading_service_module import GradingService, GradingServiceError
14
from xmodule.modulestore.django import ModuleI18nService
Calen Pennington committed
15 16

from courseware.access import has_access
17
from lms.lib.xblock.runtime import LmsModuleSystem
David Baumgold committed
18
from edxmako.shortcuts import render_to_string
Calen Pennington committed
19 20 21 22
from student.models import unique_id_for_user
from util.json_request import expect_json

from open_ended_grading.utils import does_location_exist
23
from dogapi import dog_stats_api
24

Victor Shnayder committed
25
log = logging.getLogger(__name__)
26

27 28 29 30 31 32 33 34
STAFF_ERROR_MESSAGE = _(
    u'Could not contact the external grading server. Please contact the '
    u'development team at {email}.'
).format(
    email=u'<a href="mailto:{tech_support_email}>{tech_support_email}</a>'.format(
        tech_support_email=settings.TECH_SUPPORT_EMAIL
    )
)
35
MAX_ALLOWED_FEEDBACK_LENGTH = 5000
Calen Pennington committed
36

Vik Paruchuri committed
37

38 39 40 41
class MockStaffGradingService(object):
    """
    A simple mockup of a staff grading service, testing.
    """
Vik Paruchuri committed
42

43 44 45
    def __init__(self):
        self.cnt = 0

Calen Pennington committed
46
    def get_next(self, course_id, location, grader_id):
47
        self.cnt += 1
48 49 50 51 52 53 54 55 56 57
        return {'success': True,
                'submission_id': self.cnt,
                'submission': 'Test submission {cnt}'.format(cnt=self.cnt),
                'num_graded': 3,
                'min_for_ml': 5,
                'num_pending': 4,
                'prompt': 'This is a fake prompt',
                'ml_error_info': 'ML info',
                'max_score': 2 + self.cnt % 3,
                'rubric': 'A rubric'}
58

Diana Huang committed
59 60
    def get_problem_list(self, course_id, grader_id):
        self.cnt += 1
61 62 63 64 65 66 67 68 69
        return {'success': True,
                 'problem_list': [
                     json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo1',
                                 'problem_name': "Problem 1", 'num_graded': 3, 'num_pending': 5,
                                 'min_for_ml': 10}),
                     json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo2',
                                 'problem_name': "Problem 2", 'num_graded': 1, 'num_pending': 5,
                                 'min_for_ml': 10})
                 ]}
Vik Paruchuri committed
70 71 72

    def save_grade(self, course_id, grader_id, submission_id, score, feedback, skipped, rubric_scores,
                   submission_flagged):
73
        return self.get_next(course_id, 'fake location', grader_id)
74 75


76
class StaffGradingService(GradingService):
77 78 79
    """
    Interface to staff grading backend.
    """
Vik Paruchuri committed
80

81 82
    METRIC_NAME = 'edxapp.open_ended_grading.staff_grading_service'

83
    def __init__(self, config):
84
        config['system'] = LmsModuleSystem(
85
            static_url='/static',
86
            track_function=None,
87
            get_module=None,
88 89
            render_template=render_to_string,
            replace_urls=None,
90
            descriptor_runtime=None,
91 92 93
            services={
                'i18n': ModuleI18nService(),
            },
94
        )
95
        super(StaffGradingService, self).__init__(config)
Vik Paruchuri committed
96 97
        self.url = config['url'] + config['staff_grading']
        self.login_url = self.url + '/login/'
98 99
        self.get_next_url = self.url + '/get_next_submission/'
        self.save_grade_url = self.url + '/save_grade/'
100
        self.get_problem_list_url = self.url + '/get_problem_list/'
101
        self.get_notifications_url = self.url + "/get_notifications/"
102

103 104 105 106 107 108 109 110 111
    def get_problem_list(self, course_id, grader_id):
        """
        Get the list of problems for a given course.

        Args:
            course_id: course id that we want the problems of
            grader_id: who is grading this?  The anonymous user_id of the grader.

        Returns:
112
            dict with the response from the service.  (Deliberately not
113 114 115 116 117 118
            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.
        """
Calen Pennington committed
119
        params = {'course_id': course_id, 'grader_id': grader_id}
120
        result = self.get(self.get_problem_list_url, params)
121 122 123 124 125 126 127
        tags = [u'course_id:{}'.format(course_id)]
        self._record_result('get_problem_list', result, tags)
        dog_stats_api.histogram(
            self._metric_name('get_problem_list.result.length'),
            len(result.get('problem_list', []))
        )
        return result
128

129
    def get_next(self, course_id, location, grader_id):
130
        """
131 132 133
        Get the next thing to grade.

        Args:
134 135 136
            course_id: the course that this problem belongs to
            location: location of the problem that we are grading and would like the
                next submission for
137 138 139
            grader_id: who is grading this?  The anonymous user_id of the grader.

        Returns:
140
            dict with the response from the service.  (Deliberately not
141 142 143 144 145
            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.
146
        """
147 148 149 150 151 152 153 154 155
        result = self._render_rubric(
            self.get(
                self.get_next_url,
                params={
                    'location': location,
                    'grader_id': grader_id
                }
            )
        )
156 157 158
        tags = [u'course_id:{}'.format(course_id)]
        self._record_result('get_next', result, tags)
        return result
159

Vik Paruchuri committed
160 161
    def save_grade(self, course_id, grader_id, submission_id, score, feedback, skipped, rubric_scores,
                   submission_flagged):
162
        """
163
        Save a score and feedback for a submission.
164

165
        Returns:
166
            dict with keys
167 168
                'success': bool
                'error': error msg, if something went wrong.
169

170 171
        Raises:
            GradingServiceError if there's a problem connecting.
172
        """
173 174 175 176 177
        data = {'course_id': course_id,
                'submission_id': submission_id,
                'score': score,
                'feedback': feedback,
                'grader_id': grader_id,
178
                'skipped': skipped,
179
                'rubric_scores': rubric_scores,
180 181
                'rubric_scores_complete': True,
                'submission_flagged': submission_flagged}
182

183
        result = self._render_rubric(self.post(self.save_grade_url, data=data))
184 185 186
        tags = [u'course_id:{}'.format(course_id)]
        self._record_result('save_grade', result, tags)
        return result
187

188 189
    def get_notifications(self, course_id):
        params = {'course_id': course_id}
190
        result = self.get(self.get_notifications_url, params)
191 192 193 194 195 196
        tags = [
            u'course_id:{}'.format(course_id),
            u'staff_needs_to_grade:{}'.format(result.get('staff_needs_to_grade'))
        ]
        self._record_result('get_notifications', result, tags)
        return result
197 198


199
# don't initialize until staff_grading_service() is called--means that just
Victor Shnayder committed
200 201 202
# importing this file doesn't create objects that may not have the right config
_service = None

203

204
def staff_grading_service():
Victor Shnayder committed
205 206 207 208 209 210 211 212 213 214 215 216 217 218
    """
    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:
219
        _service = StaffGradingService(settings.OPEN_ENDED_GRADING_INTERFACE)
Victor Shnayder committed
220 221

    return _service
222

Calen Pennington committed
223

224 225 226 227
def _err_response(msg):
    """
    Return a HttpResponse with a json dump with success=False, and the given error message.
    """
228 229
    return HttpResponse(json.dumps({'success': False, 'error': msg}),
                        mimetype="application/json")
230 231 232 233 234 235 236 237 238 239 240 241


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

242 243 244

def get_next(request, course_id):
    """
245
    Get the next thing to grade for course_id and with the location specified
Diana Huang committed
246
    in the request.
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265

    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)

266
    required = set(['location'])
267 268 269 270
    if request.method != 'POST':
        raise Http404
    actual = set(request.POST.keys())
    missing = required - actual
271
    if len(missing) > 0:
272 273
        return _err_response('Missing required keys {0}'.format(
            ', '.join(missing)))
274
    grader_id = unique_id_for_user(request.user)
275
    p = request.POST
276
    location = p['location']
277

278
    return HttpResponse(json.dumps(_get_next(course_id, grader_id, location)),
279
                        mimetype="application/json")
280

281
def get_problem_list(request, course_id):
282
    """
283
    Get all the problems for the given course id
Diana Huang committed
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301

    Returns a json dict with the following keys:
        success: bool

        problem_list: a list containing json dicts with the following keys:
            each dict represents a different problem in the course

            location: the location of the problem

            problem_name: the name of the problem

            num_graded: the number of responses that have been graded

            num_pending: the number of responses that are sitting in the queue

            min_for_ml: the number of responses that need to be graded before
                the ml can be run

302
        'error': if success is False, will have an error message with more info.
303
    """
304 305
    _check_access(request.user, course_id)
    try:
306
        response = staff_grading_service().get_problem_list(course_id, unique_id_for_user(request.user))
307 308 309 310 311 312 313 314

        # If 'problem_list' is in the response, then we got a list of problems from the ORA server.
        # If it is not, then ORA could not find any problems.
        if 'problem_list' in response:
            problem_list = response['problem_list']
        else:
            problem_list = []
            # Make an error messages to reflect that we could not find anything to grade.
315 316 317 318 319
            response['error'] = _(
                u'Cannot find any open response problems in this course. '
                u'Have you submitted answers to any open response assessment questions? '
                u'If not, please do so and return to this page.'
            )
320 321
        valid_problem_list = []
        for i in xrange(0,len(problem_list)):
322
            # Needed to ensure that the 'location' key can be accessed.
Vik Paruchuri committed
323 324 325 326
            try:
                problem_list[i] = json.loads(problem_list[i])
            except Exception:
                pass
327 328 329 330 331
            if does_location_exist(course_id, problem_list[i]['location']):
                valid_problem_list.append(problem_list[i])
        response['problem_list'] = valid_problem_list
        response = json.dumps(response)

332
        return HttpResponse(response,
Vik Paruchuri committed
333
                            mimetype="application/json")
334
    except GradingServiceError:
335
        #This is a dev_facing_error
336 337 338 339
        log.exception(
            "Error from staff grading service in open "
            "ended grading.  server url: {0}".format(staff_grading_service().url)
        )
340
        #This is a staff_facing_error
341
        return HttpResponse(json.dumps({'success': False,
Vik Paruchuri committed
342
                                        'error': STAFF_ERROR_MESSAGE}))
343

344 345 346 347 348

def _get_next(course_id, grader_id, location):
    """
    Implementation of get_next (also called from save_grade) -- returns a json string
    """
349
    try:
350
        return staff_grading_service().get_next(course_id, location, grader_id)
351
    except GradingServiceError:
352
        #This is a dev facing error
353 354 355 356
        log.exception(
            "Error from staff grading service in open "
            "ended grading.  server url: {0}".format(staff_grading_service().url)
        )
357
        #This is a staff_facing_error
358
        return json.dumps({'success': False,
359
                           'error': STAFF_ERROR_MESSAGE})
360 361 362 363


def save_grade(request, course_id):
    """
364 365
    Save the grade and feedback for a submission, and, if all goes well, return
    the next thing to grade.
366

367 368 369 370 371 372 373
    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.
374
    """
375 376 377 378
    _check_access(request.user, course_id)

    if request.method != 'POST':
        raise Http404
379 380 381
    p = request.POST
    required = set(['score', 'feedback', 'submission_id', 'location', 'submission_flagged'])
    skipped = 'skipped' in p
Vik Paruchuri committed
382 383
    #If the instructor has skipped grading the submission, then there will not be any rubric scores.
    #Only add in the rubric scores if the instructor has not skipped.
384
    if not skipped:
385
        required.add('rubric_scores[]')
386
    actual = set(p.keys())
387
    missing = required - actual
388
    if len(missing) > 0:
389 390
        return _err_response('Missing required keys {0}'.format(
            ', '.join(missing)))
391

392 393 394
    success, message = check_feedback_length(p)
    if not success:
        return _err_response(message)
395

396
    grader_id = unique_id_for_user(request.user)
397

398
    location = p['location']
399

400
    try:
401 402 403 404 405 406 407 408
        result = staff_grading_service().save_grade(course_id,
                                                    grader_id,
                                                    p['submission_id'],
                                                    p['score'],
                                                    p['feedback'],
                                                    skipped,
                                                    p.getlist('rubric_scores[]'),
                                                    p['submission_flagged'])
409
    except GradingServiceError:
410
        #This is a dev_facing_error
Vik Paruchuri committed
411 412 413
        log.exception(
            "Error saving grade in the staff grading interface in open ended grading.  Request: {0} Course ID: {1}".format(
                request, course_id))
414 415
        #This is a staff_facing_error
        return _err_response(STAFF_ERROR_MESSAGE)
416
    except ValueError:
417
        #This is a dev_facing_error
Vik Paruchuri committed
418 419 420
        log.exception(
            "save_grade returned broken json in the staff grading interface in open ended grading: {0}".format(
                result_json))
421 422
        #This is a staff_facing_error
        return _err_response(STAFF_ERROR_MESSAGE)
423 424

    if not result.get('success', False):
425
        #This is a dev_facing_error
Vik Paruchuri committed
426 427
        log.warning(
            'Got success=False from staff grading service in open ended grading.  Response: {0}'.format(result_json))
428
        return _err_response(STAFF_ERROR_MESSAGE)
429 430

    # Ok, save_grade seemed to work.  Get the next submission to grade.
431
    return HttpResponse(json.dumps(_get_next(course_id, grader_id, location)),
432
                        mimetype="application/json")
433 434 435 436 437 438 439 440 441 442


def check_feedback_length(data):
    feedback = data.get("feedback")
    if feedback and len(feedback) > MAX_ALLOWED_FEEDBACK_LENGTH:
        return False, "Feedback is too long, Max length is {0} characters.".format(
            MAX_ALLOWED_FEEDBACK_LENGTH
        )
    else:
        return True, ""