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

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

12
from edxmako.shortcuts import render_to_response, render_to_string
13

14 15
from django.conf import settings
from django.core.urlresolvers import reverse
16 17 18 19
from django.http import (
    HttpResponse, HttpResponseBadRequest,
    HttpResponseRedirect, Http404
)
20
from django.shortcuts import redirect
21 22
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
23
from django.views.generic.base import View
24
from django.utils.decorators import method_decorator
25
from django.utils.translation import ugettext as _, ugettext_lazy
26
from django.contrib.auth.decorators import login_required
27
from django.core.mail import send_mail
28

29 30
from openedx.core.djangoapps.user_api.api import profile as profile_api

31
from course_modes.models import CourseMode
32
from student.models import CourseEnrollment
33
from student.views import reverification_info
34
from shoppingcart.models import Order, CertificateItem
35
from shoppingcart.processors import (
36 37
    get_signed_purchase_params, get_purchase_endpoint
)
38
from verify_student.models import (
39
    SoftwareSecurePhotoVerification,
40
)
41
from reverification.models import MidcourseReverificationWindow
42
import ssencrypt
43
from xmodule.modulestore.exceptions import ItemNotFoundError
44
from opaque_keys.edx.keys import CourseKey
Julia Hansbrough committed
45
from .exceptions import WindowExpiredException
46
from xmodule.modulestore.django import modulestore
47
from microsite_configuration import microsite
48

49
from util.json_request import JsonResponse
50
from util.date_utils import get_default_time_display
51

52 53
log = logging.getLogger(__name__)

54 55 56 57
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'

58

59 60
class VerifyView(View):

61
    @method_decorator(login_required)
62
    def get(self, request, course_id):
63
        """
64 65 66 67 68
        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
69
        """
70 71
        upgrade = request.GET.get('upgrade', False)

72
        course_id = CourseKey.from_string(course_id)
73 74 75
        # 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):
76
            return redirect(
77
                reverse('verify_student_verified',
78
                        kwargs={'course_id': course_id.to_deprecated_string()}) + "?upgrade={}".format(upgrade)
79
            )
80
        elif CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == ('verified', True):
81
            return redirect(reverse('dashboard'))
82 83 84 85 86 87 88
        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"

89 90 91
        # we prefer professional over verify
        current_mode = CourseMode.verified_mode_for_course(course_id)

92 93
        # if the course doesn't have a verified mode, we want to kick them
        # from the flow
94
        if not current_mode:
95
            return redirect(reverse('dashboard'))
96
        if course_id.to_deprecated_string() in request.session.get("donation_for_course", {}):
97
            chosen_price = request.session["donation_for_course"][unicode(course_id)]
98
        else:
99
            chosen_price = current_mode.min_price
100

101
        course = modulestore().get_course(course_id)
102 103 104 105 106 107 108 109
        if current_mode.suggested_prices != '':
            suggested_prices = [
                decimal.Decimal(price)
                for price in current_mode.suggested_prices.split(",")
            ]
        else:
            suggested_prices = []

110
        context = {
111 112
            "progress_state": progress_state,
            "user_full_name": request.user.profile.name,
113 114
            "course_id": course_id.to_deprecated_string(),
            "course_modes_choose_url": reverse('course_modes_choose', kwargs={'course_id': course_id.to_deprecated_string()}),
115
            "course_name": course.display_name_with_default,
116 117
            "course_org": course.display_org_with_default,
            "course_num": course.display_number_with_default,
118
            "purchase_endpoint": get_purchase_endpoint(),
119 120
            "suggested_prices": suggested_prices,
            "currency": current_mode.currency.upper(),
121
            "chosen_price": chosen_price,
122
            "min_price": current_mode.min_price,
123
            "upgrade": upgrade == u'True',
124 125
            "can_audit": CourseMode.mode_for_course(course_id, 'audit') is not None,
            "modes_dict": CourseMode.modes_for_course_dict(course_id),
126
            "retake": request.GET.get('retake', False),
127 128 129
        }

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

131

132 133 134 135 136
class VerifiedView(View):
    """
    View that gets shown once the user has already gone through the
    verification flow
    """
137
    @method_decorator(login_required)
138
    def get(self, request, course_id):
139 140 141
        """
        Handle the case where we have a get request
        """
142
        upgrade = request.GET.get('upgrade', False)
143
        course_id = CourseKey.from_string(course_id)
144
        if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == ('verified', True):
145
            return redirect(reverse('dashboard'))
146 147

        modes_dict = CourseMode.modes_for_course_dict(course_id)
148

149 150
        # we prefer professional over verify
        current_mode = CourseMode.verified_mode_for_course(course_id)
151

152 153 154 155 156
        # if the course doesn't have a verified mode, we want to kick them
        # from the flow
        if not current_mode:
            return redirect(reverse('dashboard'))
        if course_id.to_deprecated_string() in request.session.get("donation_for_course", {}):
157
            chosen_price = request.session["donation_for_course"][unicode(course_id)]
158 159
        else:
            chosen_price = current_mode.min_price
160

161
        course = modulestore().get_course(course_id)
162
        context = {
163 164
            "course_id": course_id.to_deprecated_string(),
            "course_modes_choose_url": reverse('course_modes_choose', kwargs={'course_id': course_id.to_deprecated_string()}),
165
            "course_name": course.display_name_with_default,
166 167
            "course_org": course.display_org_with_default,
            "course_num": course.display_number_with_default,
168
            "purchase_endpoint": get_purchase_endpoint(),
169
            "currency": current_mode.currency.upper(),
170
            "chosen_price": chosen_price,
171
            "create_order_url": reverse("verify_student_create_order"),
172
            "upgrade": upgrade == u'True',
173
            "can_audit": "audit" in modes_dict,
174
            "modes_dict": modes_dict,
175 176 177 178
        }
        return render_to_response('verify_student/verified.html', context)


179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262
class PayAndVerifyView(View):
    """View for the "verify and pay" flow.

    This view is somewhat complicated, because the user
    can enter it from a number of different places:

    * From the "choose your track" page.
    * After completing payment.
    * From the dashboard in order to complete verification.
    * From the dashboard in order to upgrade to a verified track.

    The page will display different steps and requirements
    depending on:

    * Whether the user has submitted a photo verification recently.
    * Whether the user has paid for the course.
    * How the user reached the page (mostly affects messaging)

    We are also super-paranoid about how users reach this page.
    If they somehow aren't enrolled, or the course doesn't exist,
    or they've unenrolled, or they've already paid/verified,
    ... then we try to redirect them to the page with the
    most appropriate messaging (including the dashboard).

    Note that this page does NOT handle re-verification
    (photo verification that was denied or had an error);
    that is handled by the "reverify" view.

    """

    # Step definitions
    #
    # These represent the numbered steps a user sees in
    # the verify / payment flow.
    #
    # Steps can either be:
    # - displayed or hidden
    # - complete or incomplete
    #
    # For example, when a user enters the verification/payment
    # flow for the first time, the user will see steps
    # for both payment and verification.  As the user
    # completes these steps (for example, submitting a photo)
    # the steps will be marked "complete".
    #
    # If a user has already verified for another course,
    # then the verification steps will be hidden,
    # since the user has already completed them.
    #
    # If a user re-enters the flow from another application
    # (for example, after completing payment through
    # a third-party payment processor), then the user
    # will resume the flow at an intermediate step.
    #
    INTRO_STEP = 'intro-step'
    MAKE_PAYMENT_STEP = 'make-payment-step'
    PAYMENT_CONFIRMATION_STEP = 'payment-confirmation-step'
    FACE_PHOTO_STEP = 'face-photo-step'
    ID_PHOTO_STEP = 'id-photo-step'
    REVIEW_PHOTOS_STEP = 'review-photos-step'
    ENROLLMENT_CONFIRMATION_STEP = 'enrollment-confirmation-step'

    ALL_STEPS = [
        INTRO_STEP,
        MAKE_PAYMENT_STEP,
        PAYMENT_CONFIRMATION_STEP,
        FACE_PHOTO_STEP,
        ID_PHOTO_STEP,
        REVIEW_PHOTOS_STEP,
        ENROLLMENT_CONFIRMATION_STEP
    ]

    PAYMENT_STEPS = [
        MAKE_PAYMENT_STEP,
        PAYMENT_CONFIRMATION_STEP
    ]

    VERIFICATION_STEPS = [
        FACE_PHOTO_STEP,
        ID_PHOTO_STEP,
        REVIEW_PHOTOS_STEP,
        ENROLLMENT_CONFIRMATION_STEP
    ]

263
    # These steps can be skipped using the ?skip-first-step GET param
264 265 266 267
    SKIP_STEPS = [
        INTRO_STEP,
    ]

268 269 270 271 272 273 274 275 276 277 278
    Step = namedtuple(
        'Step',
        [
            'title',
            'template_name'
        ]
    )

    STEP_INFO = {
        INTRO_STEP: Step(
            title=ugettext_lazy("Intro"),
279
            template_name="intro_step"
280 281
        ),
        MAKE_PAYMENT_STEP: Step(
282
            title=ugettext_lazy("Make payment"),
283
            template_name="make_payment_step"
284 285
        ),
        PAYMENT_CONFIRMATION_STEP: Step(
286
            title=ugettext_lazy("Payment confirmation"),
287
            template_name="payment_confirmation_step"
288 289
        ),
        FACE_PHOTO_STEP: Step(
290
            title=ugettext_lazy("Take photo"),
291
            template_name="face_photo_step"
292 293
        ),
        ID_PHOTO_STEP: Step(
294
            title=ugettext_lazy("Take a photo of your ID"),
295
            template_name="id_photo_step"
296 297
        ),
        REVIEW_PHOTOS_STEP: Step(
298
            title=ugettext_lazy("Review your info"),
299
            template_name="review_photos_step"
300 301
        ),
        ENROLLMENT_CONFIRMATION_STEP: Step(
302
            title=ugettext_lazy("Enrollment confirmation"),
303
            template_name="enrollment_confirmation_step"
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
        ),
    }

    # Messages
    #
    # Depending on how the user entered reached the page,
    # we will display different text messaging.
    # For example, we show users who are upgrading
    # slightly different copy than users who are verifying
    # for the first time.
    #
    FIRST_TIME_VERIFY_MSG = 'first-time-verify'
    VERIFY_NOW_MSG = 'verify-now'
    VERIFY_LATER_MSG = 'verify-later'
    UPGRADE_MSG = 'upgrade'
    PAYMENT_CONFIRMATION_MSG = 'payment-confirmation'

    # Requirements
    #
    # These explain to the user what he or she
    # will need to successfully pay and/or verify.
    #
    # These are determined by the steps displayed
    # to the user; for example, if the user does not
    # need to complete the verification steps,
    # then the photo ID and webcam requirements are hidden.
    #
331
    ACCOUNT_ACTIVATION_REQ = "account-activation-required"
332 333 334 335 336 337 338 339 340 341 342 343
    PHOTO_ID_REQ = "photo-id-required"
    WEBCAM_REQ = "webcam-required"

    STEP_REQUIREMENTS = {
        ID_PHOTO_STEP: [PHOTO_ID_REQ, WEBCAM_REQ],
        FACE_PHOTO_STEP: [WEBCAM_REQ],
    }

    @method_decorator(login_required)
    def get(
        self, request, course_id,
        always_show_payment=False,
344
        current_step=None,
345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375
        message=FIRST_TIME_VERIFY_MSG
    ):
        """Render the pay/verify requirements page.

        Arguments:
            request (HttpRequest): The request object.
            course_id (unicode): The ID of the course the user is trying
                to enroll in.

        Keyword Arguments:
            always_show_payment (bool): If True, show the payment steps
                even if the user has already paid.  This is useful
                for users returning to the flow after paying.
            current_step (string): The current step in the flow.
            message (string): The messaging to display.

        Returns:
            HttpResponse

        Raises:
            Http404: The course does not exist or does not
                have a verified mode.

        """
        # Parse the course key
        # The URL regex should guarantee that the key format is valid.
        course_key = CourseKey.from_string(course_id)
        course = modulestore().get_course(course_key)

        # Verify that the course exists and has a verified mode
        if course is None:
376
            log.warn(u"No course specified for verification flow request.")
377 378 379 380 381
            raise Http404

        # Verify that the course has a verified mode
        course_mode = CourseMode.verified_mode_for_course(course_key)
        if course_mode is None:
382 383 384 385
            log.warn(
                u"No verified course mode found for course '{course_id}' for verification flow request"
                .format(course_id=course_id)
            )
386 387
            raise Http404

388 389 390 391 392
        log.info(
            u"Entering verified workflow for user '{user}', course '{course_id}', with current step '{current_step}'."
            .format(user=request.user, course_id=course_id, current_step=current_step)
        )

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
        # Check whether the user has verified, paid, and enrolled.
        # A user is considered "paid" if he or she has an enrollment
        # with a paid course mode (such as "verified").
        # For this reason, every paid user is enrolled, but not
        # every enrolled user is paid.
        already_verified = self._check_already_verified(request.user)
        already_paid, is_enrolled = self._check_enrollment(request.user, course_key)

        # Redirect the user to a more appropriate page if the
        # messaging won't make sense based on the user's
        # enrollment / payment / verification status.
        redirect_response = self._redirect_if_necessary(
            message,
            already_verified,
            already_paid,
            is_enrolled,
            course_key
        )
        if redirect_response is not None:
            return redirect_response

        display_steps = self._display_steps(
            always_show_payment,
            already_verified,
            already_paid
        )
419
        requirements = self._requirements(display_steps, request.user.is_active)
420

421 422 423
        if current_step is None:
            current_step = display_steps[0]['name']

424 425 426
        # Allow the caller to skip the first page
        # This is useful if we want the user to be able to
        # use the "back" button to return to the previous step.
427 428
        # This parameter should only work for known skip-able steps
        if request.GET.get('skip-first-step') and current_step in self.SKIP_STEPS:
429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446
            display_step_names = [step['name'] for step in display_steps]
            current_step_idx = display_step_names.index(current_step)
            if (current_step_idx + 1) < len(display_steps):
                current_step = display_steps[current_step_idx + 1]['name']

        courseware_url = ""
        if not course.start or course.start < datetime.datetime.today().replace(tzinfo=UTC):
            courseware_url = reverse(
                'course_root',
                kwargs={'course_id': unicode(course_key)}
            )

        full_name = (
            request.user.profile.name
            if request.user.profile.name
            else ""
        )

447 448 449 450 451 452
        # If the user set a contribution amount on another page,
        # use that amount to pre-fill the price selection form.
        contribution_amount = request.session.get(
            'donation_for_course', {}
        ).get(unicode(course_key), '')

453 454 455 456
        # Remember whether the user is upgrading
        # so we can fire an analytics event upon payment.
        request.session['attempting_upgrade'] = (message == self.UPGRADE_MSG)

457 458
        # Render the top-level page
        context = {
459
            'contribution_amount': contribution_amount,
460
            'course': course,
461
            'course_key': unicode(course_key),
462
            'course_mode': course_mode,
463
            'courseware_url': courseware_url,
464
            'current_step': current_step,
465 466
            'disable_courseware_js': True,
            'display_steps': display_steps,
467
            'is_active': json.dumps(request.user.is_active),
468 469 470 471
            'message_key': message,
            'platform_name': settings.PLATFORM_NAME,
            'purchase_endpoint': get_purchase_endpoint(),
            'requirements': requirements,
472 473 474 475 476
            'user_full_name': full_name,
            'verification_deadline': (
                get_default_time_display(course_mode.expiration_datetime)
                if course_mode.expiration_datetime else ""
            ),
477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556
        }
        return render_to_response("verify_student/pay_and_verify.html", context)

    def _redirect_if_necessary(
        self,
        message,
        already_verified,
        already_paid,
        is_enrolled,
        course_key
    ):
        """Redirect the user to a more appropriate page if necessary.

        In some cases, a user may visit this page with
        verification / enrollment / payment state that
        we don't anticipate.  For example, a user may unenroll
        from the course after paying for it, then visit the
        "verify now" page to complete verification.

        When this happens, we try to redirect the user to
        the most appropriate page.

        Arguments:

            message (string): The messaging of the page.  Should be a key
                in `MESSAGES`.

            already_verified (bool): Whether the user has submitted
                a verification request recently.

            already_paid (bool): Whether the user is enrolled in a paid
                course mode.

            is_enrolled (bool): Whether the user has an active enrollment
                in the course.

            course_key (CourseKey): The key for the course.

        Returns:
            HttpResponse or None

        """
        url = None
        course_kwargs = {'course_id': unicode(course_key)}

        if already_verified and already_paid:
            # If they've already paid and verified, there's nothing else to do,
            # so redirect them to the dashboard.
            if message != self.PAYMENT_CONFIRMATION_MSG:
                url = reverse('dashboard')
        elif message in [self.VERIFY_NOW_MSG, self.VERIFY_LATER_MSG, self.PAYMENT_CONFIRMATION_MSG]:
            if is_enrolled:
                # If the user is already enrolled but hasn't yet paid,
                # then the "upgrade" messaging is more appropriate.
                if not already_paid:
                    url = reverse('verify_student_upgrade_and_verify', kwargs=course_kwargs)
            else:
                # If the user is NOT enrolled, then send him/her
                # to the first time verification page.
                url = reverse('verify_student_start_flow', kwargs=course_kwargs)
        elif message == self.UPGRADE_MSG:
            if is_enrolled:
                # If upgrading and we've paid but haven't verified,
                # then the "verify later" messaging makes more sense.
                if already_paid:
                    url = reverse('verify_student_verify_later', kwargs=course_kwargs)
            else:
                url = reverse('verify_student_start_flow', kwargs=course_kwargs)

        # Redirect if necessary, otherwise implicitly return None
        if url is not None:
            return redirect(url)

    def _display_steps(self, always_show_payment, already_verified, already_paid):
        """Determine which steps to display to the user.

        Includes all steps by default, but removes steps
        if the user has already completed them.

        Arguments:
557

558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578
            always_show_payment (bool): If True, display the payment steps
                even if the user has already paid.

            already_verified (bool): Whether the user has submitted
                a verification request recently.

            already_paid (bool): Whether the user is enrolled in a paid
                course mode.

        Returns:
            list

        """
        display_steps = self.ALL_STEPS
        remove_steps = set()

        if already_verified:
            remove_steps |= set(self.VERIFICATION_STEPS)

        if already_paid and not always_show_payment:
            remove_steps |= set(self.PAYMENT_STEPS)
579 580 581 582
        else:
            # The "make payment" step doubles as an intro step,
            # so if we're showing the payment step, hide the intro step.
            remove_steps |= set([self.INTRO_STEP])
583 584 585 586 587

        return [
            {
                'name': step,
                'title': unicode(self.STEP_INFO[step].title),
588
                'templateName': self.STEP_INFO[step].template_name
589 590 591 592 593
            }
            for step in display_steps
            if step not in remove_steps
        ]

594
    def _requirements(self, display_steps, is_active):
595 596 597 598 599 600 601 602
        """Determine which requirements to show the user.

        For example, if the user needs to submit a photo
        verification, tell the user that she will need
        a photo ID and a webcam.

        Arguments:
            display_steps (list): The steps to display to the user.
603
            is_active (bool): If False, adds a requirement to activate the user account.
604 605 606 607 608 609 610

        Returns:
            dict: Keys are requirement names, values are booleans
                indicating whether to show the requirement.

        """
        all_requirements = {
611
            self.ACCOUNT_ACTIVATION_REQ: not is_active,
612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663
            self.PHOTO_ID_REQ: False,
            self.WEBCAM_REQ: False,
        }

        display_steps = set(step['name'] for step in display_steps)

        for step, step_requirements in self.STEP_REQUIREMENTS.iteritems():
            if step in display_steps:
                for requirement in step_requirements:
                    all_requirements[requirement] = True

        return all_requirements

    def _check_already_verified(self, user):
        """Check whether the user has a valid or pending verification.

        Note that this includes cases in which the user's verification
        has not been accepted (either because it hasn't been processed,
        or there was an error).

        This should return True if the user has done their part:
        submitted photos within the expiration period.

        """
        return SoftwareSecurePhotoVerification.user_has_valid_or_pending(user)

    def _check_enrollment(self, user, course_key):
        """Check whether the user has an active enrollment and has paid.

        If a user is enrolled in a paid course mode, we assume
        that the user has paid.

        Arguments:
            user (User): The user to check.
            course_key (CourseKey): The key of the course to check.

        Returns:
            Tuple `(has_paid, is_active)` indicating whether the user
            has paid and whether the user has an active account.

        """
        enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course_key)
        has_paid = False

        if enrollment_mode is not None and is_active:
            all_modes = CourseMode.modes_for_course_dict(course_key)
            course_mode = all_modes.get(enrollment_mode)
            has_paid = (course_mode and course_mode.min_price > 0)

        return (has_paid, bool(is_active))


664
@require_POST
665
@login_required
666
def create_order(request):
667 668 669
    """
    Submit PhotoVerification and create a new Order for this verified cert
    """
670 671 672 673 674 675 676 677
    # Only submit photos if photo data is provided by the client.
    # TODO (ECOM-188): Once the A/B test of decoupling verified / payment
    # completes, we may be able to remove photo submission from this step
    # entirely.
    submit_photo = (
        'face_image' in request.POST and
        'photo_id_image' in request.POST
    )
678 679 680 681 682

    if (
        submit_photo and not
        SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user)
    ):
683
        attempt = SoftwareSecurePhotoVerification(user=request.user)
684 685 686 687
        try:
            b64_face_image = request.POST['face_image'].split(",")[1]
            b64_photo_id_image = request.POST['photo_id_image'].split(",")[1]
        except IndexError:
688
            log.error(u"Invalid image data during photo verification.")
689 690 691 692
            context = {
                'success': False,
            }
            return JsonResponse(context)
693 694 695 696
        attempt.upload_face_image(b64_face_image.decode('base64'))
        attempt.upload_photo_id_image(b64_photo_id_image.decode('base64'))
        attempt.mark_ready()

697
        attempt.save()
698 699

    course_id = request.POST['course_id']
700
    course_id = CourseKey.from_string(course_id)
701
    donation_for_course = request.session.get('donation_for_course', {})
702 703
    current_donation = donation_for_course.get(unicode(course_id), decimal.Decimal(0))
    contribution = request.POST.get("contribution", donation_for_course.get(unicode(course_id), 0))
704 705 706 707
    try:
        amount = decimal.Decimal(contribution).quantize(decimal.Decimal('.01'), rounding=decimal.ROUND_DOWN)
    except decimal.InvalidOperation:
        return HttpResponseBadRequest(_("Selected price is not valid number."))
708

709
    if amount != current_donation:
710
        donation_for_course[unicode(course_id)] = amount
711 712
        request.session['donation_for_course'] = donation_for_course

713 714 715
    # prefer professional mode over verified_mode
    current_mode = CourseMode.verified_mode_for_course(course_id)

716
    # make sure this course has a verified mode
717
    if not current_mode:
718
        log.warn(u"Verification requested for course {course_id} without a verified mode.".format(course_id=course_id))
719 720
        return HttpResponseBadRequest(_("This course doesn't support verified certificates"))

721 722 723
    if current_mode.slug == 'professional':
        amount = current_mode.min_price

724
    if amount < current_mode.min_price:
725
        return HttpResponseBadRequest(_("No selected price or selected price is below minimum."))
726 727 728

    # I know, we should check this is valid. All kinds of stuff missing here
    cart = Order.get_cart_for_user(request.user)
729
    cart.clear()
730 731
    enrollment_mode = current_mode.slug
    CertificateItem.add_to_order(cart, course_id, amount, enrollment_mode)
732

733 734 735 736 737 738 739 740 741
    # Change the order's status so that we don't accidentally modify it later.
    # We need to do this to ensure that the parameters we send to the payment system
    # match what we store in the database.
    # (Ordinarily we would do this client-side when the user submits the form, but since
    # the JavaScript on this page does that immediately, we make the change here instead.
    # This avoids a second AJAX call and some additional complication of the JavaScript.)
    # If a user later re-enters the verification / payment flow, she will create a new order.
    cart.start_purchase()

742 743 744
    callback_url = request.build_absolute_uri(
        reverse("shoppingcart.views.postpay_callback")
    )
745

746
    params = get_signed_purchase_params(
Will Daly committed
747 748
        cart,
        callback_url=callback_url,
749
        extra_data=[unicode(course_id), current_mode.slug]
750
    )
751

752
    params['success'] = True
753
    return HttpResponse(json.dumps(params), content_type="text/json")
754

755

756
@require_POST
757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779
@login_required
def submit_photos_for_verification(request):
    """Submit a photo verification attempt.

    Arguments:
        request (HttpRequest): The request to submit photos.

    Returns:
        HttpResponse: 200 on success, 400 if there are errors.

    """
    # Check the required parameters
    missing_params = set(['face_image', 'photo_id_image']) - set(request.POST.keys())
    if len(missing_params) > 0:
        msg = _("Missing required parameters: {missing}").format(missing=", ".join(missing_params))
        return HttpResponseBadRequest(msg)

    # If the user already has valid or pending request, the UI will hide
    # the verification steps.  For this reason, we reject any requests
    # for users that already have a valid or pending verification.
    if SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user):
        return HttpResponseBadRequest(_("You already have a valid or pending verification."))

780 781
    username = request.user.username

782 783 784 785 786
    # If the user wants to change his/her full name,
    # then try to do that before creating the attempt.
    if request.POST.get('full_name'):
        try:
            profile_api.update_profile(
787
                username,
788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811
                full_name=request.POST.get('full_name')
            )
        except profile_api.ProfileUserNotFound:
            return HttpResponseBadRequest(_("No profile found for user"))
        except profile_api.ProfileInvalidField:
            msg = _(
                "Name must be at least {min_length} characters long."
            ).format(min_length=profile_api.FULL_NAME_MIN_LENGTH)
            return HttpResponseBadRequest(msg)

    # Create the attempt
    attempt = SoftwareSecurePhotoVerification(user=request.user)
    try:
        b64_face_image = request.POST['face_image'].split(",")[1]
        b64_photo_id_image = request.POST['photo_id_image'].split(",")[1]
    except IndexError:
        msg = _("Image data is not valid.")
        return HttpResponseBadRequest(msg)

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

812 813 814 815 816 817 818 819 820 821 822 823 824 825 826
    profile_dict = profile_api.profile_info(username)
    if profile_dict:
        # Send a confirmation email to the user
        context = {
            'full_name': profile_dict.get('full_name'),
            'platform_name': settings.PLATFORM_NAME
        }

        subject = _("Verification photos received")
        message = render_to_string('emails/photo_submission_confirmation.txt', context)
        from_address = microsite.get_value('default_from_email', settings.DEFAULT_FROM_EMAIL)
        to_address = profile_dict.get('email')

        send_mail(subject, message, from_address, [to_address], fail_silently=False)

827 828 829 830
    return HttpResponse(200)


@require_POST
831
@csrf_exempt  # SS does its own message signing, and their API won't have a cookie value
832 833 834 835 836 837
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
838 839 840 841 842 843 844 845 846 847 848

    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))

849 850 851 852 853 854 855 856 857 858 859 860 861
    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"]
    )

862
    _response, access_key_and_sig = headers["Authorization"].split(" ")
863 864 865 866 867 868 869 870 871
    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")
872 873 874 875 876 877

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

878 879 880 881 882 883 884 885
    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))
886
        attempt.approve()
887 888 889
    elif result == "FAIL":
        log.debug("Denying verification for {}".format(receipt_id))
        attempt.deny(json.dumps(reason), error_code=error_code)
890
    elif result == "SYSTEM FAIL":
891 892
        log.debug("System failure for {} -- resetting to must_retry".format(receipt_id))
        attempt.system_error(json.dumps(reason), error_code=error_code)
893
        log.error("Software Secure callback attempt for %s failed: %s", receipt_id, reason)
894 895 896 897 898
    else:
        log.error("Software Secure returned unknown result {}".format(result))
        return HttpResponseBadRequest(
            "Result {} not understood. Known results: PASS, FAIL, SYSTEM FAIL".format(result)
        )
899

900 901
    # If this is a reverification, log an event
    if attempt.window:
902
        course_id = attempt.window.course_id
903 904 905
        course_enrollment = CourseEnrollment.get_or_create_enrollment(attempt.user, course_id)
        course_enrollment.emit_event(EVENT_NAME_USER_REVERIFICATION_REVIEWED_BY_SOFTWARESECURE)

906
    return HttpResponse("OK!")
907

908

909 910 911
@login_required
def show_requirements(request, course_id):
    """
912
    Show the requirements necessary for the verification flow.
913
    """
914
    # TODO: seems borked for professional; we're told we need to take photos even if there's a pending verification
915
    course_id = CourseKey.from_string(course_id)
916
    upgrade = request.GET.get('upgrade', False)
917
    if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == ('verified', True):
918
        return redirect(reverse('dashboard'))
919 920
    if SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user):
        return redirect(
921 922 923 924
            reverse(
                'verify_student_verified',
                kwargs={'course_id': course_id.to_deprecated_string()}
            ) + "?upgrade={}".format(upgrade)
925
        )
926

927
    upgrade = request.GET.get('upgrade', False)
928
    course = modulestore().get_course(course_id)
929
    modes_dict = CourseMode.modes_for_course_dict(course_id)
930
    context = {
931 932 933
        "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()}),
934
        "course_name": course.display_name_with_default,
935 936
        "course_org": course.display_org_with_default,
        "course_num": course.display_number_with_default,
937
        "is_not_active": not request.user.is_active,
938
        "upgrade": upgrade == u'True',
939
        "modes_dict": modes_dict,
940
    }
941
    return render_to_response("verify_student/show_requirements.html", context)
942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996


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)

997

998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012
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
        """
1013
        course_id = CourseKey.from_string(course_id)
1014
        course = modulestore().get_course(course_id)
1015 1016 1017
        if course is None:
            raise Http404

1018 1019 1020
        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)
1021 1022 1023
        context = {
            "user_full_name": request.user.profile.name,
            "error": False,
1024
            "course_id": course_id.to_deprecated_string(),
1025 1026 1027 1028
            "course_name": course.display_name_with_default,
            "course_org": course.display_org_with_default,
            "course_num": course.display_number_with_default,
            "reverify": True,
1029
        }
1030

1031 1032 1033 1034 1035 1036 1037 1038 1039
        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)
1040
            course_id = CourseKey.from_string(course_id)
Julia Hansbrough committed
1041 1042 1043 1044
            window = MidcourseReverificationWindow.get_window(course_id, now)
            if window is None:
                raise WindowExpiredException
            attempt = SoftwareSecurePhotoVerification(user=request.user, window=window)
1045 1046 1047 1048 1049 1050 1051 1052
            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()
1053 1054 1055
            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)
1056
            return HttpResponseRedirect(reverse('verify_student_midcourse_reverification_confirmation'))
Julia Hansbrough committed
1057 1058 1059 1060 1061 1062 1063

        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'))

1064 1065 1066 1067 1068 1069 1070 1071 1072 1073
        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)

1074

1075
@login_required
1076
def midcourse_reverify_dash(request):
1077 1078 1079 1080
    """
    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.
    """
1081
    user = request.user
1082 1083 1084
    course_enrollment_pairs = []
    for enrollment in CourseEnrollment.enrollments_for_user(user):
        try:
1085
            course_enrollment_pairs.append((modulestore().get_course(enrollment.course_id), enrollment))
1086
        except ItemNotFoundError:
1087
            log.error("User {0} enrolled in non-existent course {1}".format(user.username, enrollment.course_id))
1088

1089 1090 1091
    statuses = ["approved", "pending", "must_reverify", "denied"]

    reverifications = reverification_info(course_enrollment_pairs, user, statuses)
1092

1093
    context = {
1094
        "user_full_name": user.profile.name,
1095
        'reverifications': reverifications,
1096
        'referer': request.META.get('HTTP_REFERER'),
1097
        'billing_email': settings.PAYMENT_SUPPORT_EMAIL,
1098 1099
    }
    return render_to_response("verify_student/midcourse_reverify_dash.html", context)
1100

1101

1102 1103
@login_required
@require_POST
1104 1105 1106 1107 1108
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.
    """
1109
    user_id = request.user.id
1110
    SoftwareSecurePhotoVerification.display_off(user_id)
1111 1112
    return HttpResponse('Success')

1113

1114 1115 1116 1117 1118 1119
@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")
1120

1121

1122
@login_required
1123
def midcourse_reverification_confirmation(_request):  # pylint: disable=invalid-name
1124 1125 1126 1127
    """
    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
1128 1129 1130 1131 1132 1133 1134 1135 1136 1137


@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")