views.py 19.8 KB
Newer Older
1
"""
2
Views for the verification flow
3 4

"""
5
import json
6
import logging
7
import decimal
8 9
import datetime
from pytz import UTC
10

David Baumgold committed
11
from edxmako.shortcuts import render_to_response
12

13 14
from django.conf import settings
from django.core.urlresolvers import reverse
15
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
16
from django.shortcuts import redirect
17 18
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
19
from django.views.generic.base import View
20
from django.utils.decorators import method_decorator
21
from django.utils.translation import ugettext as _
22
from django.contrib.auth.decorators import login_required
23

24
from course_modes.models import CourseMode
25
from student.models import CourseEnrollment
26
from student.views import reverification_info
27
from shoppingcart.models import Order, CertificateItem
28 29 30
from shoppingcart.processors.CyberSource import (
    get_signed_purchase_params, get_purchase_endpoint
)
31
from verify_student.models import (
32
    SoftwareSecurePhotoVerification,
33
)
34
from reverification.models import MidcourseReverificationWindow
35
import ssencrypt
36
from xmodule.modulestore.exceptions import ItemNotFoundError
37
from opaque_keys.edx.locations import SlashSeparatedCourseKey
Julia Hansbrough committed
38
from .exceptions import WindowExpiredException
39
from xmodule.modulestore.django import modulestore
40

41 42
log = logging.getLogger(__name__)

43 44 45 46
EVENT_NAME_USER_ENTERED_MIDCOURSE_REVERIFY_VIEW = 'edx.course.enrollment.reverify.started'
EVENT_NAME_USER_SUBMITTED_MIDCOURSE_REVERIFY = 'edx.course.enrollment.reverify.submitted'
EVENT_NAME_USER_REVERIFICATION_REVIEWED_BY_SOFTWARESECURE = 'edx.course.enrollment.reverify.reviewed'

47 48
class VerifyView(View):

49
    @method_decorator(login_required)
50
    def get(self, request, course_id):
51
        """
52 53 54 55 56
        Displays the main verification view, which contains three separate steps:
            - Taking the standard face photo
            - Taking the id photo
            - Confirming that the photos and payment price are correct
              before proceeding to payment
57
        """
58 59
        upgrade = request.GET.get('upgrade', False)

60
        course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
61 62 63
        # If the user has already been verified within the given time period,
        # redirect straight to the payment -- no need to verify again.
        if SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user):
64
            return redirect(
65
                reverse('verify_student_verified',
66
                        kwargs={'course_id': course_id.to_deprecated_string()}) + "?upgrade={}".format(upgrade)
67
            )
68
        elif CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == ('verified', True):
69
            return redirect(reverse('dashboard'))
70 71 72 73 74 75 76
        else:
            # If they haven't completed a verification attempt, we have to
            # restart with a new one. We can't reuse an older one because we
            # won't be able to show them their encrypted photo_id -- it's easier
            # bookkeeping-wise just to start over.
            progress_state = "start"

77
        verify_mode = CourseMode.mode_for_course(course_id, "verified")
78 79 80 81
        # if the course doesn't have a verified mode, we want to kick them
        # from the flow
        if not verify_mode:
            return redirect(reverse('dashboard'))
82 83
        if course_id.to_deprecated_string() in request.session.get("donation_for_course", {}):
            chosen_price = request.session["donation_for_course"][course_id.to_deprecated_string()]
84
        else:
85
            chosen_price = verify_mode.min_price
86

87
        course = modulestore().get_course(course_id)
88
        context = {
89 90
            "progress_state": progress_state,
            "user_full_name": request.user.profile.name,
91 92
            "course_id": course_id.to_deprecated_string(),
            "course_modes_choose_url": reverse('course_modes_choose', kwargs={'course_id': course_id.to_deprecated_string()}),
93
            "course_name": course.display_name_with_default,
94 95
            "course_org": course.display_org_with_default,
            "course_num": course.display_number_with_default,
96
            "purchase_endpoint": get_purchase_endpoint(),
97 98 99 100
            "suggested_prices": [
                decimal.Decimal(price)
                for price in verify_mode.suggested_prices.split(",")
            ],
101 102
            "currency": verify_mode.currency.upper(),
            "chosen_price": chosen_price,
103
            "min_price": verify_mode.min_price,
104
            "upgrade": upgrade,
105 106 107
        }

        return render_to_response('verify_student/photo_verification.html', context)
108

109

110 111 112 113 114
class VerifiedView(View):
    """
    View that gets shown once the user has already gone through the
    verification flow
    """
115
    @method_decorator(login_required)
116
    def get(self, request, course_id):
117 118 119
        """
        Handle the case where we have a get request
        """
120
        upgrade = request.GET.get('upgrade', False)
121
        course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
122
        if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == ('verified', True):
123
            return redirect(reverse('dashboard'))
124
        verify_mode = CourseMode.mode_for_course(course_id, "verified")
125 126 127 128

        if verify_mode is None:
            return redirect(reverse('dashboard'))

129 130 131 132 133 134 135
        chosen_price = request.session.get(
            "donation_for_course",
            {}
        ).get(
            course_id.to_deprecated_string(),
            verify_mode.min_price
        )
136

137
        course = modulestore().get_course(course_id)
138
        context = {
139 140
            "course_id": course_id.to_deprecated_string(),
            "course_modes_choose_url": reverse('course_modes_choose', kwargs={'course_id': course_id.to_deprecated_string()}),
141
            "course_name": course.display_name_with_default,
142 143
            "course_org": course.display_org_with_default,
            "course_num": course.display_number_with_default,
144 145 146
            "purchase_endpoint": get_purchase_endpoint(),
            "currency": verify_mode.currency.upper(),
            "chosen_price": chosen_price,
147
            "create_order_url": reverse("verify_student_create_order"),
148
            "upgrade": upgrade,
149 150 151 152
        }
        return render_to_response('verify_student/verified.html', context)


153
@login_required
154
def create_order(request):
155 156 157
    """
    Submit PhotoVerification and create a new Order for this verified cert
    """
158 159
    if not SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user):
        attempt = SoftwareSecurePhotoVerification(user=request.user)
160 161 162 163 164 165 166
        b64_face_image = request.POST['face_image'].split(",")[1]
        b64_photo_id_image = request.POST['photo_id_image'].split(",")[1]

        attempt.upload_face_image(b64_face_image.decode('base64'))
        attempt.upload_photo_id_image(b64_photo_id_image.decode('base64'))
        attempt.mark_ready()

167
        attempt.save()
168 169

    course_id = request.POST['course_id']
170
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
171
    donation_for_course = request.session.get('donation_for_course', {})
172
    current_donation = donation_for_course.get(course_id, decimal.Decimal(0))
173
    contribution = request.POST.get("contribution", donation_for_course.get(course_id, 0))
174 175 176 177
    try:
        amount = decimal.Decimal(contribution).quantize(decimal.Decimal('.01'), rounding=decimal.ROUND_DOWN)
    except decimal.InvalidOperation:
        return HttpResponseBadRequest(_("Selected price is not valid number."))
178

179 180 181 182
    if amount != current_donation:
        donation_for_course[course_id] = amount
        request.session['donation_for_course'] = donation_for_course

183
    verified_mode = CourseMode.modes_for_course_dict(course_id).get('verified', None)
David Ormsbee committed
184

185 186 187 188
    # make sure this course has a verified mode
    if not verified_mode:
        return HttpResponseBadRequest(_("This course doesn't support verified certificates"))

189
    if amount < verified_mode.min_price:
190
        return HttpResponseBadRequest(_("No selected price or selected price is below minimum."))
191 192 193

    # I know, we should check this is valid. All kinds of stuff missing here
    cart = Order.get_cart_for_user(request.user)
194
    cart.clear()
195
    CertificateItem.add_to_order(cart, course_id, amount, 'verified')
196

197
    params = get_signed_purchase_params(cart)
198

199
    return HttpResponse(json.dumps(params), content_type="text/json")
200

201

202
@require_POST
203
@csrf_exempt  # SS does its own message signing, and their API won't have a cookie value
204 205 206 207 208 209
def results_callback(request):
    """
    Software Secure will call this callback to tell us whether a user is
    verified to be who they said they are.
    """
    body = request.body
210 211 212 213 214 215 216 217 218 219 220

    try:
        body_dict = json.loads(body)
    except ValueError:
        log.exception("Invalid JSON received from Software Secure:\n\n{}\n".format(body))
        return HttpResponseBadRequest("Invalid JSON. Received:\n\n{}".format(body))

    if not isinstance(body_dict, dict):
        log.error("Reply from Software Secure is not a dict:\n\n{}\n".format(body))
        return HttpResponseBadRequest("JSON should be dict. Received:\n\n{}".format(body))

221 222 223 224 225 226 227 228 229 230 231 232 233
    headers = {
        "Authorization": request.META.get("HTTP_AUTHORIZATION", ""),
        "Date": request.META.get("HTTP_DATE", "")
    }

    sig_valid = ssencrypt.has_valid_signature(
        "POST",
        headers,
        body_dict,
        settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_ACCESS_KEY"],
        settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_SECRET_KEY"]
    )

234
    _response, access_key_and_sig = headers["Authorization"].split(" ")
235 236 237 238 239 240 241 242 243
    access_key = access_key_and_sig.split(":")[0]

    # This is what we should be doing...
    #if not sig_valid:
    #    return HttpResponseBadRequest("Signature is invalid")

    # This is what we're doing until we can figure out why we disagree on sigs
    if access_key != settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_ACCESS_KEY"]:
        return HttpResponseBadRequest("Access key invalid")
244 245 246 247 248 249

    receipt_id = body_dict.get("EdX-ID")
    result = body_dict.get("Result")
    reason = body_dict.get("Reason", "")
    error_code = body_dict.get("MessageType", "")

250 251 252 253 254 255 256 257
    try:
        attempt = SoftwareSecurePhotoVerification.objects.get(receipt_id=receipt_id)
    except SoftwareSecurePhotoVerification.DoesNotExist:
        log.error("Software Secure posted back for receipt_id {}, but not found".format(receipt_id))
        return HttpResponseBadRequest("edX ID {} not found".format(receipt_id))

    if result == "PASS":
        log.debug("Approving verification for {}".format(receipt_id))
258
        attempt.approve()
259 260 261
    elif result == "FAIL":
        log.debug("Denying verification for {}".format(receipt_id))
        attempt.deny(json.dumps(reason), error_code=error_code)
262
    elif result == "SYSTEM FAIL":
263 264
        log.debug("System failure for {} -- resetting to must_retry".format(receipt_id))
        attempt.system_error(json.dumps(reason), error_code=error_code)
265
        log.error("Software Secure callback attempt for %s failed: %s", receipt_id, reason)
266 267 268 269 270
    else:
        log.error("Software Secure returned unknown result {}".format(result))
        return HttpResponseBadRequest(
            "Result {} not understood. Known results: PASS, FAIL, SYSTEM FAIL".format(result)
        )
271

272 273
    # If this is a reverification, log an event
    if attempt.window:
274
        course_id = attempt.window.course_id
275 276 277
        course_enrollment = CourseEnrollment.get_or_create_enrollment(attempt.user, course_id)
        course_enrollment.emit_event(EVENT_NAME_USER_REVERIFICATION_REVIEWED_BY_SOFTWARESECURE)

278
    return HttpResponse("OK!")
279

280

281 282 283
@login_required
def show_requirements(request, course_id):
    """
284
    Show the requirements necessary for the verification flow.
285
    """
286
    course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
287
    if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == ('verified', True):
288
        return redirect(reverse('dashboard'))
289

290
    upgrade = request.GET.get('upgrade', False)
291
    course = modulestore().get_course(course_id)
292
    context = {
293 294 295
        "course_id": course_id.to_deprecated_string(),
        "course_modes_choose_url": reverse("course_modes_choose", kwargs={'course_id': course_id.to_deprecated_string()}),
        "verify_student_url": reverse('verify_student_verify', kwargs={'course_id': course_id.to_deprecated_string()}),
296
        "course_name": course.display_name_with_default,
297 298
        "course_org": course.display_org_with_default,
        "course_num": course.display_number_with_default,
299
        "is_not_active": not request.user.is_active,
300
        "upgrade": upgrade,
301
    }
302
    return render_to_response("verify_student/show_requirements.html", context)
303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357


class ReverifyView(View):
    """
    The main reverification view. Under similar constraints as the main verification view.
    Has to perform these functions:
        - take new face photo
        - take new id photo
        - submit photos to photo verification service

    Does not need to be attached to a particular course.
    Does not need to worry about pricing
    """
    @method_decorator(login_required)
    def get(self, request):
        """
        display this view
        """
        context = {
            "user_full_name": request.user.profile.name,
            "error": False,
        }

        return render_to_response("verify_student/photo_reverification.html", context)

    @method_decorator(login_required)
    def post(self, request):
        """
        submits the reverification to SoftwareSecure
        """

        try:
            attempt = SoftwareSecurePhotoVerification(user=request.user)
            b64_face_image = request.POST['face_image'].split(",")[1]
            b64_photo_id_image = request.POST['photo_id_image'].split(",")[1]

            attempt.upload_face_image(b64_face_image.decode('base64'))
            attempt.upload_photo_id_image(b64_photo_id_image.decode('base64'))
            attempt.mark_ready()

            # save this attempt
            attempt.save()
            # then submit it across
            attempt.submit()
            return HttpResponseRedirect(reverse('verify_student_reverification_confirmation'))
        except Exception:
            log.exception(
                "Could not submit verification attempt for user {}".format(request.user.id)
            )
            context = {
                "user_full_name": request.user.profile.name,
                "error": True,
            }
            return render_to_response("verify_student/photo_reverification.html", context)

358

359 360 361 362 363 364 365 366 367 368 369 370 371 372 373
class MidCourseReverifyView(View):
    """
    The mid-course reverification view.
    Needs to perform these functions:
        - take new face photo
        - retrieve the old id photo
        - submit these photos to photo verification service

    Does not need to worry about pricing
    """
    @method_decorator(login_required)
    def get(self, request, course_id):
        """
        display this view
        """
374
        course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
375
        course = modulestore().get_course(course_id)
376 377 378
        course_enrollment = CourseEnrollment.get_or_create_enrollment(request.user, course_id)
        course_enrollment.update_enrollment(mode="verified")
        course_enrollment.emit_event(EVENT_NAME_USER_ENTERED_MIDCOURSE_REVERIFY_VIEW)
379 380 381
        context = {
            "user_full_name": request.user.profile.name,
            "error": False,
382
            "course_id": course_id.to_deprecated_string(),
383 384 385 386
            "course_name": course.display_name_with_default,
            "course_org": course.display_org_with_default,
            "course_num": course.display_number_with_default,
            "reverify": True,
387
        }
388

389 390 391 392 393 394 395 396 397
        return render_to_response("verify_student/midcourse_photo_reverification.html", context)

    @method_decorator(login_required)
    def post(self, request, course_id):
        """
        submits the reverification to SoftwareSecure
        """
        try:
            now = datetime.datetime.now(UTC)
398
            course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
Julia Hansbrough committed
399 400 401 402
            window = MidcourseReverificationWindow.get_window(course_id, now)
            if window is None:
                raise WindowExpiredException
            attempt = SoftwareSecurePhotoVerification(user=request.user, window=window)
403 404 405 406 407 408 409 410
            b64_face_image = request.POST['face_image'].split(",")[1]

            attempt.upload_face_image(b64_face_image.decode('base64'))
            attempt.fetch_photo_id_image()
            attempt.mark_ready()

            attempt.save()
            attempt.submit()
411 412 413
            course_enrollment = CourseEnrollment.get_or_create_enrollment(request.user, course_id)
            course_enrollment.update_enrollment(mode="verified")
            course_enrollment.emit_event(EVENT_NAME_USER_SUBMITTED_MIDCOURSE_REVERIFY)
414
            return HttpResponseRedirect(reverse('verify_student_midcourse_reverification_confirmation'))
Julia Hansbrough committed
415 416 417 418 419 420 421

        except WindowExpiredException:
            log.exception(
                "User {} attempted to re-verify, but the window expired before the attempt".format(request.user.id)
            )
            return HttpResponseRedirect(reverse('verify_student_reverification_window_expired'))

422 423 424 425 426 427 428 429 430 431
        except Exception:
            log.exception(
                "Could not submit verification attempt for user {}".format(request.user.id)
            )
            context = {
                "user_full_name": request.user.profile.name,
                "error": True,
            }
            return render_to_response("verify_student/midcourse_photo_reverification.html", context)

432

433
@login_required
434
def midcourse_reverify_dash(request):
435 436 437 438
    """
    Shows the "course reverification dashboard", which displays the reverification status (must reverify,
    pending, approved, failed, etc) of all courses in which a student has a verified enrollment.
    """
439
    user = request.user
440 441 442
    course_enrollment_pairs = []
    for enrollment in CourseEnrollment.enrollments_for_user(user):
        try:
443
            course_enrollment_pairs.append((modulestore().get_course(enrollment.course_id), enrollment))
444 445 446
        except ItemNotFoundError:
            log.error("User {0} enrolled in non-existent course {1}"
                      .format(user.username, enrollment.course_id))
447

448 449 450
    statuses = ["approved", "pending", "must_reverify", "denied"]

    reverifications = reverification_info(course_enrollment_pairs, user, statuses)
451

452
    context = {
453
        "user_full_name": user.profile.name,
454
        'reverifications': reverifications,
455
        'referer': request.META.get('HTTP_REFERER'),
456
        'billing_email': settings.PAYMENT_SUPPORT_EMAIL,
457 458
    }
    return render_to_response("verify_student/midcourse_reverify_dash.html", context)
459

460

461 462
@login_required
@require_POST
463 464 465 466 467
def toggle_failed_banner_off(request):
    """
    Finds all denied midcourse reverifications for a user and permanently toggles
    the "Reverification Failed" banner off for those verifications.
    """
468
    user_id = request.user.id
469
    SoftwareSecurePhotoVerification.display_off(user_id)
470 471
    return HttpResponse('Success')

472 473


474 475 476 477 478 479
@login_required
def reverification_submission_confirmation(_request):
    """
    Shows the user a confirmation page if the submission to SoftwareSecure was successful
    """
    return render_to_response("verify_student/reverification_confirmation.html")
480

481

482
@login_required
483
def midcourse_reverification_confirmation(_request):  # pylint: disable=C0103
484 485 486 487
    """
    Shows the user a confirmation page if the submission to SoftwareSecure was successful
    """
    return render_to_response("verify_student/midcourse_reverification_confirmation.html")
Julia Hansbrough committed
488 489 490 491 492 493 494 495 496 497


@login_required
def reverification_window_expired(_request):
    """
    Displays an error page if a student tries to submit a reverification, but the window
    for that reverification has already expired.
    """
    # TODO need someone to review the copy for this template
    return render_to_response("verify_student/reverification_window_expired.html")