diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 4ebbcff592..75c9b75821 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -25,14 +25,13 @@ from django.core.validators import validate_email, validate_slug, ValidationErro from django.core.exceptions import ObjectDoesNotExist from django.db import IntegrityError, transaction from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, - HttpResponseNotAllowed, Http404) + Http404) from django.shortcuts import redirect from django_future.csrf import ensure_csrf_cookie -from django.utils.http import cookie_date, base36_to_int, urlencode +from django.utils.http import cookie_date, base36_to_int from django.utils.translation import ugettext as _ from django.views.decorators.http import require_POST, require_GET from django.contrib.admin.views.decorators import staff_member_required -from django.utils.translation import ugettext as _u from ratelimitbackend.exceptions import RateLimitException @@ -47,6 +46,8 @@ from student.models import ( ) from student.forms import PasswordResetFormNoActive +from verify_student.models import SoftwareSecurePhotoVerification + from certificates.models import CertificateStatuses, certificate_status_for_student from xmodule.course_module import CourseDescriptor @@ -334,6 +335,8 @@ def dashboard(request): CourseAuthorization.instructor_email_enabled(course.id) ) ) + # Verification Attempts + verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user) # get info w.r.t ExternalAuthMap external_auth_map = None try: @@ -351,6 +354,8 @@ def dashboard(request): 'all_course_modes': course_modes, 'cert_statuses': cert_statuses, 'show_email_settings_for': show_email_settings_for, + 'verification_status': verification_status, + 'verification_msg': verification_msg, } return render_to_response('dashboard.html', context) @@ -657,11 +662,11 @@ def manage_user_standing(request): row = [user.username, user.standing.all()[0].changed_by] rows.append(row) - context = {'headers': headers, 'rows': rows} return render_to_response("manage_user_standing.html", context) + @require_POST @login_required @ensure_csrf_cookie @@ -675,34 +680,34 @@ def disable_account_ajax(request): username = request.POST.get('username') context = {} if username is None or username.strip() == '': - context['message'] = _u('Please enter a username') + context['message'] = _('Please enter a username') return JsonResponse(context, status=400) account_action = request.POST.get('account_action') if account_action is None: - context['message'] = _u('Please choose an option') + context['message'] = _('Please choose an option') return JsonResponse(context, status=400) username = username.strip() try: user = User.objects.get(username=username) except User.DoesNotExist: - context['message'] = _u("User with username {} does not exist").format(username) + context['message'] = _("User with username {} does not exist").format(username) return JsonResponse(context, status=400) else: - user_account, _ = UserStanding.objects.get_or_create( + user_account, _success = UserStanding.objects.get_or_create( user=user, defaults={'changed_by': request.user}, ) if account_action == 'disable': user_account.account_status = UserStanding.ACCOUNT_DISABLED - context['message'] = _u("Successfully disabled {}'s account").format(username) + context['message'] = _("Successfully disabled {}'s account").format(username) log.info("{} disabled {}'s account".format(request.user, username)) elif account_action == 'reenable': user_account.account_status = UserStanding.ACCOUNT_ENABLED - context['message'] = _u("Successfully reenabled {}'s account").format(username) + context['message'] = _("Successfully reenabled {}'s account").format(username) log.info("{} reenabled {}'s account".format(request.user, username)) else: - context['message'] = _u("Unexpected account status") + context['message'] = _("Unexpected account status") return JsonResponse(context, status=400) user_account.changed_by = request.user user_account.standing_last_changed_at = datetime.datetime.now(UTC) diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 39ea4106ff..523e8393a4 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -26,6 +26,7 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.db import models from django.contrib.auth.models import User +from django.utils.translation import ugettext as _ from model_utils.models import StatusModel from model_utils import Choices @@ -114,9 +115,6 @@ class PhotoVerification(StatusModel): attempt.status == "created" pending_requests = PhotoVerification.submitted.all() """ - # We can make this configurable later... - DAYS_GOOD_FOR = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] - ######################## Fields Set During Creation ######################## # See class docstring for description of status states STATUS = Choices('created', 'ready', 'submitted', 'must_retry', 'approved', 'denied') @@ -174,6 +172,18 @@ class PhotoVerification(StatusModel): ordering = ['-created_at'] ##### Methods listed in the order you'd typically call them + @classmethod + def _earliest_allowed_date(cls): + """ + Returns the earliest allowed date given the settings + + """ + DAYS_GOOD_FOR = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] + allowed_date = ( + datetime.now(pytz.UTC) - timedelta(days=DAYS_GOOD_FOR) + ) + return allowed_date + @classmethod def user_is_verified(cls, user, earliest_allowed_date=None): """ @@ -181,14 +191,11 @@ class PhotoVerification(StatusModel): identity. Depending on the policy, this can expire after some period of time, so a user might have to renew periodically. """ - earliest_allowed_date = ( - earliest_allowed_date or - datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR) - ) return cls.objects.filter( user=user, status="approved", - created_at__gte=earliest_allowed_date + created_at__gte=(earliest_allowed_date + or cls._earliest_allowed_date()) ).exists() @classmethod @@ -201,14 +208,11 @@ class PhotoVerification(StatusModel): on the contents of the attempt, and we have not yet received a denial. """ valid_statuses = ['must_retry', 'submitted', 'approved'] - earliest_allowed_date = ( - earliest_allowed_date or - datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR) - ) return cls.objects.filter( user=user, status__in=valid_statuses, - created_at__gte=earliest_allowed_date + created_at__gte=(earliest_allowed_date + or cls._earliest_allowed_date()) ).exists() @classmethod @@ -225,6 +229,55 @@ class PhotoVerification(StatusModel): else: return None + @classmethod + def user_status(cls, user): + """ + Returns the status of the user based on their past verification attempts + + If no such verification exists, returns 'none' + If verification has expired, returns 'expired' + If the verification has been approved, returns 'approved' + If the verification process is still ongoing, returns 'pending' + If the verification has been denied and the user must resubmit photos, returns 'must_reverify' + """ + status = 'none' + error_msg = '' + + if cls.user_is_verified(user): + status = 'approved' + elif cls.user_has_valid_or_pending(user): + # user_has_valid_or_pending does include 'approved', but if we are + # here, we know that the attempt is still pending + status = 'pending' + else: + # we need to check the most recent attempt to see if we need to ask them to do + # a retry + try: + attempts = cls.objects.filter(user=user).order_by('-updated_at') + attempt = attempts[0] + except IndexError: + return ('none', error_msg) + if attempt.created_at < cls._earliest_allowed_date(): + return ('expired', error_msg) + + # right now, this is the only state at which they must reverify. It + # may change later + if attempt.status == 'denied': + status = 'must_reverify' + if attempt.error_msg: + error_msg = attempt.parsed_error_msg() + + return (status, error_msg) + + def parsed_error_msg(self): + """ + Sometimes, the error message we've received needs to be parsed into + something more human readable + + The default behavior is to return the current error message as is. + """ + return self.error_msg + @status_before_must_be("created") def upload_face_image(self, img): raise NotImplementedError @@ -486,6 +539,42 @@ class SoftwareSecurePhotoVerification(PhotoVerification): self.status = "must_retry" self.save() + def parsed_error_msg(self): + """ + Parse the error messages we receive from SoftwareSecure + + Error messages are written in the form: + + `[{"photoIdReasons": ["Not provided"]}]` + + Returns a list of error messages + """ + # Translates the category names and messages into something more human readable + message_dict = { + ("photoIdReasons", "Not provided"): _("No photo ID was provided."), + ("photoIdReasons", "Text not clear"): _("We couldn't read your name from your photo ID image."), + ("generalReasons", "Name mismatch"): _("The name associated with your account and the name on your ID do not match."), + ("userPhotoReasons", "Image not clear"): _("The image of your face was not clear."), + ("userPhotoReasons", "Face out of view"): _("Your face was not visible in your self-photo"), + } + + try: + msg_json = json.loads(self.error_msg) + msg_dict = msg_json[0] + + msg = [] + for category in msg_dict: + # find the messages associated with this category + category_msgs = msg_dict[category] + for category_msg in category_msgs: + msg.append(message_dict[(category, category_msg)]) + return u", ".join(msg) + except (ValueError, KeyError): + # if we can't parse the message as JSON or the category doesn't + # match one of our known categories, show a generic error + log.error('PhotoVerification: Error parsing this error message: %s', self.error_msg) + return _("There was an error verifying your ID photos.") + def image_url(self, name): """ We dynamically generate this, since we want it the expiration clock to diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index 40842cdcdb..fd2b767859 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -17,11 +17,11 @@ from util.testing import UrlResetMixin import verify_student.models FAKE_SETTINGS = { - "SOFTWARE_SECURE" : { + "SOFTWARE_SECURE": { "FACE_IMAGE_AES_KEY" : "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "API_ACCESS_KEY" : "BBBBBBBBBBBBBBBBBBBB", - "API_SECRET_KEY" : "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", - "RSA_PUBLIC_KEY" : """-----BEGIN PUBLIC KEY----- + "API_ACCESS_KEY": "BBBBBBBBBBBBBBBBBBBB", + "API_SECRET_KEY": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "RSA_PUBLIC_KEY": """-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu2fUn20ZQtDpa1TKeCA/ rDA2cEeFARjEr41AP6jqP/k3O7TeqFX6DgCBkxcjojRCs5IfE8TimBHtv/bcSx9o 7PANTq/62ZLM9xAMpfCcU6aAd4+CVqQkXSYjj5TUqamzDFBkp67US8IPmw7I2Gaa @@ -30,10 +30,10 @@ dyZCM9pBcvcH+60ma+nNg8GVGBAW/oLxILBtg+T3PuXSUvcu/r6lUFMHk55pU94d 9A/T8ySJm379qU24ligMEetPk1o9CUasdaI96xfXVDyFhrzrntAmdD+HYCSPOQHz iwIDAQAB -----END PUBLIC KEY-----""", - "API_URL" : "http://localhost/verify_student/fake_endpoint", - "AWS_ACCESS_KEY" : "FAKEACCESSKEY", - "AWS_SECRET_KEY" : "FAKESECRETKEY", - "S3_BUCKET" : "fake-bucket" + "API_URL": "http://localhost/verify_student/fake_endpoint", + "AWS_ACCESS_KEY": "FAKEACCESSKEY", + "AWS_SECRET_KEY": "FAKESECRETKEY", + "S3_BUCKET": "fake-bucket" } } @@ -57,11 +57,13 @@ class MockKey(object): def generate_url(self, duration): return "http://fake-edx-s3.edx.org/" + class MockBucket(object): """Mocking a boto S3 Bucket object.""" def __init__(self, name): self.name = name + class MockS3Connection(object): """Mocking a boto S3 Connection""" def __init__(self, access_key, secret_key): @@ -165,14 +167,14 @@ class TestPhotoVerification(TestCase): # approved assert_raises(VerificationException, attempt.submit) - attempt.approve() # no-op - attempt.system_error("System error") # no-op, something processed it without error + attempt.approve() # no-op + attempt.system_error("System error") # no-op, something processed it without error attempt.deny(DENY_ERROR_MSG) # denied assert_raises(VerificationException, attempt.submit) - attempt.deny(DENY_ERROR_MSG) # no-op - attempt.system_error("System error") # no-op, something processed it without error + attempt.deny(DENY_ERROR_MSG) # no-op + attempt.system_error("System error") # no-op, something processed it without error attempt.approve() def test_name_freezing(self): @@ -307,3 +309,56 @@ class TestPhotoVerification(TestCase): attempt.save() assert_true(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user), status) + def test_user_status(self): + # test for correct status when no error returned + user = UserFactory.create() + status = SoftwareSecurePhotoVerification.user_status(user) + self.assertEquals(status, ('none', '')) + + # test for when one has been created + attempt = SoftwareSecurePhotoVerification(user=user) + attempt.status = 'approved' + attempt.save() + + status = SoftwareSecurePhotoVerification.user_status(user) + self.assertEquals(status, ('approved', '')) + + # create another one for the same user, make sure the right one is + # returned + attempt2 = SoftwareSecurePhotoVerification(user=user) + attempt2.status = 'denied' + attempt2.error_msg = '[{"photoIdReasons": ["Not provided"]}]' + attempt2.save() + + status = SoftwareSecurePhotoVerification.user_status(user) + self.assertEquals(status, ('approved', '')) + + # now delete the first one and verify that the denial is being handled + # properly + attempt.delete() + status = SoftwareSecurePhotoVerification.user_status(user) + self.assertEquals(status, ('must_reverify', "No photo ID was provided.")) + + def test_parse_error_msg_success(self): + user = UserFactory.create() + attempt = SoftwareSecurePhotoVerification(user=user) + attempt.status = 'denied' + attempt.error_msg = '[{"photoIdReasons": ["Not provided"]}]' + parsed_error_msg = attempt.parsed_error_msg() + self.assertEquals("No photo ID was provided.", parsed_error_msg) + + def test_parse_error_msg_failure(self): + user = UserFactory.create() + attempt = SoftwareSecurePhotoVerification(user=user) + attempt.status = 'denied' + # when we can't parse into json + bad_messages = { + 'Not Provided', + '[{"IdReasons": ["Not provided"]}]', + '{"IdReasons": ["Not provided"]}', + u'[{"ïḋṚëäṡöṅṡ": ["Ⓝⓞⓣ ⓟⓡⓞⓥⓘⓓⓔⓓ "]}]', + } + for msg in bad_messages: + attempt.error_msg = msg + parsed_error_msg = attempt.parsed_error_msg() + self.assertEquals(parsed_error_msg, "There was an error verifying your ID photos.") diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 35efc583c6..43cbff15bd 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -10,20 +10,31 @@ verify_student/start?course_id=MITx/6.002x/2013_Spring # create """ import urllib +from mock import patch, Mock, ANY from django.test import TestCase from django.test.utils import override_settings +from django.conf import settings from django.core.urlresolvers import reverse +from django.core.exceptions import ObjectDoesNotExist from xmodule.modulestore.tests.factories import CourseFactory from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from student.tests.factories import UserFactory from course_modes.models import CourseMode +from verify_student.views import render_to_response +from verify_student.models import SoftwareSecurePhotoVerification + + +def mock_render_to_response(*args, **kwargs): + return render_to_response(*args, **kwargs) + +render_mock = Mock(side_effect=mock_render_to_response) class StartView(TestCase): - def start_url(course_id=""): + def start_url(self, course_id=""): return "/verify_student/{0}".format(urllib.quote(course_id)) def test_start_new_verification(self): @@ -58,3 +69,44 @@ class TestVerifyView(TestCase): response = self.client.get(url) self.assertEquals(response.status_code, 302) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestReverifyView(TestCase): + """ + Tests for the reverification views + + """ + def setUp(self): + self.user = UserFactory.create(username="rusty", password="test") + self.client.login(username="rusty", password="test") + + @patch('verify_student.views.render_to_response', render_mock) + def test_reverify_get(self): + url = reverse('verify_student_reverify') + response = self.client.get(url) + self.assertEquals(response.status_code, 200) + ((_template, context), _kwargs) = render_mock.call_args + self.assertFalse(context['error']) + + @patch('verify_student.views.render_to_response', render_mock) + def test_reverify_post_failure(self): + url = reverse('verify_student_reverify') + response = self.client.post(url, {'face_image': '', + 'photo_id_image': ''}) + self.assertEquals(response.status_code, 200) + ((template, context), _kwargs) = render_mock.call_args + self.assertIn('photo_reverification', template) + self.assertTrue(context['error']) + + @patch.dict(settings.MITX_FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) + def test_reverify_post_success(self): + url = reverse('verify_student_reverify') + response = self.client.post(url, {'face_image': ',', + 'photo_id_image': ','}) + self.assertEquals(response.status_code, 302) + try: + verification_attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user) + self.assertIsNotNone(verification_attempt) + except ObjectDoesNotExist: + self.fail('No verification object generated') diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index 15c2cc5f6b..843ebf9602 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -35,4 +35,15 @@ urlpatterns = patterns( name="verify_student_results_callback", ), + url( + r'^reverify$', + views.ReverifyView.as_view(), + name="verify_student_reverify" + ), + + url( + r'^reverification_confirmation$', + views.reverification_submission_confirmation, + name="verify_student_reverification_confirmation" + ), ) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 8fafc26834..673564e72c 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -267,3 +267,66 @@ def show_requirements(request, course_id): "upgrade": upgrade, } return render_to_response("verify_student/show_requirements.html", context) + + +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) + + +@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") diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index e1976f32e7..76a2b81926 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -91,10 +91,6 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True # Use the auto_auth workflow for creating users and logging them in MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True -# Don't actually send any requests to Software Secure for student identity -# verification. -MITX_FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True - # Enable fake payment processing page MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True @@ -102,6 +98,9 @@ MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True MITX_FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False +# Don't actually send any requests to Software Secure for student identity +# verification. +MITX_FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True # Configure the payment processor to use the fake processing page # Since both the fake payment page and the shoppingcart app are using diff --git a/lms/static/js/verify_student/photocapture.js b/lms/static/js/verify_student/photocapture.js index 0519767841..4db15e9d51 100644 --- a/lms/static/js/verify_student/photocapture.js +++ b/lms/static/js/verify_student/photocapture.js @@ -18,6 +18,23 @@ function initVideoCapture() { return !(navigator.getUserMedia == undefined); } +var submitReverificationPhotos = function() { + // add photos to the form + $('').attr({ + type: 'hidden', + name: 'face_image', + value: $("#face_image")[0].src, + }).appendTo("#reverify_form"); + $('').attr({ + type: 'hidden', + name: 'photo_id_image', + value: $("#photo_id_image")[0].src, + }).appendTo("#reverify_form"); + + $("#reverify_form").submit(); + +} + var submitToPaymentProcessing = function() { var contribution_input = $("input[name='contribution']:checked") var contribution = 0; @@ -255,10 +272,15 @@ $(document).ready(function() { submitToPaymentProcessing(); }); + $("#reverify_button").click(function() { + submitReverificationPhotos(); + }); + // prevent browsers from keeping this button checked $("#confirm_pics_good").prop("checked", false) $("#confirm_pics_good").change(function() { $("#pay_button").toggleClass('disabled'); + $("#reverify_button").toggleClass('disabled'); }); diff --git a/lms/static/sass/elements/_controls.scss b/lms/static/sass/elements/_controls.scss index 4846a14ae5..b1e323effc 100644 --- a/lms/static/sass/elements/_controls.scss +++ b/lms/static/sass/elements/_controls.scss @@ -84,6 +84,23 @@ } } +// blue primary error color +%btn-primary-error { + @extend %btn-primary; + box-shadow: 0 2px 1px 0 shade($error-color, 25%); + background: shade($error-color, 25%); + color: $white; + + &:hover, &:active { + background: $error-color; + color: $white; + } + + &.disabled, &[disabled] { + box-shadow: none; + } +} + // blue primary button %btn-primary-blue { @extend %btn-primary; diff --git a/lms/static/sass/elements/_typography.scss b/lms/static/sass/elements/_typography.scss index 68a3cd50df..2d677700c6 100644 --- a/lms/static/sass/elements/_typography.scss +++ b/lms/static/sass/elements/_typography.scss @@ -278,7 +278,7 @@ %copy-badge { @extend %t-title8; - @extend %t-weight5; + @extend %t-weight3; border-radius: ($baseline/5); padding: ($baseline/2) $baseline; text-transform: uppercase; diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 47582dd538..0b4f33efa2 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -75,6 +75,12 @@ margin-bottom: 15px; padding-bottom: 17px; + &:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; + } + &:hover, &:focus { .title .icon { opacity: 1.0; @@ -760,4 +766,169 @@ margin-right: 10px; } } + + // account-related + .user-info { + + // status + .status { + + .list--nav { + margin: ($baseline/2) 0 0 0; + padding: 0; + } + + .nav__item { + @extend %t-weight4; + @include font-size(13); + margin-left: 26px; + } + } + } + + // status - verification + .status-verification { + + .status-title { + margin: 0 0 ($baseline/4) 26px; + } + + .status-data { + margin: 0 0 ($baseline/2) 26px; + width: 80%; + } + + .status-data-message { + @extend %t-copy-sub1; + @extend %t-weight4; + margin-bottom: ($baseline/2); + } + + .list-actions { + @extend %ui-no-list; + + .action { + @extend %t-weight4; + display: block; + @include font-size(14); + } + } + + .status-note { + @extend %t-copy-sub2; + position: relative; + margin-top: $baseline; + border-top: 1px solid $black-t0; + padding-top: ($baseline/2); + + p { + @extend %t-copy-sub2; + } + + .deco-arrow { + @include triangle(($baseline/2), $m-gray-d3, up); + position: absolute; + left: 45%; + top: -($baseline/2); + } + } + + // CASE: is denied + &.is-denied { + + .status-data-message { + color: $error-color; + border-bottom-color: rgba($error-color, 0.25); + } + + .status-note { + color: desaturate($error-color, 65%); + border-top-color: rgba($error-color, 0.25); + } + + .action-reverify { + @extend %btn-primary-error; + @extend %t-weight4; + display: block; + @include font-size(14); + } + + .deco-arrow { + @include triangle(($baseline/2), $error-color, up); + } + } + + // CASE: is accepted + &.is-accepted { + + .status-data-message { + color: $verified-color-lvl1; + border-bottom-color: $verified-color-lvl4; + } + + .status-note { + color: $m-gray-l1; + border-top-color: $verified-color-lvl4; + } + + .deco-arrow { + @include triangle(($baseline/2), $verified-color-lvl4, up); + } + } + + // CASE: is pending + &.is-pending { + + .status-data-message { + color: $m-gray-d3; + border-bottom-color: $m-gray-l4; + } + + .status-note { + color: $m-gray-l1; + border-top-color: $m-gray-d3; + } + } + } + + // status - verification + .status--verification { + + .data { + white-space: normal !important; + text-overflow: no !important; + overflow: visible !important; + } + + .list--nav { + margin-left: 26px; + } + + // STATE: is denied + &.is-denied { + + .data { + color: $error-color !important; + } + } + } + + // message + .msg { + margin: ($baseline/2) 0 ($baseline/2) 26px; + } + + .msg__title { + @extend %hd-lv5; + color: $lighter-base-font-color; + } + + .msg__copy { + @extend %copy-metadata; + color: $lighter-base-font-color; + + p { + @extend %t-copy; + } + } } diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 43950d6f41..9bd1274928 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -226,7 +226,7 @@ } // reset: lists - .list-actions, .list-steps, .progress-steps, .list-controls, .list-fields, .list-help, .list-faq, .nav-wizard, .list-reqs, .list-faq, .review-tasks, .list-tips, .wrapper-photos, .field-group, .list-info { + .list-actions, .list-steps, .progress-steps, .list-controls, .list-fields, .list-nav, .list-help, .list-faq, .nav-wizard, .list-reqs, .list-faq, .review-tasks, .list-tips, .wrapper-photos, .field-group, .list-info { @extend %ui-no-list; } @@ -358,15 +358,22 @@ // UI : message .wrapper-msg { - width: flex-grid(12,12); - margin: 0 auto ($baseline*1.5) auto; + margin-bottom: ($baseline*1.5); border-bottom: ($baseline/4) solid $m-blue; padding: $baseline ($baseline*1.5); background: tint($m-blue, 95%); + .msg { + @include clearfix(); + max-width: grid-width(12); + min-width: 760px; + width: flex-grid(12); + margin: 0 auto; + } + .msg-content, .msg-icon { - display: inline-block; - vertical-align: middle; + display: block; + float: left; } .msg-content { @@ -385,6 +392,7 @@ .msg-icon { width: flex-grid(1,12); @extend %t-icon2; + margin-right: flex-gutter(); text-align: center; color: $m-blue; } @@ -620,6 +628,38 @@ // ==================== + // UI: reverification message + .wrapper-reverification { + border-bottom: ($baseline/10) solid $m-pink; + margin-bottom: $baseline; + padding-bottom: $baseline; + position: relative; + + .deco-arrow { + @include triangle($baseline, $m-pink, down); + position: absolute; + bottom: -($baseline); + left: 50%; + } + } + + .reverification { + + .message { + + .title { + @extend %hd-lv3; + color: $m-pink; + } + + .copy { + @extend %t-copy-sub1; + } + } + } + + // ==================== + // UI: slides .carousel { @@ -697,6 +737,10 @@ padding-bottom: 0; } } + + .help-item-emphasis { + @extend %t-weight4; + } } // help - faq @@ -1851,6 +1895,7 @@ } } } +// ==================== // STATE: already verified .register.is-verified { @@ -1904,6 +1949,8 @@ } } +// ==================== + // STATE: upgrading registration type .register.is-upgrading { @@ -1911,3 +1958,141 @@ margin-top: ($baseline*2) !important; } } + +// STATE: re-verifying +.register.is-not-verified { + + .help-item-emphasis { + color: $m-pink; + } + + // progress indicator + .progress-sts { + width: 72%; + left: 15%; + } + + // VIEW: photo + &.step-photos { + + // progress nav + .progress .progress-step { + + // STATE: is current + &#progress-step1 { + border-bottom: ($baseline/5) solid $m-blue-d1; + opacity: 1.0; + + .wrapper-step-number { + border-color: $m-blue-d1; + } + + .step-number, .step-name { + color: $m-gray-d3; + } + } + } + + .progress-sts-value { + width: 0% !important; + } + } + + // VIEW: ID + &.step-photos-id { + + // progress nav + .progress .progress-step { + + // STATE: is completed + &#progress-step1 { + border-bottom: ($baseline/5) solid $verified-color-lvl3; + + .wrapper-step-number { + border-color: $verified-color-lvl3; + } + + .step-number, .step-name { + color: $m-gray-l3; + } + } + + // STATE: is current + &#progress-step2 { + border-bottom: ($baseline/5) solid $m-blue-d1; + opacity: 1.0; + + .wrapper-step-number { + border-color: $m-blue-d1; + } + + .step-number, .step-name { + color: $m-gray-d3; + } + } + } + + .progress-sts-value { + width: 40% !important; + } + } + + // VIEW: REVIEW + &.step-review { + + // progress nav + .progress .progress-step { + + // STATE: is completed + &#progress-step1, &#progress-step2 { + border-bottom: ($baseline/5) solid $verified-color-lvl3; + + .wrapper-step-number { + border-color: $verified-color-lvl3; + } + + .step-number, .step-name { + color: $m-gray-l3; + } + } + + // STATE: is current + &#progress-step3 { + border-bottom: ($baseline/5) solid $m-blue-d1; + opacity: 1.0; + + .wrapper-step-number { + border-color: $m-blue-d1; + } + + .step-number, .step-name { + color: $m-gray-d3; + } + } + } + + .progress-sts-value { + width: 70% !important; + } + } + + &.step-confirmation { + + .content-confirmation { + margin-bottom: ($baseline*2); + } + + .view { + + .title { + @extend %hd-lv2; + color: $m-blue-d1; + } + + .instruction { + @extend %t-copy-lead1; + margin-bottom: $baseline; + } + } + } +} diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 3310451c94..eb657d13e4 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -150,10 +150,10 @@
@@ -188,7 +190,7 @@ <% cert_status = cert_statuses.get(course.id) %> <% show_email_settings = (course.id in show_email_settings_for) %> <% course_mode_info = all_course_modes.get(course.id) %> - <%include file='dashboard/dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info" /> + <%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info" /> % endfor diff --git a/lms/templates/dashboard/dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html similarity index 98% rename from lms/templates/dashboard/dashboard_course_listing.html rename to lms/templates/dashboard/_dashboard_course_listing.html index 0a7925e93b..c43e7e79de 100644 --- a/lms/templates/dashboard/dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -57,7 +57,7 @@ - % if course.has_ended() and cert_status: + % if course.has_ended() and cert_status and not enrollment.mode == 'audit': <% if cert_status['status'] == 'generating': status_css_class = 'course-status-certrendering' diff --git a/lms/templates/dashboard/_dashboard_status_verification.html b/lms/templates/dashboard/_dashboard_status_verification.html new file mode 100644 index 0000000000..65ccedb323 --- /dev/null +++ b/lms/templates/dashboard/_dashboard_status_verification.html @@ -0,0 +1,63 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! + from django.core.urlresolvers import reverse +%> + +<%namespace name='static' file='../static_content.html'/> + +%if verification_status == 'approved': +
  • + ${_("ID-Verification Status")} + +
    + ${_("Reviewed and Verified")} + +
    + + +

    ${_("Your verification status is good for one year after submission.")}

    +
    +
    +
  • +%endif + +%if verification_status == 'pending': +
  • + ${_("ID-Verification Status")} + +
    + ${_("Pending")} + +
    + + +

    ${_("Your verification photos have been submitted and will be reviewed shortly.")}

    +
    +
    +
  • + +%endif + +%if verification_status == 'must_reverify': +
  • + ${_("ID-Verification Status")} + +
    + ${verification_msg} + + + +
    + + +

    ${_("If you fail to pass a verification attempt before your course ends, you will not receive a verified certificate.")}

    +
    +
    +
  • +%endif + + diff --git a/lms/templates/verify_student/_reverification_support.html b/lms/templates/verify_student/_reverification_support.html new file mode 100644 index 0000000000..14b249762e --- /dev/null +++ b/lms/templates/verify_student/_reverification_support.html @@ -0,0 +1,33 @@ +<%! from django.utils.translation import ugettext as _ %> + +
    + +
    diff --git a/lms/templates/verify_student/photo_reverification.html b/lms/templates/verify_student/photo_reverification.html new file mode 100644 index 0000000000..4203afe773 --- /dev/null +++ b/lms/templates/verify_student/photo_reverification.html @@ -0,0 +1,395 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="bodyclass">register verification-process is-not-verified step-photos +<%block name="title">${_("Re-Verification")} + +<%block name="js_extra"> + + + + + + +<%block name="content"> + + + + + +%if error: +
    +
    + +
    +

    ${_("Error submitting your images")}

    +
    +

    ${_("Oops! Something went wrong. Please confirm your details and try again.")}

    +
    +
    +
    +
    +%endif + +
    +
    + +
    +
    +
    +

    ${_("Please Resubmit Your Verification Information")}

    +
    +

    ${_("There was an error with your previous verification. In order proceed in the verified certificate of achievement track of your current courses, please complete the following steps.")}

    +
    +
    + + +
    +
    + +
    +
    +

    ${_("Your Progress")}

    + +
      +
    1. + 1 + ${_("Current Step: ")}${_("Re-Take Photo")} +
    2. + +
    3. + 2 + ${_("Re-Take ID Photo")} +
    4. + +
    5. + 3 + ${_("Review")} +
    6. + +
    7. + + + + ${_("Confirmation")} +
    8. +
    + + + + +
    +
    + +
    +
    + + +
    +
    + + <%include file="_reverification_support.html" /> +
    +
    + +<%include file="_modal_editname.html" /> + diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 92b976ce51..8e754452c0 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -67,7 +67,6 @@

    ${_("Your Progress")}

    -
    1. 0 diff --git a/lms/templates/verify_student/reverification_confirmation.html b/lms/templates/verify_student/reverification_confirmation.html new file mode 100644 index 0000000000..5b2ee17333 --- /dev/null +++ b/lms/templates/verify_student/reverification_confirmation.html @@ -0,0 +1,78 @@ + +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="bodyclass">register verification-process is-not-verified step-confirmation +<%block name="title">${_("Re-Verification Submission Confirmation")} + +<%block name="js_extra"> + + + +<%block name="content"> + +
      +
      + +
      +
      +

      ${_("Your Progress")}

      + +
        +
      1. + 1 + ${_("Re-Take Photo")} +
      2. + +
      3. + 2 + ${_("Re-Take ID Photo")} +
      4. + +
      5. + 3 + ${_("Review")} +
      6. + +
      7. + + + + ${_("Current Step: ")}${_("Confirmation")} +
      8. +
      + + + + +
      +
      + +
      +
      +
      +
      +
      +

      ${_("Your Credentials Have Been Updated")}

      + +
      +

      ${_("We've captured your re-submitted information and will review it to verify your identity shortly. You should receive an update to your veriication status within 1-2 days. In the meantime, you still have access to all of your course content.")}

      +
      + +
        + +
      +
      +
      +
      +
      +
      + + <%include file="_reverification_support.html" /> +
      +
      +