staff_grading_service.py 15.9 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 14 15
from xmodule.open_ended_grading_classes.grading_service_module import GradingService, GradingServiceError

from courseware.access import has_access
David Baumgold committed
16
from edxmako.shortcuts import render_to_string
Calen Pennington committed
17 18 19
from student.models import unique_id_for_user

from open_ended_grading.utils import does_location_exist
20
import dogstats_wrapper as dog_stats_api
21

Victor Shnayder committed
22
log = logging.getLogger(__name__)
23

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

Vik Paruchuri committed
34

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

40 41 42
    def __init__(self):
        self.cnt = 0

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

Diana Huang committed
56 57
    def get_problem_list(self, course_id, grader_id):
        self.cnt += 1
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
        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
77 78 79

    def save_grade(self, course_id, grader_id, submission_id, score, feedback, skipped, rubric_scores,
                   submission_flagged):
80
        return self.get_next(course_id, 'fake location', grader_id)
81 82


83
class StaffGradingService(GradingService):
84 85 86
    """
    Interface to staff grading backend.
    """
Vik Paruchuri committed
87

88 89
    METRIC_NAME = 'edxapp.open_ended_grading.staff_grading_service'

90
    def __init__(self, config):
91
        config['render_template'] = render_to_string
92
        super(StaffGradingService, self).__init__(config)
Vik Paruchuri committed
93 94
        self.url = config['url'] + config['staff_grading']
        self.login_url = self.url + '/login/'
95 96
        self.get_next_url = self.url + '/get_next_submission/'
        self.save_grade_url = self.url + '/save_grade/'
97
        self.get_problem_list_url = self.url + '/get_problem_list/'
98
        self.get_notifications_url = self.url + "/get_notifications/"
99

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

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

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

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

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

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

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

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

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


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

200

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

    return _service
219

Calen Pennington committed
220

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


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

    return

238 239 240

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

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

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

276
    return HttpResponse(json.dumps(_get_next(course_key, grader_id, location)),
277
                        mimetype="application/json")
278

279

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

    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

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

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

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

345 346 347 348 349

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


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

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

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

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

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

399
    grader_id = unique_id_for_user(request.user)
400

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

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

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

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


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