staff_grading_service.py 16.2 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 opaque_keys.edx.locations import SlashSeparatedCourseKey
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
from student.models import unique_id_for_user

from open_ended_grading.utils import does_location_exist
22
import dogstats_wrapper as dog_stats_api
23

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

26 27 28 29 30 31 32 33
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
    )
)
34
MAX_ALLOWED_FEEDBACK_LENGTH = 5000
Calen Pennington committed
35

Vik Paruchuri committed
36

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

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

Calen Pennington committed
45
    def get_next(self, course_id, location, grader_id):
46
        self.cnt += 1
47 48 49 50 51 52 53 54 55 56
        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'}
57

Diana Huang committed
58 59
    def get_problem_list(self, course_id, grader_id):
        self.cnt += 1
60 61 62 63 64 65 66 67 68
        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
69 70 71

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


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

80 81
    METRIC_NAME = 'edxapp.open_ended_grading.staff_grading_service'

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

102 103 104 105 106 107 108 109 110
    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:
111
            dict with the response from the service.  (Deliberately not
112 113 114 115 116 117
            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.
        """
118
        params = {'course_id': course_id.to_deprecated_string(), 'grader_id': grader_id}
119
        result = self.get(self.get_problem_list_url, params)
120 121 122 123 124 125 126
        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
127

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

        Args:
133 134 135
            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
136 137 138
            grader_id: who is grading this?  The anonymous user_id of the grader.

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

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

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

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

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

187
    def get_notifications(self, course_id):
188
        params = {'course_id': course_id.to_deprecated_string()}
189
        result = self.get(self.get_notifications_url, params)
190 191 192 193 194 195
        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
196 197


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

202

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

    return _service
221

Calen Pennington committed
222

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


def _check_access(user, course_id):
    """
    Raise 404 if user doesn't have staff access to course_id
    """
235
    if not has_access(user, 'staff', course_id):
236 237 238 239
        raise Http404

    return

240 241 242

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

    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.
    """
262 263 264
    assert(isinstance(course_id, basestring))
    course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    _check_access(request.user, course_key)
265

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 = course_key.make_usage_key_from_deprecated_string(p['location'])
277

278
    return HttpResponse(json.dumps(_get_next(course_key, 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 306
    assert(isinstance(course_id, basestring))
    course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    _check_access(request.user, course_key)
307
    try:
308
        response = staff_grading_service().get_problem_list(course_key, unique_id_for_user(request.user))
309 310 311 312 313 314 315 316

        # 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.
317 318 319 320 321
            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.'
            )
322 323
        valid_problem_list = []
        for i in xrange(0,len(problem_list)):
324
            # Needed to ensure that the 'location' key can be accessed.
Vik Paruchuri committed
325 326 327 328
            try:
                problem_list[i] = json.loads(problem_list[i])
            except Exception:
                pass
329
            if does_location_exist(course_key.make_usage_key_from_deprecated_string(problem_list[i]['location'])):
330 331 332 333
                valid_problem_list.append(problem_list[i])
        response['problem_list'] = valid_problem_list
        response = json.dumps(response)

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

346 347 348 349 350

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


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

369 370 371 372 373 374 375
    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.
376
    """
377 378 379

    course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    _check_access(request.user, course_key)
380 381 382

    if request.method != 'POST':
        raise Http404
383 384 385
    p = request.POST
    required = set(['score', 'feedback', 'submission_id', 'location', 'submission_flagged'])
    skipped = 'skipped' in p
Vik Paruchuri committed
386 387
    #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.
388
    if not skipped:
389
        required.add('rubric_scores[]')
390
    actual = set(p.keys())
391
    missing = required - actual
392
    if len(missing) > 0:
393 394
        return _err_response('Missing required keys {0}'.format(
            ', '.join(missing)))
395

396 397 398
    success, message = check_feedback_length(p)
    if not success:
        return _err_response(message)
399

400
    grader_id = unique_id_for_user(request.user)
401

402
    location = course_key.make_usage_key_from_deprecated_string(p['location'])
403

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

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

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


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, ""