queue.py 19.2 KB
Newer Older
1 2 3 4 5 6
"""Interface for adding certificate generation tasks to the XQueue. """
import json
import random
import logging
import lxml.html
from lxml.etree import XMLSyntaxError, ParserError  # pylint:disable=no-name-in-module
7

8 9
from django.test.client import RequestFactory
from django.conf import settings
10
from django.core.urlresolvers import reverse
11
from requests.auth import HTTPBasicAuth
12 13 14 15 16

from courseware import grades
from xmodule.modulestore.django import modulestore
from capa.xqueue_interface import XQueueInterface
from capa.xqueue_interface import make_xheader, make_hashkey
17
from student.models import UserProfile, CourseEnrollment
18
from verify_student.models import SoftwareSecurePhotoVerification
19

20 21 22 23 24 25 26
from certificates.models import (
    GeneratedCertificate,
    certificate_status_for_student,
    CertificateStatuses as status,
    CertificateWhitelist,
    ExampleCertificate
)
27 28


29
LOGGER = logging.getLogger(__name__)
30 31


32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
class XQueueAddToQueueError(Exception):
    """An error occurred when adding a certificate task to the queue. """

    def __init__(self, error_code, error_msg):
        self.error_code = error_code
        self.error_msg = error_msg
        super(XQueueAddToQueueError, self).__init__(unicode(self))

    def __unicode__(self):
        return (
            u"Could not add certificate to the XQueue.  "
            u"The error code was '{code}' and the message was '{msg}'."
        ).format(
            code=self.error_code,
            msg=self.error_msg
        )


John Jarvis committed
50
class XQueueCertInterface(object):
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
    """
    XQueueCertificateInterface provides an
    interface to the xqueue server for
    managing student certificates.

    Instantiating an object will create a new
    connection to the queue server.

    See models.py for valid state transitions,
    summary of methods:

       add_cert:   Add a new certificate.  Puts a single
                   request on the queue for the student/course.
                   Once the certificate is generated a post
                   will be made to the update_certificate
                   view which will save the certificate
                   download URL.

       regen_cert: Regenerate an existing certificate.
                   For a user that already has a certificate
                   this will delete the existing one and
                   generate a new cert.


       del_cert:   Delete an existing certificate
                   For a user that already has a certificate
                   this will delete his cert.

    """
80

81
    def __init__(self, request=None):
82

John Jarvis committed
83 84 85
        # Get basic auth (username/password) for
        # xqueue connection if it's in the settings

86 87
        if settings.XQUEUE_INTERFACE.get('basic_auth') is not None:
            requests_auth = HTTPBasicAuth(
88
                *settings.XQUEUE_INTERFACE['basic_auth'])
89 90 91 92 93 94 95 96 97 98
        else:
            requests_auth = None

        if request is None:
            factory = RequestFactory()
            self.request = factory.get('/')
        else:
            self.request = request

        self.xqueue_interface = XQueueInterface(
99 100 101 102
            settings.XQUEUE_INTERFACE['url'],
            settings.XQUEUE_INTERFACE['django_auth'],
            requests_auth,
        )
103 104
        self.whitelist = CertificateWhitelist.objects.all()
        self.restricted = UserProfile.objects.filter(allow_certificate=False)
105
        self.use_https = True
106

107
    def regen_cert(self, student, course_id, course=None, forced_grade=None, template_file=None, generate_pdf=True):
108 109
        """(Re-)Make certificate for a particular student in a particular course

110
        Arguments:
111
          student   - User.object
112 113
          course_id - courseenrollment.course_id (string)

114
        WARNING: this command will leave the old certificate, if one exists,
115
                 laying around in AWS taking up space. If this is a problem,
116
                 take pains to clean up storage before running this command.
117

118 119
        Change the certificate status to unavailable (if it exists) and request
        grading. Passing grades will put a certificate request on the queue.
120

121
        Return the status object.
122
        """
123
        # TODO: when del_cert is implemented and plumbed through certificates
124 125 126 127 128
        #       repo also, do a deletion followed by a creation r/t a simple
        #       recreation. XXX: this leaves orphan cert files laying around in
        #       AWS. See note in the docstring too.
        try:
            certificate = GeneratedCertificate.objects.get(user=student, course_id=course_id)
129 130 131 132 133 134 135 136 137 138 139 140

            LOGGER.info(
                (
                    u"Found an existing certificate entry for student %s "
                    u"in course '%s' "
                    u"with status '%s' while regenerating certificates. "
                ),
                student.id,
                unicode(course_id),
                certificate.status
            )

141 142
            certificate.status = status.unavailable
            certificate.save()
143 144 145 146 147 148 149 150 151 152 153

            LOGGER.info(
                (
                    u"The certificate status for student %s "
                    u"in course '%s' has been changed to '%s'."
                ),
                student.id,
                unicode(course_id),
                certificate.status
            )

154 155 156
        except GeneratedCertificate.DoesNotExist:
            pass

157
        return self.add_cert(student, course_id, course, forced_grade, template_file, generate_pdf)
158

159
    def del_cert(self, student, course_id):
160

161
        """
162 163 164 165 166 167 168
        Arguments:
          student - User.object
          course_id - courseenrollment.course_id (string)

        Removes certificate for a student, will change
        the certificate status to 'deleting'.

169 170
        Certificate must be in the 'error' or 'downloadable' state
        otherwise it will return the current state
171 172 173

        """

174
        raise NotImplementedError
175

176 177 178
    # pylint: disable=too-many-statements
    def add_cert(self, student, course_id, course=None, forced_grade=None, template_file=None,
                 title='None', generate_pdf=True):
179
        """
180
        Request a new certificate for a student.
181 182

        Arguments:
183
          student   - User.object
184
          course_id - courseenrollment.course_id (CourseKey)
185 186 187
          forced_grade - a string indicating a grade parameter to pass with
                         the certificate request. If this is given, grading
                         will be skipped.
188
          generate_pdf - Boolean should a message be sent in queue to generate certificate PDF
189

190 191
        Will change the certificate status to 'generating' or
        `downloadable` in case of web view certificates.
192

193
        Certificate must be in the 'unavailable', 'error',
194
        'deleted' or 'generating' state.
195

196
        If a student has a passing grade or is in the whitelist
197
        table for the course a request will be made for a new cert.
198 199 200

        If a student has allow_certificate set to False in the
        userprofile table the status will change to 'restricted'
201 202 203 204

        If a student does not have a passing grade the status
        will change to status.notpassing

205
        Returns the student's status and newly created certificate instance
206 207
        """

208 209 210 211 212
        valid_statuses = [
            status.generating,
            status.unavailable,
            status.deleted,
            status.error,
213 214
            status.notpassing,
            status.downloadable
215
        ]
216

217
        cert_status = certificate_status_for_student(student, course_id)['status']
218
        new_status = cert_status
219
        cert = None
220

221 222 223 224 225 226 227 228 229 230 231 232 233
        if cert_status not in valid_statuses:
            LOGGER.warning(
                (
                    u"Cannot create certificate generation task for user %s "
                    u"in the course '%s'; "
                    u"the certificate status '%s' is not one of %s."
                ),
                student.id,
                unicode(course_id),
                cert_status,
                unicode(valid_statuses)
            )
        else:
234
            # grade the student
235

236 237 238
            # re-use the course passed in optionally so we don't have to re-fetch everything
            # for every student
            if course is None:
239
                course = modulestore().get_course(course_id, depth=0)
240
            profile = UserProfile.objects.get(user=student)
241
            profile_name = profile.name
242

243 244 245 246
            # Needed
            self.request.user = student
            self.request.session = {}

247
            course_name = course.display_name or unicode(course_id)
248
            is_whitelisted = self.whitelist.filter(user=student, course_id=course_id, whitelist=True).exists()
249
            grade = grades.grade(student, self.request, course)
250
            enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(student, course_id)
251 252
            mode_is_verified = (enrollment_mode == GeneratedCertificate.MODES.verified)
            user_is_verified = SoftwareSecurePhotoVerification.user_is_verified(student)
253
            cert_mode = enrollment_mode
254
            if mode_is_verified and user_is_verified:
255
                template_pdf = "certificate-template-{id.org}-{id.course}-verified.pdf".format(id=course_id)
256
            elif mode_is_verified and not user_is_verified:
257
                template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format(id=course_id)
258
                cert_mode = GeneratedCertificate.MODES.honor
259 260
            else:
                # honor code and audit students
261
                template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format(id=course_id)
262 263
            if forced_grade:
                grade['grade'] = forced_grade
264

265
            cert, __ = GeneratedCertificate.objects.get_or_create(user=student, course_id=course_id)
266

267
            cert.mode = cert_mode
268 269 270
            cert.user = student
            cert.grade = grade['percent']
            cert.course_id = course_id
271
            cert.name = profile_name
272
            cert.download_url = ''
273 274 275 276
            # Strip HTML from grade range label
            grade_contents = grade.get('grade', None)
            try:
                grade_contents = lxml.html.fromstring(grade_contents).text_content()
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291
            except (TypeError, XMLSyntaxError, ParserError) as exc:
                LOGGER.info(
                    (
                        u"Could not retrieve grade for student %s "
                        u"in the course '%s' "
                        u"because an exception occurred while parsing the "
                        u"grade contents '%s' as HTML. "
                        u"The exception was: '%s'"
                    ),
                    student.id,
                    unicode(course_id),
                    grade_contents,
                    unicode(exc)
                )

292 293
                #   Despite blowing up the xml parser, bad values here are fine
                grade_contents = None
294

295
            if is_whitelisted or grade_contents is not None:
296

297 298 299 300 301 302 303
                if is_whitelisted:
                    LOGGER.info(
                        u"Student %s is whitelisted in '%s'",
                        student.id,
                        unicode(course_id)
                    )

304 305 306 307
                # check to see whether the student is on the
                # the embargoed country restricted list
                # otherwise, put a new certificate request
                # on the queue
308

309
                if self.restricted.filter(user=student).exists():
310 311
                    new_status = status.restricted
                    cert.status = new_status
312
                    cert.save()
313 314 315 316 317 318 319 320 321 322 323 324

                    LOGGER.info(
                        (
                            u"Student %s is in the embargoed country restricted "
                            u"list, so their certificate status has been set to '%s' "
                            u"for the course '%s'. "
                            u"No certificate generation task was sent to the XQueue."
                        ),
                        student.id,
                        new_status,
                        unicode(course_id)
                    )
325
                else:
326 327
                    key = make_hashkey(random.random())
                    cert.key = key
328 329 330
                    contents = {
                        'action': 'create',
                        'username': student.username,
331
                        'course_id': unicode(course_id),
332
                        'course_name': course_name,
333 334
                        'name': profile_name,
                        'grade': grade_contents,
335
                        'template_pdf': template_pdf,
336
                    }
337 338
                    if template_file:
                        contents['template_pdf'] = template_file
339
                    new_status = status.generating if generate_pdf else status.downloadable
340
                    cert.status = new_status
341
                    cert.save()
342

343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368
                    if generate_pdf:
                        try:
                            self._send_to_xqueue(contents, key)
                        except XQueueAddToQueueError as exc:
                            new_status = ExampleCertificate.STATUS_ERROR
                            cert.status = new_status
                            cert.error_reason = unicode(exc)
                            cert.save()
                            LOGGER.critical(
                                (
                                    u"Could not add certificate task to XQueue.  "
                                    u"The course was '%s' and the student was '%s'."
                                    u"The certificate task status has been marked as 'error' "
                                    u"and can be re-submitted with a management command."
                                ), course_id, student.id
                            )
                        else:
                            LOGGER.info(
                                (
                                    u"The certificate status has been set to '%s'.  "
                                    u"Sent a certificate grading task to the XQueue "
                                    u"with the key '%s'. "
                                ),
                                new_status,
                                key
                            )
369
            else:
370 371
                new_status = status.notpassing
                cert.status = new_status
372
                cert.save()
373

374 375 376 377 378 379 380 381
                LOGGER.info(
                    (
                        u"Student %s does not have a grade for '%s', "
                        u"so their certificate status has been set to '%s'. "
                        u"No certificate generation task was sent to the XQueue."
                    ),
                    student.id,
                    unicode(course_id),
382
                    new_status
383 384
                )

385
        return new_status, cert
386

387 388
    def add_example_cert(self, example_cert):
        """Add a task to create an example certificate.
389

390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434
        Unlike other certificates, an example certificate is
        not associated with any particular user and is never
        shown to students.

        If an error occurs when adding the example certificate
        to the queue, the example certificate status
        will be set to "error".

        Arguments:
            example_cert (ExampleCertificate)

        """
        contents = {
            'action': 'create',
            'course_id': unicode(example_cert.course_key),
            'name': example_cert.full_name,
            'template_pdf': example_cert.template,

            # Example certificates are not associated with a particular user.
            # However, we still need to find the example certificate when
            # we receive a response from the queue.  For this reason,
            # we use the example certificate's unique identifier as a username.
            # Note that the username is *not* displayed on the certificate;
            # it is used only to identify the certificate task in the queue.
            'username': example_cert.uuid,

            # We send this extra parameter to differentiate
            # example certificates from other certificates.
            # This is not used by the certificates workers or XQueue.
            'example_certificate': True,
        }

        # The callback for example certificates is different than the callback
        # for other certificates.  Although both tasks use the same queue,
        # we can distinguish whether the certificate was an example cert based
        # on which end-point XQueue uses once the task completes.
        callback_url_path = reverse('certificates.views.update_example_certificate')

        try:
            self._send_to_xqueue(
                contents,
                example_cert.access_key,
                task_identifier=example_cert.uuid,
                callback_url_path=callback_url_path
            )
435
            LOGGER.info(u"Started generating example certificates for course '%s'.", example_cert.course_key)
436 437 438 439 440
        except XQueueAddToQueueError as exc:
            example_cert.update_status(
                ExampleCertificate.STATUS_ERROR,
                error_reason=unicode(exc)
            )
441 442 443 444 445 446 447
            LOGGER.critical(
                (
                    u"Could not add example certificate with uuid '%s' to XQueue.  "
                    u"The exception was %s.  "
                    u"The example certificate has been marked with status 'error'."
                ), example_cert.uuid, unicode(exc)
            )
448

449
    def _send_to_xqueue(self, contents, key, task_identifier=None, callback_url_path='/update_certificate'):
450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488
        """Create a new task on the XQueue.

        Arguments:
            contents (dict): The contents of the XQueue task.
            key (str): An access key for the task.  This will be sent
                to the callback end-point once the task completes,
                so that we can validate that the sender is the same
                entity that received the task.

        Keyword Arguments:
            callback_url_path (str): The path of the callback URL.
                If not provided, use the default end-point for student-generated
                certificates.

        """
        callback_url = u'{protocol}://{base_url}{path}'.format(
            protocol=("https" if self.use_https else "http"),
            base_url=settings.SITE_NAME,
            path=callback_url_path
        )

        # Append the key to the URL
        # This is necessary because XQueue assumes that only one
        # submission is active for a particular URL.
        # If it receives a second submission with the same callback URL,
        # it "retires" any other submission with the same URL.
        # This was a hack that depended on the URL containing the user ID
        # and courseware location; an assumption that does not apply
        # to certificate generation.
        # XQueue also truncates the callback URL to 128 characters,
        # but since our key lengths are shorter than that, this should
        # not affect us.
        callback_url += "?key={key}".format(
            key=(
                task_identifier
                if task_identifier is not None
                else key
            )
        )
489

490
        xheader = make_xheader(callback_url, key, settings.CERT_QUEUE)
491 492

        (error, msg) = self.xqueue_interface.send_to_queue(
493
            header=xheader, body=json.dumps(contents))
494
        if error:
495 496 497
            exc = XQueueAddToQueueError(error, msg)
            LOGGER.critical(unicode(exc))
            raise exc