Commit 00185687 by Diana Huang

Merge pull request #1555 from edx/diana/certs-reverification-path

Verified Certificate Reverification Path
parents 97d32acd b5ec2c72
...@@ -25,14 +25,13 @@ from django.core.validators import validate_email, validate_slug, ValidationErro ...@@ -25,14 +25,13 @@ from django.core.validators import validate_email, validate_slug, ValidationErro
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden,
HttpResponseNotAllowed, Http404) Http404)
from django.shortcuts import redirect from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie 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.utils.translation import ugettext as _
from django.views.decorators.http import require_POST, require_GET from django.views.decorators.http import require_POST, require_GET
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from django.utils.translation import ugettext as _u
from ratelimitbackend.exceptions import RateLimitException from ratelimitbackend.exceptions import RateLimitException
...@@ -47,6 +46,8 @@ from student.models import ( ...@@ -47,6 +46,8 @@ from student.models import (
) )
from student.forms import PasswordResetFormNoActive from student.forms import PasswordResetFormNoActive
from verify_student.models import SoftwareSecurePhotoVerification
from certificates.models import CertificateStatuses, certificate_status_for_student from certificates.models import CertificateStatuses, certificate_status_for_student
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
...@@ -334,6 +335,8 @@ def dashboard(request): ...@@ -334,6 +335,8 @@ def dashboard(request):
CourseAuthorization.instructor_email_enabled(course.id) CourseAuthorization.instructor_email_enabled(course.id)
) )
) )
# Verification Attempts
verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user)
# get info w.r.t ExternalAuthMap # get info w.r.t ExternalAuthMap
external_auth_map = None external_auth_map = None
try: try:
...@@ -351,6 +354,8 @@ def dashboard(request): ...@@ -351,6 +354,8 @@ def dashboard(request):
'all_course_modes': course_modes, 'all_course_modes': course_modes,
'cert_statuses': cert_statuses, 'cert_statuses': cert_statuses,
'show_email_settings_for': show_email_settings_for, 'show_email_settings_for': show_email_settings_for,
'verification_status': verification_status,
'verification_msg': verification_msg,
} }
return render_to_response('dashboard.html', context) return render_to_response('dashboard.html', context)
...@@ -657,11 +662,11 @@ def manage_user_standing(request): ...@@ -657,11 +662,11 @@ def manage_user_standing(request):
row = [user.username, user.standing.all()[0].changed_by] row = [user.username, user.standing.all()[0].changed_by]
rows.append(row) rows.append(row)
context = {'headers': headers, 'rows': rows} context = {'headers': headers, 'rows': rows}
return render_to_response("manage_user_standing.html", context) return render_to_response("manage_user_standing.html", context)
@require_POST @require_POST
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -675,34 +680,34 @@ def disable_account_ajax(request): ...@@ -675,34 +680,34 @@ def disable_account_ajax(request):
username = request.POST.get('username') username = request.POST.get('username')
context = {} context = {}
if username is None or username.strip() == '': 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) return JsonResponse(context, status=400)
account_action = request.POST.get('account_action') account_action = request.POST.get('account_action')
if account_action is None: if account_action is None:
context['message'] = _u('Please choose an option') context['message'] = _('Please choose an option')
return JsonResponse(context, status=400) return JsonResponse(context, status=400)
username = username.strip() username = username.strip()
try: try:
user = User.objects.get(username=username) user = User.objects.get(username=username)
except User.DoesNotExist: 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) return JsonResponse(context, status=400)
else: else:
user_account, _ = UserStanding.objects.get_or_create( user_account, _success = UserStanding.objects.get_or_create(
user=user, defaults={'changed_by': request.user}, user=user, defaults={'changed_by': request.user},
) )
if account_action == 'disable': if account_action == 'disable':
user_account.account_status = UserStanding.ACCOUNT_DISABLED 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)) log.info("{} disabled {}'s account".format(request.user, username))
elif account_action == 'reenable': elif account_action == 'reenable':
user_account.account_status = UserStanding.ACCOUNT_ENABLED 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)) log.info("{} reenabled {}'s account".format(request.user, username))
else: else:
context['message'] = _u("Unexpected account status") context['message'] = _("Unexpected account status")
return JsonResponse(context, status=400) return JsonResponse(context, status=400)
user_account.changed_by = request.user user_account.changed_by = request.user
user_account.standing_last_changed_at = datetime.datetime.now(UTC) user_account.standing_last_changed_at = datetime.datetime.now(UTC)
......
...@@ -26,6 +26,7 @@ from django.conf import settings ...@@ -26,6 +26,7 @@ from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import ugettext as _
from model_utils.models import StatusModel from model_utils.models import StatusModel
from model_utils import Choices from model_utils import Choices
...@@ -114,9 +115,6 @@ class PhotoVerification(StatusModel): ...@@ -114,9 +115,6 @@ class PhotoVerification(StatusModel):
attempt.status == "created" attempt.status == "created"
pending_requests = PhotoVerification.submitted.all() pending_requests = PhotoVerification.submitted.all()
""" """
# We can make this configurable later...
DAYS_GOOD_FOR = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]
######################## Fields Set During Creation ######################## ######################## Fields Set During Creation ########################
# See class docstring for description of status states # See class docstring for description of status states
STATUS = Choices('created', 'ready', 'submitted', 'must_retry', 'approved', 'denied') STATUS = Choices('created', 'ready', 'submitted', 'must_retry', 'approved', 'denied')
...@@ -175,20 +173,29 @@ class PhotoVerification(StatusModel): ...@@ -175,20 +173,29 @@ class PhotoVerification(StatusModel):
##### Methods listed in the order you'd typically call them ##### Methods listed in the order you'd typically call them
@classmethod @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): def user_is_verified(cls, user, earliest_allowed_date=None):
""" """
Return whether or not a user has satisfactorily proved their Return whether or not a user has satisfactorily proved their
identity. Depending on the policy, this can expire after some period of identity. Depending on the policy, this can expire after some period of
time, so a user might have to renew periodically. 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( return cls.objects.filter(
user=user, user=user,
status="approved", status="approved",
created_at__gte=earliest_allowed_date created_at__gte=(earliest_allowed_date
or cls._earliest_allowed_date())
).exists() ).exists()
@classmethod @classmethod
...@@ -201,14 +208,11 @@ class PhotoVerification(StatusModel): ...@@ -201,14 +208,11 @@ class PhotoVerification(StatusModel):
on the contents of the attempt, and we have not yet received a denial. on the contents of the attempt, and we have not yet received a denial.
""" """
valid_statuses = ['must_retry', 'submitted', 'approved'] 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( return cls.objects.filter(
user=user, user=user,
status__in=valid_statuses, status__in=valid_statuses,
created_at__gte=earliest_allowed_date created_at__gte=(earliest_allowed_date
or cls._earliest_allowed_date())
).exists() ).exists()
@classmethod @classmethod
...@@ -225,6 +229,55 @@ class PhotoVerification(StatusModel): ...@@ -225,6 +229,55 @@ class PhotoVerification(StatusModel):
else: else:
return None 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") @status_before_must_be("created")
def upload_face_image(self, img): def upload_face_image(self, img):
raise NotImplementedError raise NotImplementedError
...@@ -486,6 +539,42 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -486,6 +539,42 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
self.status = "must_retry" self.status = "must_retry"
self.save() 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): def image_url(self, name):
""" """
We dynamically generate this, since we want it the expiration clock to We dynamically generate this, since we want it the expiration clock to
......
...@@ -17,11 +17,11 @@ from util.testing import UrlResetMixin ...@@ -17,11 +17,11 @@ from util.testing import UrlResetMixin
import verify_student.models import verify_student.models
FAKE_SETTINGS = { FAKE_SETTINGS = {
"SOFTWARE_SECURE" : { "SOFTWARE_SECURE": {
"FACE_IMAGE_AES_KEY" : "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "FACE_IMAGE_AES_KEY" : "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"API_ACCESS_KEY" : "BBBBBBBBBBBBBBBBBBBB", "API_ACCESS_KEY": "BBBBBBBBBBBBBBBBBBBB",
"API_SECRET_KEY" : "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "API_SECRET_KEY": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
"RSA_PUBLIC_KEY" : """-----BEGIN PUBLIC KEY----- "RSA_PUBLIC_KEY": """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu2fUn20ZQtDpa1TKeCA/ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu2fUn20ZQtDpa1TKeCA/
rDA2cEeFARjEr41AP6jqP/k3O7TeqFX6DgCBkxcjojRCs5IfE8TimBHtv/bcSx9o rDA2cEeFARjEr41AP6jqP/k3O7TeqFX6DgCBkxcjojRCs5IfE8TimBHtv/bcSx9o
7PANTq/62ZLM9xAMpfCcU6aAd4+CVqQkXSYjj5TUqamzDFBkp67US8IPmw7I2Gaa 7PANTq/62ZLM9xAMpfCcU6aAd4+CVqQkXSYjj5TUqamzDFBkp67US8IPmw7I2Gaa
...@@ -30,10 +30,10 @@ dyZCM9pBcvcH+60ma+nNg8GVGBAW/oLxILBtg+T3PuXSUvcu/r6lUFMHk55pU94d ...@@ -30,10 +30,10 @@ dyZCM9pBcvcH+60ma+nNg8GVGBAW/oLxILBtg+T3PuXSUvcu/r6lUFMHk55pU94d
9A/T8ySJm379qU24ligMEetPk1o9CUasdaI96xfXVDyFhrzrntAmdD+HYCSPOQHz 9A/T8ySJm379qU24ligMEetPk1o9CUasdaI96xfXVDyFhrzrntAmdD+HYCSPOQHz
iwIDAQAB iwIDAQAB
-----END PUBLIC KEY-----""", -----END PUBLIC KEY-----""",
"API_URL" : "http://localhost/verify_student/fake_endpoint", "API_URL": "http://localhost/verify_student/fake_endpoint",
"AWS_ACCESS_KEY" : "FAKEACCESSKEY", "AWS_ACCESS_KEY": "FAKEACCESSKEY",
"AWS_SECRET_KEY" : "FAKESECRETKEY", "AWS_SECRET_KEY": "FAKESECRETKEY",
"S3_BUCKET" : "fake-bucket" "S3_BUCKET": "fake-bucket"
} }
} }
...@@ -57,11 +57,13 @@ class MockKey(object): ...@@ -57,11 +57,13 @@ class MockKey(object):
def generate_url(self, duration): def generate_url(self, duration):
return "http://fake-edx-s3.edx.org/" return "http://fake-edx-s3.edx.org/"
class MockBucket(object): class MockBucket(object):
"""Mocking a boto S3 Bucket object.""" """Mocking a boto S3 Bucket object."""
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
class MockS3Connection(object): class MockS3Connection(object):
"""Mocking a boto S3 Connection""" """Mocking a boto S3 Connection"""
def __init__(self, access_key, secret_key): def __init__(self, access_key, secret_key):
...@@ -165,14 +167,14 @@ class TestPhotoVerification(TestCase): ...@@ -165,14 +167,14 @@ class TestPhotoVerification(TestCase):
# approved # approved
assert_raises(VerificationException, attempt.submit) assert_raises(VerificationException, attempt.submit)
attempt.approve() # no-op attempt.approve() # no-op
attempt.system_error("System error") # no-op, something processed it without error attempt.system_error("System error") # no-op, something processed it without error
attempt.deny(DENY_ERROR_MSG) attempt.deny(DENY_ERROR_MSG)
# denied # denied
assert_raises(VerificationException, attempt.submit) assert_raises(VerificationException, attempt.submit)
attempt.deny(DENY_ERROR_MSG) # no-op attempt.deny(DENY_ERROR_MSG) # no-op
attempt.system_error("System error") # no-op, something processed it without error attempt.system_error("System error") # no-op, something processed it without error
attempt.approve() attempt.approve()
def test_name_freezing(self): def test_name_freezing(self):
...@@ -307,3 +309,56 @@ class TestPhotoVerification(TestCase): ...@@ -307,3 +309,56 @@ class TestPhotoVerification(TestCase):
attempt.save() attempt.save()
assert_true(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user), status) 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.")
...@@ -10,20 +10,31 @@ verify_student/start?course_id=MITx/6.002x/2013_Spring # create ...@@ -10,20 +10,31 @@ verify_student/start?course_id=MITx/6.002x/2013_Spring # create
""" """
import urllib import urllib
from mock import patch, Mock, ANY
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.exceptions import ObjectDoesNotExist
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from course_modes.models import CourseMode 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): class StartView(TestCase):
def start_url(course_id=""): def start_url(self, course_id=""):
return "/verify_student/{0}".format(urllib.quote(course_id)) return "/verify_student/{0}".format(urllib.quote(course_id))
def test_start_new_verification(self): def test_start_new_verification(self):
...@@ -58,3 +69,44 @@ class TestVerifyView(TestCase): ...@@ -58,3 +69,44 @@ class TestVerifyView(TestCase):
response = self.client.get(url) response = self.client.get(url)
self.assertEquals(response.status_code, 302) 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')
...@@ -35,4 +35,15 @@ urlpatterns = patterns( ...@@ -35,4 +35,15 @@ urlpatterns = patterns(
name="verify_student_results_callback", 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"
),
) )
...@@ -267,3 +267,66 @@ def show_requirements(request, course_id): ...@@ -267,3 +267,66 @@ def show_requirements(request, course_id):
"upgrade": upgrade, "upgrade": upgrade,
} }
return render_to_response("verify_student/show_requirements.html", context) 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")
...@@ -91,10 +91,6 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True ...@@ -91,10 +91,6 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
# Use the auto_auth workflow for creating users and logging them in # Use the auto_auth workflow for creating users and logging them in
MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True 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 # Enable fake payment processing page
MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True
...@@ -102,6 +98,9 @@ MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True ...@@ -102,6 +98,9 @@ MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
MITX_FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False 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 # Configure the payment processor to use the fake processing page
# Since both the fake payment page and the shoppingcart app are using # Since both the fake payment page and the shoppingcart app are using
......
...@@ -18,6 +18,23 @@ function initVideoCapture() { ...@@ -18,6 +18,23 @@ function initVideoCapture() {
return !(navigator.getUserMedia == undefined); return !(navigator.getUserMedia == undefined);
} }
var submitReverificationPhotos = function() {
// add photos to the form
$('<input>').attr({
type: 'hidden',
name: 'face_image',
value: $("#face_image")[0].src,
}).appendTo("#reverify_form");
$('<input>').attr({
type: 'hidden',
name: 'photo_id_image',
value: $("#photo_id_image")[0].src,
}).appendTo("#reverify_form");
$("#reverify_form").submit();
}
var submitToPaymentProcessing = function() { var submitToPaymentProcessing = function() {
var contribution_input = $("input[name='contribution']:checked") var contribution_input = $("input[name='contribution']:checked")
var contribution = 0; var contribution = 0;
...@@ -255,10 +272,15 @@ $(document).ready(function() { ...@@ -255,10 +272,15 @@ $(document).ready(function() {
submitToPaymentProcessing(); submitToPaymentProcessing();
}); });
$("#reverify_button").click(function() {
submitReverificationPhotos();
});
// prevent browsers from keeping this button checked // prevent browsers from keeping this button checked
$("#confirm_pics_good").prop("checked", false) $("#confirm_pics_good").prop("checked", false)
$("#confirm_pics_good").change(function() { $("#confirm_pics_good").change(function() {
$("#pay_button").toggleClass('disabled'); $("#pay_button").toggleClass('disabled');
$("#reverify_button").toggleClass('disabled');
}); });
......
...@@ -84,6 +84,23 @@ ...@@ -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 // blue primary button
%btn-primary-blue { %btn-primary-blue {
@extend %btn-primary; @extend %btn-primary;
......
...@@ -278,7 +278,7 @@ ...@@ -278,7 +278,7 @@
%copy-badge { %copy-badge {
@extend %t-title8; @extend %t-title8;
@extend %t-weight5; @extend %t-weight3;
border-radius: ($baseline/5); border-radius: ($baseline/5);
padding: ($baseline/2) $baseline; padding: ($baseline/2) $baseline;
text-transform: uppercase; text-transform: uppercase;
......
...@@ -75,6 +75,12 @@ ...@@ -75,6 +75,12 @@
margin-bottom: 15px; margin-bottom: 15px;
padding-bottom: 17px; padding-bottom: 17px;
&:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
&:hover, &:focus { &:hover, &:focus {
.title .icon { .title .icon {
opacity: 1.0; opacity: 1.0;
...@@ -760,4 +766,169 @@ ...@@ -760,4 +766,169 @@
margin-right: 10px; 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;
}
}
} }
...@@ -226,7 +226,7 @@ ...@@ -226,7 +226,7 @@
} }
// reset: lists // 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; @extend %ui-no-list;
} }
...@@ -358,15 +358,22 @@ ...@@ -358,15 +358,22 @@
// UI : message // UI : message
.wrapper-msg { .wrapper-msg {
width: flex-grid(12,12); margin-bottom: ($baseline*1.5);
margin: 0 auto ($baseline*1.5) auto;
border-bottom: ($baseline/4) solid $m-blue; border-bottom: ($baseline/4) solid $m-blue;
padding: $baseline ($baseline*1.5); padding: $baseline ($baseline*1.5);
background: tint($m-blue, 95%); 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 { .msg-content, .msg-icon {
display: inline-block; display: block;
vertical-align: middle; float: left;
} }
.msg-content { .msg-content {
...@@ -385,6 +392,7 @@ ...@@ -385,6 +392,7 @@
.msg-icon { .msg-icon {
width: flex-grid(1,12); width: flex-grid(1,12);
@extend %t-icon2; @extend %t-icon2;
margin-right: flex-gutter();
text-align: center; text-align: center;
color: $m-blue; color: $m-blue;
} }
...@@ -620,6 +628,38 @@ ...@@ -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 // UI: slides
.carousel { .carousel {
...@@ -697,6 +737,10 @@ ...@@ -697,6 +737,10 @@
padding-bottom: 0; padding-bottom: 0;
} }
} }
.help-item-emphasis {
@extend %t-weight4;
}
} }
// help - faq // help - faq
...@@ -1851,6 +1895,7 @@ ...@@ -1851,6 +1895,7 @@
} }
} }
} }
// ====================
// STATE: already verified // STATE: already verified
.register.is-verified { .register.is-verified {
...@@ -1904,6 +1949,8 @@ ...@@ -1904,6 +1949,8 @@
} }
} }
// ====================
// STATE: upgrading registration type // STATE: upgrading registration type
.register.is-upgrading { .register.is-upgrading {
...@@ -1911,3 +1958,141 @@ ...@@ -1911,3 +1958,141 @@
margin-top: ($baseline*2) !important; 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;
}
}
}
}
...@@ -150,10 +150,10 @@ ...@@ -150,10 +150,10 @@
</header> </header>
<section class="user-info"> <section class="user-info">
<ul> <ul>
<li> <li class="info--username">
<span class="title"><div class="icon name-icon"></div>${_("Full Name")} (<a href="#apply_name_change" rel="leanModal" class="edit-name">${_("edit")}</a>)</span> <span class="data">${ user.profile.name | h }</span> <span class="title"><div class="icon name-icon"></div>${_("Full Name")} (<a href="#apply_name_change" rel="leanModal" class="edit-name">${_("edit")}</a>)</span> <span class="data">${ user.profile.name | h }</span>
</li> </li>
<li> <li class="info--email">
<span class="title"><div class="icon email-icon"></div>${_("Email")} <span class="title"><div class="icon email-icon"></div>${_("Email")}
% if external_auth_map is None or 'shib' not in external_auth_map.external_domain: % if external_auth_map is None or 'shib' not in external_auth_map.external_domain:
(<a href="#change_email" rel="leanModal" class="edit-email">${_("edit")}</a>) (<a href="#change_email" rel="leanModal" class="edit-email">${_("edit")}</a>)
...@@ -162,8 +162,8 @@ ...@@ -162,8 +162,8 @@
</li> </li>
% if external_auth_map is None or 'shib' not in external_auth_map.external_domain: % if external_auth_map is None or 'shib' not in external_auth_map.external_domain:
<li> <li class="controls--account">
<span class="title"><a href="#password_reset_complete" rel="leanModal" id="pwd_reset_button">${_("Reset Password")}</a></span> <span class="title"><div class="icon"></div><a href="#password_reset_complete" rel="leanModal" id="pwd_reset_button">${_("Reset Password")}</a></span>
<form id="password_reset_form" method="post" data-remote="true" action="${reverse('password_reset')}"> <form id="password_reset_form" method="post" data-remote="true" action="${reverse('password_reset')}">
<input id="id_email" type="hidden" name="email" maxlength="75" value="${user.email}" /> <input id="id_email" type="hidden" name="email" maxlength="75" value="${user.email}" />
<!-- <input type="submit" id="pwd_reset_button" value="${_('Reset Password')}" /> --> <!-- <input type="submit" id="pwd_reset_button" value="${_('Reset Password')}" /> -->
...@@ -171,6 +171,8 @@ ...@@ -171,6 +171,8 @@
</li> </li>
% endif % endif
<%include file='dashboard/_dashboard_status_verification.html' />
</ul> </ul>
</section> </section>
...@@ -188,7 +190,7 @@ ...@@ -188,7 +190,7 @@
<% cert_status = cert_statuses.get(course.id) %> <% cert_status = cert_statuses.get(course.id) %>
<% show_email_settings = (course.id in show_email_settings_for) %> <% show_email_settings = (course.id in show_email_settings_for) %>
<% course_mode_info = all_course_modes.get(course.id) %> <% 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 % endfor
</ul> </ul>
......
...@@ -57,7 +57,7 @@ ...@@ -57,7 +57,7 @@
</h3> </h3>
</hgroup> </hgroup>
% if course.has_ended() and cert_status: % if course.has_ended() and cert_status and not enrollment.mode == 'audit':
<% <%
if cert_status['status'] == 'generating': if cert_status['status'] == 'generating':
status_css_class = 'course-status-certrendering' status_css_class = 'course-status-certrendering'
......
<%! from django.utils.translation import ugettext as _ %>
<%!
from django.core.urlresolvers import reverse
%>
<%namespace name='static' file='../static_content.html'/>
%if verification_status == 'approved':
<li class="status status-verification is-accepted">
<span class="title status-title">${_("ID-Verification Status")}</span>
<div class="status-data">
<span class="status-data-message">${_("Reviewed and Verified")}</span>
<div class="status-note">
<span class="deco-arrow"></span>
<p>${_("Your verification status is good for one year after submission.")}</p>
</div>
</div>
</li>
%endif
%if verification_status == 'pending':
<li class="status status-verification is-pending">
<span class="title status-title">${_("ID-Verification Status")}</span>
<div class="status-data">
<span class="status-data-message">${_("Pending")}</span>
<div class="status-note">
<span class="deco-arrow"></span>
<p>${_("Your verification photos have been submitted and will be reviewed shortly.")}</p>
</div>
</div>
</li>
%endif
%if verification_status == 'must_reverify':
<li class="status status-verification is-denied">
<span class="title status-title">${_("ID-Verification Status")}</span>
<div class="status-data">
<span class="status-data-message">${verification_msg}</span>
<ul class="list-actions">
<li class="action-item action-item-reverify">
<a href="${reverse('verify_student_reverify')}" class="action action-reverify">${_("Re-verify Yourself")}</a>
</li>
</ul>
<div class="status-note">
<span class="deco-arrow"></span>
<p>${_("If you fail to pass a verification attempt before your course ends, you will not receive a verified certificate.")}</p>
</div>
</div>
</li>
%endif
<%! from django.utils.translation import ugettext as _ %>
<div class="wrapper-content-supplementary">
<aside class="content-supplementary">
<ul class="list-help">
<li class="help-item help-item-whyreverify">
<h3 class="title">${_("Why Do I Need to Re-Verify?")}</h3>
<div class="copy">
<p>${_("There was a problem with your original verification. To make sure that your identity is correctly associated with your course progress, we need to retake your photo and a photo of your identification document. If you don't have a valid identification document, contact {link_start}{support_email}{link_end}.").format(
support_email=settings.DEFAULT_FEEDBACK_EMAIL,
link_start=u'<a href="mailto:{address}?subject={subject_line}">'.format(
address=settings.DEFAULT_FEEDBACK_EMAIL,
subject_line=_('Problem with ID re-verification')),
link_end=u'</a>')}</p>
</div>
</li>
<li class="help-item help-item-technical">
<h3 class="title">${_("Having Technical Trouble?")}</h3>
<div class="copy">
<p>${_("Please make sure your browser is updated to the {strong_start}{a_start}most recent version possible{a_end}{strong_end}. Also, please make sure your {strong_start}web cam is plugged in, turned on, and allowed to function in your web browser (commonly adjustable in your browser settings).{strong_end}").format(a_start='<a rel="external" href="http://browsehappy.com/">', a_end="</a>", strong_start="<strong>", strong_end="</strong>")}</p>
</div>
</li>
<li class="help-item help-item-questions">
<h3 class="title">${_("Have questions?")}</h3>
<div class="copy">
<p>${_("Please read {a_start}our FAQs to view common questions about our certificates{a_end}.").format(a_start='<a rel="external" href="'+ marketing_link('WHAT_IS_VERIFIED_CERT') + '">', a_end="</a>")}</p>
</div>
</li>
</ul>
</aside>
</div> <!-- /wrapper-content-supplementary -->
...@@ -67,7 +67,6 @@ ...@@ -67,7 +67,6 @@
<section class="progress"> <section class="progress">
<h3 class="sr title">${_("Your Progress")}</h3> <h3 class="sr title">${_("Your Progress")}</h3>
<!-- FIXME: Move the "Current Step: " text to the right DOM element -->
<ol class="progress-steps"> <ol class="progress-steps">
<li class="progress-step is-completed" id="progress-step0"> <li class="progress-step is-completed" id="progress-step0">
<span class="wrapper-step-number"><span class="step-number">0</span></span> <span class="wrapper-step-number"><span class="step-number">0</span></span>
......
<%! 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>
<%block name="title"><title>${_("Re-Verification Submission Confirmation")}</title></%block>
<%block name="js_extra">
<script src="${static.url('js/vendor/responsive-carousel/responsive-carousel.js')}"></script>
<script src="${static.url('js/vendor/responsive-carousel/responsive-carousel.keybd.js')}"></script>
</%block>
<%block name="content">
<div class="container">
<section class="wrapper">
<div class="wrapper-progress">
<section class="progress">
<h3 class="sr title">${_("Your Progress")}</h3>
<ol class="progress-steps">
<li class="progress-step is-completed" id="progress-step1">
<span class="wrapper-step-number"><span class="step-number">1</span></span>
<span class="step-name">${_("Re-Take Photo")}</span>
</li>
<li class="progress-step is-completed" id="progress-step2">
<span class="wrapper-step-number"><span class="step-number">2</span></span>
<span class="step-name">${_("Re-Take ID Photo")}</span>
</li>
<li class="progress-step is-completed" id="progress-step3">
<span class="wrapper-step-number"><span class="step-number">3</span></span>
<span class="step-name">${_("Review")}</span>
</li>
<li class="progress-step is-current progress-step-icon" id="progress-step4">
<span class="wrapper-step-number"><span class="step-number">
<i class="icon-ok"></i>
</span></span>
<span class="step-name"><span class="sr">${_("Current Step: ")}</span>${_("Confirmation")}</span>
</li>
</ol>
<span class="progress-sts">
<span class="progress-sts-value"></span>
</span>
</section>
</div>
<div class="wrapper-content-main">
<article class="content-main">
<section class="content-confirmation">
<div class="wrapper-view">
<div class="view">
<h3 class="title">${_("Your Credentials Have Been Updated")}</h3>
<div class="instruction">
<p>${_("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.")}</p>
</div>
<ol class="list-nav">
<li class="nav-item">
<a class="action action-primary" href="${reverse('dashboard')}">${_("Return to Your Dashboard")}</a>
</li>
</ol>
</div> <!-- /view -->
</div> <!-- /wrapper-view -->
</section>
</article>
</div> <!-- /wrapper-content-main -->
<%include file="_reverification_support.html" />
</section>
</div>
</%block>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment