diff --git a/cms/envs/common.py b/cms/envs/common.py index 7417ed4589..4abea6ce13 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -454,6 +454,8 @@ INSTALLED_APPS = ( # Dark-launching languages 'dark_lang', + # Student identity reverification + 'reverification', ) diff --git a/common/djangoapps/reverification/__init__.py b/common/djangoapps/reverification/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/reverification/admin.py b/common/djangoapps/reverification/admin.py new file mode 100644 index 0000000000..982572ad73 --- /dev/null +++ b/common/djangoapps/reverification/admin.py @@ -0,0 +1,8 @@ +""" +Reverification admin +""" + +from ratelimitbackend import admin +from reverification.models import MidcourseReverificationWindow + +admin.site.register(MidcourseReverificationWindow) diff --git a/common/djangoapps/reverification/migrations/0001_initial.py b/common/djangoapps/reverification/migrations/0001_initial.py new file mode 100644 index 0000000000..89ad801879 --- /dev/null +++ b/common/djangoapps/reverification/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'MidcourseReverificationWindow' + db.create_table('reverification_midcoursereverificationwindow', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('start_date', self.gf('django.db.models.fields.DateTimeField')(default=None, null=True, blank=True)), + ('end_date', self.gf('django.db.models.fields.DateTimeField')(default=None, null=True, blank=True)), + )) + db.send_create_signal('reverification', ['MidcourseReverificationWindow']) + + + def backwards(self, orm): + # Deleting model 'MidcourseReverificationWindow' + db.delete_table('reverification_midcoursereverificationwindow') + + + models = { + 'reverification.midcoursereverificationwindow': { + 'Meta': {'object_name': 'MidcourseReverificationWindow'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'end_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'start_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}) + } + } + + complete_apps = ['reverification'] \ No newline at end of file diff --git a/common/djangoapps/reverification/migrations/__init__.py b/common/djangoapps/reverification/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/reverification/models.py b/common/djangoapps/reverification/models.py new file mode 100644 index 0000000000..53b2b659c9 --- /dev/null +++ b/common/djangoapps/reverification/models.py @@ -0,0 +1,54 @@ +""" +Models for reverification features common to both lms and studio +""" +from datetime import datetime +import pytz + +from django.core.exceptions import ValidationError +from django.db import models +from util.validate_on_save import ValidateOnSaveMixin + + +class MidcourseReverificationWindow(ValidateOnSaveMixin, models.Model): + """ + Defines the start and end times for midcourse reverification for a particular course. + + There can be many MidcourseReverificationWindows per course, but they cannot have + overlapping time ranges. This is enforced by this class's clean() method. + """ + # the course that this window is attached to + course_id = models.CharField(max_length=255, db_index=True) + start_date = models.DateTimeField(default=None, null=True, blank=True) + end_date = models.DateTimeField(default=None, null=True, blank=True) + + def clean(self): + """ + Gives custom validation for the MidcourseReverificationWindow model. + Prevents overlapping windows for any particular course. + """ + query = MidcourseReverificationWindow.objects.filter( + course_id=self.course_id, + end_date__gte=self.start_date, + start_date__lte=self.end_date + ) + if query.count() > 0: + raise ValidationError('Reverification windows cannot overlap for a given course.') + + @classmethod + def window_open_for_course(cls, course_id): + """ + Returns a boolean, True if the course is currently asking for reverification, else False. + """ + now = datetime.now(pytz.UTC) + return cls.get_window(course_id, now) is not None + + @classmethod + def get_window(cls, course_id, date): + """ + Returns the window that is open for a particular course for a particular date. + If no such window is open, or if more than one window is open, returns None. + """ + try: + return cls.objects.get(course_id=course_id, start_date__lte=date, end_date__gte=date) + except cls.DoesNotExist: + return None diff --git a/common/djangoapps/reverification/tests/__init__.py b/common/djangoapps/reverification/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/reverification/tests/factories.py b/common/djangoapps/reverification/tests/factories.py new file mode 100644 index 0000000000..5a0452b7f7 --- /dev/null +++ b/common/djangoapps/reverification/tests/factories.py @@ -0,0 +1,19 @@ +""" +verify_student factories +""" +from reverification.models import MidcourseReverificationWindow +from factory.django import DjangoModelFactory +import pytz +from datetime import timedelta, datetime + + +# Factories don't have __init__ methods, and are self documenting +# pylint: disable=W0232 +class MidcourseReverificationWindowFactory(DjangoModelFactory): + """ Creates a generic MidcourseReverificationWindow. """ + FACTORY_FOR = MidcourseReverificationWindow + + course_id = u'MITx/999/Robot_Super_Course' + # By default this factory creates a window that is currently open + start_date = datetime.now(pytz.UTC) - timedelta(days=100) + end_date = datetime.now(pytz.UTC) + timedelta(days=100) diff --git a/common/djangoapps/reverification/tests/test_models.py b/common/djangoapps/reverification/tests/test_models.py new file mode 100644 index 0000000000..4f94fb5ef7 --- /dev/null +++ b/common/djangoapps/reverification/tests/test_models.py @@ -0,0 +1,73 @@ +""" +Tests for Reverification models +""" +from datetime import timedelta, datetime +import pytz + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.test.utils import override_settings + +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from reverification.models import MidcourseReverificationWindow +from reverification.tests.factories import MidcourseReverificationWindowFactory +from xmodule.modulestore.tests.factories import CourseFactory + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestMidcourseReverificationWindow(TestCase): + """ Tests for MidcourseReverificationWindow objects """ + def setUp(self): + course = CourseFactory.create() + self.course_id = course.id + + def test_window_open_for_course(self): + # Should return False if no windows exist for a course + self.assertFalse(MidcourseReverificationWindow.window_open_for_course(self.course_id)) + + # Should return False if a window exists, but it's not in the current timeframe + MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.utc) - timedelta(days=10), + end_date=datetime.now(pytz.utc) - timedelta(days=5) + ) + self.assertFalse(MidcourseReverificationWindow.window_open_for_course(self.course_id)) + + # Should return True if a non-expired window exists + MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.utc) - timedelta(days=3), + end_date=datetime.now(pytz.utc) + timedelta(days=3) + ) + self.assertTrue(MidcourseReverificationWindow.window_open_for_course(self.course_id)) + + def test_get_window(self): + # if no window exists, returns None + self.assertIsNone(MidcourseReverificationWindow.get_window(self.course_id, datetime.now(pytz.utc))) + + # we should get the expected window otherwise + window_valid = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.utc) - timedelta(days=3), + end_date=datetime.now(pytz.utc) + timedelta(days=3) + ) + self.assertEquals( + window_valid, + MidcourseReverificationWindow.get_window(self.course_id, datetime.now(pytz.utc)) + ) + + def test_no_overlapping_windows(self): + window_valid = MidcourseReverificationWindow( + course_id=self.course_id, + start_date=datetime.now(pytz.utc) - timedelta(days=3), + end_date=datetime.now(pytz.utc) + timedelta(days=3) + ) + window_valid.save() + + with self.assertRaises(ValidationError): + window_invalid = MidcourseReverificationWindow( + course_id=self.course_id, + start_date=datetime.now(pytz.utc) - timedelta(days=2), + end_date=datetime.now(pytz.utc) + timedelta(days=4) + ) + window_invalid.save() diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index 1096092117..fdef5da3eb 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -1,3 +1,6 @@ +""" +Utility functions for validating forms +""" from django import forms from django.contrib.auth.models import User from django.contrib.auth.forms import PasswordResetForm diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 5ab43a161d..147c587746 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -10,6 +10,8 @@ import string # pylint: disable=W0402 import urllib import uuid import time +from collections import defaultdict +from pytz import UTC from django.conf import settings from django.contrib.auth import logout, authenticate, login @@ -45,7 +47,7 @@ from student.models import ( ) from student.forms import PasswordResetFormNoActive -from verify_student.models import SoftwareSecurePhotoVerification +from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow from certificates.models import CertificateStatuses, certificate_status_for_student from xmodule.course_module import CourseDescriptor @@ -82,6 +84,7 @@ log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") Article = namedtuple('Article', 'title url author image deck publication publish_date') +ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=C0103 def csrf_token(context): @@ -181,6 +184,88 @@ def cert_info(user, course): return _cert_info(user, course, certificate_status_for_student(user, course.id)) +def reverification_info(course_enrollment_pairs, user, statuses): + """ + Returns reverification-related information for *all* of user's enrollments whose + reverification status is in status_list + + Args: + course_enrollment_pairs (list): list of (course, enrollment) tuples + user (User): the user whose information we want + statuses (list): a list of reverification statuses we want information for + example: ["must_reverify", "denied"] + + Returns: + dictionary of lists: dictionary with one key per status, e.g. + dict["must_reverify"] = [] + dict["must_reverify"] = [some information] + """ + reverifications = defaultdict(list) + for (course, enrollment) in course_enrollment_pairs: + info = single_course_reverification_info(user, course, enrollment) + if info: + reverifications[info.status].append(info) + + # Sort the data by the reverification_end_date + for status in statuses: + if reverifications[status]: + reverifications[status].sort(key=lambda x: x.date) + return reverifications + + +def single_course_reverification_info(user, course, enrollment): # pylint: disable=invalid-name + """Returns midcourse reverification-related information for user with enrollment in course. + + If a course has an open re-verification window, and that user has a verified enrollment in + the course, we return a tuple with relevant information. Returns None if there is no info.. + + Args: + user (User): the user we want to get information for + course (Course): the course in which the student is enrolled + enrollment (CourseEnrollment): the object representing the type of enrollment user has in course + + Returns: + ReverifyInfo: (course_id, course_name, course_number, date, status) + OR, None: None if there is no re-verification info for this enrollment + """ + window = MidcourseReverificationWindow.get_window(course.id, datetime.datetime.now(UTC)) + + # If there's no window OR the user is not verified, we don't get reverification info + if (not window) or (enrollment.mode != "verified"): + return None + return ReverifyInfo( + course.id, course.display_name, course.number, + window.end_date.strftime('%B %d, %Y %X %p'), + SoftwareSecurePhotoVerification.user_status(user, window)[0], + SoftwareSecurePhotoVerification.display_status(user, window), + ) + + +def get_course_enrollment_pairs(user, course_org_filter, org_filter_out_set): + """ + Get the relevant set of (Course, CourseEnrollment) pairs to be displayed on + a student's dashboard. + """ + for enrollment in CourseEnrollment.enrollments_for_user(user): + try: + course = course_from_id(enrollment.course_id) + + # if we are in a Microsite, then filter out anything that is not + # attributed (by ORG) to that Microsite + if course_org_filter and course_org_filter != course.location.org: + continue + # Conversely, if we are not in a Microsite, then let's filter out any enrollments + # with courses attributed (by ORG) to Microsites + elif course.location.org in org_filter_out_set: + continue + + yield (course, enrollment) + except ItemNotFoundError: + log.error("User {0} enrolled in non-existent course {1}" + .format(user.username, enrollment.course_id)) + + + def _cert_info(user, course, cert_status): """ Implements the logic for cert_info -- split out for testing. @@ -321,11 +406,6 @@ def complete_course_mode_info(course_id, enrollment): def dashboard(request): user = request.user - # Build our (course, enrollment) list for the user, but ignore any courses that no - # longer exist (because the course IDs have changed). Still, we don't delete those - # enrollments, because it could have been a data push snafu. - course_enrollment_pairs = [] - # for microsites, we want to filter and only show enrollments for courses within # the microsites 'ORG' course_org_filter = MicrositeConfiguration.get_microsite_configuration_value('course_org_filter') @@ -338,23 +418,10 @@ def dashboard(request): if course_org_filter: org_filter_out_set.remove(course_org_filter) - for enrollment in CourseEnrollment.enrollments_for_user(user): - try: - course = course_from_id(enrollment.course_id) - - # if we are in a Microsite, then filter out anything that is not - # attributed (by ORG) to that Microsite - if course_org_filter and course_org_filter != course.location.org: - continue - # Conversely, if we are not in a Microsite, then let's filter out any enrollments - # with courses attributed (by ORG) to Microsites - elif course.location.org in org_filter_out_set: - continue - - course_enrollment_pairs.append((course, enrollment)) - except ItemNotFoundError: - log.error(u"User {0} enrolled in non-existent course {1}" - .format(user.username, enrollment.course_id)) + # Build our (course, enrollment) list for the user, but ignore any courses that no + # longer exist (because the course IDs have changed). Still, we don't delete those + # enrollments, because it could have been a data push snafu. + course_enrollment_pairs = list(get_course_enrollment_pairs(user, course_org_filter, org_filter_out_set)) course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True) @@ -386,8 +453,13 @@ def dashboard(request): ) # Verification Attempts + # Used to generate the "you must reverify for course x" banner verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user) + # Gets data for midcourse reverifications, if any are necessary or have failed + statuses = ["approved", "denied", "pending", "must_reverify"] + reverifications = reverification_info(course_enrollment_pairs, user, statuses) + show_refund_option_for = frozenset(course.id for course, _enrollment in course_enrollment_pairs if _enrollment.refundable()) @@ -398,6 +470,10 @@ def dashboard(request): except ExternalAuthMap.DoesNotExist: pass + # If there are *any* denied reverifications that have not been toggled off, + # we'll display the banner + denied_banner = any(item.display for item in reverifications["denied"]) + context = {'course_enrollment_pairs': course_enrollment_pairs, 'course_optouts': course_optouts, 'message': message, @@ -408,9 +484,12 @@ def dashboard(request): 'all_course_modes': course_modes, 'cert_statuses': cert_statuses, 'show_email_settings_for': show_email_settings_for, + 'reverifications': reverifications, 'verification_status': verification_status, 'verification_msg': verification_msg, 'show_refund_option_for': show_refund_option_for, + 'denied_banner': denied_banner, + 'billing_email': settings.PAYMENT_SUPPORT_EMAIL, } return render_to_response('dashboard.html', context) diff --git a/common/djangoapps/util/validate_on_save.py b/common/djangoapps/util/validate_on_save.py new file mode 100644 index 0000000000..ff78d460b9 --- /dev/null +++ b/common/djangoapps/util/validate_on_save.py @@ -0,0 +1,14 @@ +""" Utility mixin; forces models to validate *before* saving to db """ + + +class ValidateOnSaveMixin(object): + """ + Forces models to call their full_clean method prior to saving + """ + def save(self, force_insert=False, force_update=False, **kwargs): + """ + Modifies the save method to call full_clean + """ + if not (force_insert or force_update): + self.full_clean() + super(ValidateOnSaveMixin, self).save(force_insert, force_update, **kwargs) diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 2f9e70517a..04d1d0ba48 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -176,14 +176,16 @@ class XQueueCertInterface(object): is_whitelisted = self.whitelist.filter( user=student, course_id=course_id, whitelist=True).exists() enrollment_mode = CourseEnrollment.enrollment_mode_for_user(student, course_id) + mode_is_verified = (enrollment_mode == GeneratedCertificate.MODES.verified) + user_is_verified = SoftwareSecurePhotoVerification.user_is_verified(student) + user_is_reverified = SoftwareSecurePhotoVerification.user_is_reverified_for_all(course_id, student) org = course_id.split('/')[0] course_num = course_id.split('/')[1] cert_mode = enrollment_mode - if enrollment_mode == GeneratedCertificate.MODES.verified and SoftwareSecurePhotoVerification.user_is_verified(student): + if (mode_is_verified and user_is_verified and user_is_reverified): template_pdf = "certificate-template-{0}-{1}-verified.pdf".format( org, course_num) - elif (enrollment_mode == GeneratedCertificate.MODES.verified and not - SoftwareSecurePhotoVerification.user_is_verified(student)): + elif (mode_is_verified and not (user_is_verified and user_is_reverified)): template_pdf = "certificate-template-{0}-{1}.pdf".format( org, course_num) cert_mode = GeneratedCertificate.MODES.honor diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 064dd36f13..d87a3830e2 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -2,6 +2,7 @@ import logging import urllib from functools import partial +from collections import defaultdict from django.conf import settings from django.core.context_processors import csrf @@ -29,6 +30,7 @@ from courseware.models import StudentModule, StudentModuleHistory from course_modes.models import CourseMode from student.models import UserTestGroup, CourseEnrollment +from student.views import course_from_id, single_course_reverification_info from util.cache import cache, cache_if_anonymous from xblock.fragment import Fragment from xmodule.modulestore import Location @@ -265,7 +267,8 @@ def index(request, course_id, chapter=None, section=None, 'fragment': Fragment(), 'staff_access': staff_access, 'masquerade': masq, - 'xqa_server': settings.FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa') + 'xqa_server': settings.FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa'), + 'reverifications': fetch_reverify_banner_info(request, course_id), } # Only show the chat if it's enabled by the course and in the @@ -451,9 +454,19 @@ def course_info(request, course_id): course = get_course_with_access(request.user, course_id, 'load') staff_access = has_access(request.user, course, 'staff') masq = setup_masquerade(request, staff_access) # allow staff to toggle masquerade on info page + reverifications = fetch_reverify_banner_info(request, course_id) - return render_to_response('courseware/info.html', {'request': request, 'course_id': course_id, 'cache': None, - 'course': course, 'staff_access': staff_access, 'masquerade': masq}) + context = { + 'request': request, + 'course_id': course_id, + 'cache': None, + 'course': course, + 'staff_access': staff_access, + 'masquerade': masq, + 'reverifications': reverifications, + } + + return render_to_response('courseware/info.html', context) @ensure_csrf_cookie @@ -654,6 +667,7 @@ def _progress(request, course_id, student_id): 'grade_summary': grade_summary, 'staff_access': staff_access, 'student': student, + 'reverifications': fetch_reverify_banner_info(request, course_id) } with grades.manual_transaction(): @@ -662,6 +676,21 @@ def _progress(request, course_id, student_id): return response +def fetch_reverify_banner_info(request, course_id): + """ + Fetches needed context variable to display reverification banner in courseware + """ + reverifications = defaultdict(list) + user = request.user + if not user.id: + return reverifications + enrollment = CourseEnrollment.get_or_create_enrollment(request.user, course_id) + course = course_from_id(course_id) + info = single_course_reverification_info(user, course, enrollment) + if info: + reverifications[info.status].append(info) + return reverifications + @login_required def submission_history(request, course_id, student_username, location): """Render an HTML fragment (meant for inclusion elsewhere) that renders a diff --git a/lms/djangoapps/dashboard/sysadmin.py b/lms/djangoapps/dashboard/sysadmin.py index a8dd51f54c..87823947a9 100644 --- a/lms/djangoapps/dashboard/sysadmin.py +++ b/lms/djangoapps/dashboard/sysadmin.py @@ -47,7 +47,6 @@ from xmodule.modulestore.xml import XMLModuleStore log = logging.getLogger(__name__) - class SysadminDashboardView(TemplateView): """Base class for sysadmin dashboard views with common methods""" @@ -675,7 +674,7 @@ class GitLogs(TemplateView): mdb = mongoengine.connect(mongo_db['db'], host=mongo_db['host']) except mongoengine.connection.ConnectionError: log.exception('Unable to connect to mongodb to save log, ' - 'please check MONGODB_LOG settings.') + 'please check MONGODB_LOG settings.') if course_id is None: # Require staff if not going to specific course diff --git a/lms/djangoapps/verify_student/exceptions.py b/lms/djangoapps/verify_student/exceptions.py new file mode 100644 index 0000000000..d31fdb6a6d --- /dev/null +++ b/lms/djangoapps/verify_student/exceptions.py @@ -0,0 +1,9 @@ +""" +Exceptions for the verify student app +""" +# (Exception Class Names are sort of self-explanatory, so skipping docstring requirement) +# pylint: disable=C0111 + + +class WindowExpiredException(Exception): + pass diff --git a/lms/djangoapps/verify_student/migrations/0002_auto__add_field_softwaresecurephotoverification_window.py b/lms/djangoapps/verify_student/migrations/0002_auto__add_field_softwaresecurephotoverification_window.py new file mode 100644 index 0000000000..0672427d31 --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0002_auto__add_field_softwaresecurephotoverification_window.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'SoftwareSecurePhotoVerification.window' + db.add_column('verify_student_softwaresecurephotoverification', 'window', + self.gf('django.db.models.fields.related.ForeignKey')(to=orm['reverification.MidcourseReverificationWindow'], null=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'SoftwareSecurePhotoVerification.window' + db.delete_column('verify_student_softwaresecurephotoverification', 'window_id') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'reverification.midcoursereverificationwindow': { + 'Meta': {'object_name': 'MidcourseReverificationWindow'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'end_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'start_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}) + }, + 'verify_student.softwaresecurephotoverification': { + 'Meta': {'ordering': "['-created_at']", 'object_name': 'SoftwareSecurePhotoVerification'}, + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'error_code': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'error_msg': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'face_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'photo_id_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}), + 'photo_id_key': ('django.db.models.fields.TextField', [], {'max_length': '1024'}), + 'receipt_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'db_index': 'True'}), + 'reviewing_service': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'reviewing_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'photo_verifications_reviewed'", 'null': 'True', 'to': "orm['auth.User']"}), + 'status': ('model_utils.fields.StatusField', [], {'default': "'created'", 'max_length': '100', u'no_check_for_status': 'True'}), + 'status_changed': ('model_utils.fields.MonitorField', [], {'default': 'datetime.datetime.now', u'monitor': "u'status'"}), + 'submitted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'window': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['reverification.MidcourseReverificationWindow']", 'null': 'True'}) + } + } + + complete_apps = ['verify_student'] \ No newline at end of file diff --git a/lms/djangoapps/verify_student/migrations/0003_auto__add_field_softwaresecurephotoverification_display.py b/lms/djangoapps/verify_student/migrations/0003_auto__add_field_softwaresecurephotoverification_display.py new file mode 100644 index 0000000000..3c41289180 --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0003_auto__add_field_softwaresecurephotoverification_display.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'SoftwareSecurePhotoVerification.display' + db.add_column('verify_student_softwaresecurephotoverification', 'display', + self.gf('django.db.models.fields.BooleanField')(default=True, db_index=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'SoftwareSecurePhotoVerification.display' + db.delete_column('verify_student_softwaresecurephotoverification', 'display') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'reverification.midcoursereverificationwindow': { + 'Meta': {'object_name': 'MidcourseReverificationWindow'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'end_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'start_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}) + }, + 'verify_student.softwaresecurephotoverification': { + 'Meta': {'ordering': "['-created_at']", 'object_name': 'SoftwareSecurePhotoVerification'}, + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'display': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'db_index': 'True'}), + 'error_code': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'error_msg': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'face_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'photo_id_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}), + 'photo_id_key': ('django.db.models.fields.TextField', [], {'max_length': '1024'}), + 'receipt_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'db_index': 'True'}), + 'reviewing_service': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'reviewing_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'photo_verifications_reviewed'", 'null': 'True', 'to': "orm['auth.User']"}), + 'status': ('model_utils.fields.StatusField', [], {'default': "'created'", 'max_length': '100', u'no_check_for_status': 'True'}), + 'status_changed': ('model_utils.fields.MonitorField', [], {'default': 'datetime.datetime.now', u'monitor': "u'status'"}), + 'submitted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'window': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['reverification.MidcourseReverificationWindow']", 'null': 'True'}) + } + } + + complete_apps = ['verify_student'] \ No newline at end of file diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index dccbdb430a..e8387dff08 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -35,9 +35,16 @@ from verify_student.ssencrypt import ( generate_signed_message, rsa_encrypt ) +from reverification.models import MidcourseReverificationWindow + log = logging.getLogger(__name__) +def generateUUID(): # pylint: disable=C0103 + """ Utility function; generates UUIDs """ + return str(uuid.uuid4) + + class VerificationException(Exception): pass @@ -135,13 +142,18 @@ class PhotoVerification(StatusModel): # user IDs or something too easily guessable. receipt_id = models.CharField( db_index=True, - default=uuid.uuid4, + default=generateUUID, max_length=255, ) created_at = models.DateTimeField(auto_now_add=True, db_index=True) updated_at = models.DateTimeField(auto_now=True, db_index=True) + # Indicates whether or not a user wants to see the verification status + # displayed on their dash. Right now, only relevant for allowing students + # to "dismiss" a failed midcourse reverification message + display = models.BooleanField(db_index=True, default=True) + ######################## Fields Set When Submitting ######################## submitted_at = models.DateTimeField(null=True, db_index=True) @@ -185,52 +197,67 @@ class PhotoVerification(StatusModel): return allowed_date @classmethod - def user_is_verified(cls, user, earliest_allowed_date=None): + def user_is_verified(cls, user, earliest_allowed_date=None, window=None): """ - Return whether or not a user has satisfactorily proved their - identity. Depending on the policy, this can expire after some period of - time, so a user might have to renew periodically. + Return whether or not a user has satisfactorily proved their identity. + Depending on the policy, this can expire after some period of time, so + a user might have to renew periodically. + + If window=None, then this will check for the user's *initial* verification. + If window is set to anything else, it will check for the reverification + associated with that window. """ return cls.objects.filter( user=user, status="approved", created_at__gte=(earliest_allowed_date - or cls._earliest_allowed_date()) + or cls._earliest_allowed_date()), + window=window ).exists() @classmethod - def user_has_valid_or_pending(cls, user, earliest_allowed_date=None): + def user_has_valid_or_pending(cls, user, earliest_allowed_date=None, window=None): """ Return whether the user has a complete verification attempt that is or *might* be good. This means that it's approved, been submitted, or would have been submitted but had an non-user error when it was being submitted. It's basically any situation in which the user has signed off on the contents of the attempt, and we have not yet received a denial. + + If window=None, this will check for the user's *initial* verification. If + window is anything else, this will check for the reverification associated + with that window. """ - valid_statuses = ['must_retry', 'submitted', 'approved'] + valid_statuses = ['submitted', 'approved'] + if not window: + valid_statuses.append('must_retry') return cls.objects.filter( user=user, status__in=valid_statuses, created_at__gte=(earliest_allowed_date - or cls._earliest_allowed_date()) + or cls._earliest_allowed_date()), + window=window, ).exists() @classmethod - def active_for_user(cls, user): + def active_for_user(cls, user, window=None): """ Return the most recent PhotoVerification that is marked ready (i.e. the user has said they're set, but we haven't submitted anything yet). + + If window=None, this checks for the original verification. If window is set to + anything else, this will check for the reverification associated with that window. """ # This should only be one at the most, but just in case we create more # by mistake, we'll grab the most recently created one. - active_attempts = cls.objects.filter(user=user, status='ready').order_by('-created_at') + active_attempts = cls.objects.filter(user=user, status='ready', window=window).order_by('-created_at') if active_attempts: return active_attempts[0] else: return None @classmethod - def user_status(cls, user): + def user_status(cls, user, window=None): """ Returns the status of the user based on their past verification attempts @@ -239,36 +266,53 @@ class PhotoVerification(StatusModel): 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' + + If window=None, this checks initial verifications + If window is set, this checks for the reverification associated with that window """ status = 'none' error_msg = '' - if cls.user_is_verified(user): + if cls.user_is_verified(user, window=window): status = 'approved' - elif cls.user_has_valid_or_pending(user): + + elif cls.user_has_valid_or_pending(user, window=window): # 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') + attempts = cls.objects.filter(user=user, window=window).order_by('-updated_at') attempt = attempts[0] except IndexError: - return ('none', error_msg) + + # If no verification exists for a *midcourse* reverification, then that just + # means the student still needs to reverify. For *original* verifications, + # we return 'none' + if(window): + return('must_reverify', error_msg) + else: + 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 someone is denied their original verification attempt, they can try to reverify. + # However, if a midcourse reverification is denied, that denial is permanent. if attempt.status == 'denied': - status = 'must_reverify' + if window is None: + status = 'must_reverify' + else: + status = 'denied' 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 @@ -320,10 +364,6 @@ class PhotoVerification(StatusModel): self.status = "ready" self.save() - @status_before_must_be("must_retry", "ready", "submitted") - def submit(self): - raise NotImplementedError - @status_before_must_be("must_retry", "submitted", "approved", "denied") def approve(self, user_id=None, service=""): """ @@ -429,6 +469,28 @@ class PhotoVerification(StatusModel): self.status = "must_retry" self.save() + @classmethod + def display_off(cls, user_id): + """ + Find all failed PhotoVerifications for a user, and sets those verifications' `display` + property to false, so the notification banner can be switched off. + """ + user = User.objects.get(id=user_id) + cls.objects.filter(user=user, status="denied").exclude(window=None).update(display=False) + + @classmethod + def display_status(cls, user, window): + """ + Finds the `display` property for the PhotoVerification associated with + (user, window). Default is True + """ + attempts = cls.objects.filter(user=user, window=window).order_by('-updated_at') + try: + attempt = attempts[0] + return attempt.display + except IndexError: + return True + class SoftwareSecurePhotoVerification(PhotoVerification): """ @@ -454,6 +516,12 @@ class SoftwareSecurePhotoVerification(PhotoVerification): 3. The encrypted photos are base64 encoded and stored in an S3 bucket that edx-platform does not have read access to. + + Note: this model handles both *inital* verifications (which you must perform + at the time you register for a verified cert), and *midcourse reverifications*. + To distinguish between the two, check the value of the property window: + intial verifications of a window of None, whereas midcourse reverifications + * must always be linked to a specific window*. """ # This is a base64.urlsafe_encode(rsa_encrypt(photo_id_aes_key), ss_pub_key) # So first we generate a random AES-256 key to encrypt our photo ID with. @@ -463,6 +531,43 @@ class SoftwareSecurePhotoVerification(PhotoVerification): IMAGE_LINK_DURATION = 5 * 60 * 60 * 24 # 5 days in seconds + window = models.ForeignKey(MidcourseReverificationWindow, db_index=True, null=True) + + @classmethod + def user_is_reverified_for_all(cls, course_id, user): + """ + Checks to see if the student has successfully reverified for all of the + mandatory re-verification windows associated with a course. + + This is used primarily by the certificate generation code... if the user is + not re-verified for all windows, then they cannot receive a certificate. + """ + all_windows = MidcourseReverificationWindow.objects.filter(course_id=course_id) + # if there are no windows for a course, then return True right off + if (not all_windows.exists()): + return True + + for window in all_windows: + try: + # The status of the most recent reverification for each window must be "approved" + # for a student to count as completely reverified + attempts = cls.objects.filter(user=user, window=window).order_by('-updated_at') + attempt = attempts[0] + if attempt.status != "approved": + return False + except Exception: # pylint: disable=W0703 + return False + + return True + + @classmethod + def original_verification(cls, user): + """ + Returns the most current SoftwareSecurePhotoVerification object associated with the user. + """ + query = cls.objects.filter(user=user, window=None).order_by('-updated_at') + return query[0] + @status_before_must_be("created") def upload_face_image(self, img_data): """ @@ -483,9 +588,22 @@ class SoftwareSecurePhotoVerification(PhotoVerification): aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"] aes_key = aes_key_str.decode("hex") - s3_key = self._generate_key("face") + s3_key = self._generate_s3_key("face") s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key)) + @status_before_must_be("created") + def fetch_photo_id_image(self): + """ + Find the user's photo ID image, which was submitted with their original verification. + The image has already been encrypted and stored in s3, so we just need to find that + location + """ + if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'): + return + + self.photo_id_key = self.original_verification(self.user).photo_id_key + self.save() + @status_before_must_be("created") def upload_photo_id_image(self, img_data): """ @@ -510,7 +628,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification): rsa_encrypted_aes_key = rsa_encrypt(aes_key, rsa_key_str) # Upload this to S3 - s3_key = self._generate_key("photo_id") + s3_key = self._generate_s3_key("photo_id") s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key)) # Update our record fields @@ -580,11 +698,13 @@ class SoftwareSecurePhotoVerification(PhotoVerification): We dynamically generate this, since we want it the expiration clock to start when the message is created, not when the record is created. """ - s3_key = self._generate_key(name) + s3_key = self._generate_s3_key(name) return s3_key.generate_url(self.IMAGE_LINK_DURATION) - def _generate_key(self, prefix): + def _generate_s3_key(self, prefix): """ + Generates a key for an s3 bucket location + Example: face/4dd1add9-6719-42f7-bea0-115c008c4fca """ conn = S3Connection( @@ -659,6 +779,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification): return header_txt + "\n\n" + body_txt + def send_request(self): """ Assembles a submission to Software Secure and sends it via HTTPS. diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index fd2b767859..beac953dec 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -1,18 +1,25 @@ # -*- coding: utf-8 -*- -from datetime import timedelta +from datetime import timedelta, datetime import json +from xmodule.modulestore.tests.factories import CourseFactory from nose.tools import ( assert_in, assert_is_none, assert_equals, assert_not_equals, assert_raises, assert_true, assert_false ) from mock import MagicMock, patch +import pytz from django.test import TestCase +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from django.test.utils import override_settings from django.conf import settings import requests import requests.exceptions from student.tests.factories import UserFactory -from verify_student.models import SoftwareSecurePhotoVerification, VerificationException +from verify_student.models import ( + SoftwareSecurePhotoVerification, VerificationException, +) +from reverification.tests.factories import MidcourseReverificationWindowFactory from util.testing import UrlResetMixin import verify_student.models @@ -208,6 +215,23 @@ class TestPhotoVerification(TestCase): return attempt + def test_fetch_photo_id_image(self): + user = UserFactory.create() + orig_attempt = SoftwareSecurePhotoVerification(user=user, window=None) + orig_attempt.save() + + old_key = orig_attempt.photo_id_key + + window = MidcourseReverificationWindowFactory( + course_id="ponies", + start_date=datetime.now(pytz.utc) - timedelta(days=5), + end_date=datetime.now(pytz.utc) + timedelta(days=5) + ) + new_attempt = SoftwareSecurePhotoVerification(user=user, window=window) + new_attempt.save() + new_attempt.fetch_photo_id_image() + assert_equals(new_attempt.photo_id_key, old_key) + def test_submissions(self): """Test that we set our status correctly after a submission.""" # Basic case, things go well. @@ -339,6 +363,37 @@ class TestPhotoVerification(TestCase): status = SoftwareSecurePhotoVerification.user_status(user) self.assertEquals(status, ('must_reverify', "No photo ID was provided.")) + # test for correct status for reverifications + window = MidcourseReverificationWindowFactory() + reverify_status = SoftwareSecurePhotoVerification.user_status(user=user, window=window) + self.assertEquals(reverify_status, ('must_reverify', '')) + + reverify_attempt = SoftwareSecurePhotoVerification(user=user, window=window) + reverify_attempt.status = 'approved' + reverify_attempt.save() + + reverify_status = SoftwareSecurePhotoVerification.user_status(user=user, window=window) + self.assertEquals(reverify_status, ('approved', '')) + + reverify_attempt.status = 'denied' + reverify_attempt.save() + + reverify_status = SoftwareSecurePhotoVerification.user_status(user=user, window=window) + self.assertEquals(reverify_status, ('denied', '')) + + def test_display(self): + user = UserFactory.create() + window = MidcourseReverificationWindowFactory() + attempt = SoftwareSecurePhotoVerification(user=user, window=window, status="denied") + attempt.save() + + # We expect the verification to be displayed by default + self.assertEquals(SoftwareSecurePhotoVerification.display_status(user, window), True) + + # Turn it off + SoftwareSecurePhotoVerification.display_off(user.id) + self.assertEquals(SoftwareSecurePhotoVerification.display_status(user, window), False) + def test_parse_error_msg_success(self): user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) @@ -362,3 +417,101 @@ class TestPhotoVerification(TestCase): attempt.error_msg = msg parsed_error_msg = attempt.parsed_error_msg() self.assertEquals(parsed_error_msg, "There was an error verifying your ID photos.") + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) +@patch('verify_student.models.S3Connection', new=MockS3Connection) +@patch('verify_student.models.Key', new=MockKey) +@patch('verify_student.models.requests.post', new=mock_software_secure_post) +class TestMidcourseReverification(TestCase): + """ Tests for methods that are specific to midcourse SoftwareSecurePhotoVerification objects """ + def setUp(self): + self.course_id = "MITx/999/Robot_Super_Course" + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.user = UserFactory.create() + + def test_user_is_reverified_for_all(self): + + # if there are no windows for a course, this should return True + self.assertTrue(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + # first, make three windows + window1 = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=15), + end_date=datetime.now(pytz.UTC) - timedelta(days=13), + ) + + window2 = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=10), + end_date=datetime.now(pytz.UTC) - timedelta(days=8), + ) + + window3 = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=5), + end_date=datetime.now(pytz.UTC) - timedelta(days=3), + ) + + # make two SSPMidcourseReverifications for those windows + attempt1 = SoftwareSecurePhotoVerification( + status="approved", + user=self.user, + window=window1 + ) + attempt1.save() + + attempt2 = SoftwareSecurePhotoVerification( + status="approved", + user=self.user, + window=window2 + ) + attempt2.save() + + # should return False because only 2 of 3 windows have verifications + self.assertFalse(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + attempt3 = SoftwareSecurePhotoVerification( + status="must_retry", + user=self.user, + window=window3 + ) + attempt3.save() + + # should return False because the last verification exists BUT is not approved + self.assertFalse(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + attempt3.status = "approved" + attempt3.save() + + # should now return True because all windows have approved verifications + self.assertTrue(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + def test_original_verification(self): + orig_attempt = SoftwareSecurePhotoVerification(user=self.user) + orig_attempt.save() + window = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=15), + end_date=datetime.now(pytz.UTC) - timedelta(days=13), + ) + midcourse_attempt = SoftwareSecurePhotoVerification(user=self.user, window=window) + self.assertEquals(midcourse_attempt.original_verification(user=self.user), orig_attempt) + + def test_user_has_valid_or_pending(self): + window = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=15), + end_date=datetime.now(pytz.UTC) - timedelta(days=13), + ) + + attempt = SoftwareSecurePhotoVerification(status="must_retry", user=self.user, window=window) + attempt.save() + + assert_false(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user=self.user, window=window)) + + attempt.status = "approved" + attempt.save() + assert_true(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user=self.user, window=window)) diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index c14f41d87b..4ddef86f18 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -11,6 +11,8 @@ verify_student/start?course_id=MITx/6.002x/2013_Spring # create """ import urllib from mock import patch, Mock, ANY +import pytz +from datetime import timedelta, datetime from django.test import TestCase from django.test.utils import override_settings @@ -18,12 +20,16 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.core.exceptions import ObjectDoesNotExist +from mock import sentinel + from xmodule.modulestore.tests.factories import CourseFactory from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from student.tests.factories import UserFactory +from student.models import CourseEnrollment from course_modes.models import CourseMode from verify_student.views import render_to_response from verify_student.models import SoftwareSecurePhotoVerification +from reverification.tests.factories import MidcourseReverificationWindowFactory def mock_render_to_response(*args, **kwargs): @@ -80,6 +86,8 @@ class TestReverifyView(TestCase): def setUp(self): self.user = UserFactory.create(username="rusty", password="test") self.client.login(username="rusty", password="test") + self.course_id = "MITx/999/Robot_Super_Course" + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') @patch('verify_student.views.render_to_response', render_mock) def test_reverify_get(self): @@ -110,3 +118,100 @@ class TestReverifyView(TestCase): self.assertIsNotNone(verification_attempt) except ObjectDoesNotExist: self.fail('No verification object generated') + ((template, context), _kwargs) = render_mock.call_args + self.assertIn('photo_reverification', template) + self.assertTrue(context['error']) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestMidCourseReverifyView(TestCase): + """ Tests for the midcourse reverification views """ + def setUp(self): + self.user = UserFactory.create(username="rusty", password="test") + self.client.login(username="rusty", password="test") + self.course_id = 'Robot/999/Test_Course' + CourseFactory.create(org='Robot', number='999', display_name='Test Course') + + patcher = patch('student.models.server_track') + self.mock_server_track = patcher.start() + self.addCleanup(patcher.stop) + + crum_patcher = patch('student.models.crum.get_current_request') + self.mock_get_current_request = crum_patcher.start() + self.addCleanup(crum_patcher.stop) + self.mock_get_current_request.return_value = sentinel.request + + @patch('verify_student.views.render_to_response', render_mock) + def test_midcourse_reverify_get(self): + url = reverse('verify_student_midcourse_reverify', + kwargs={"course_id": self.course_id}) + response = self.client.get(url) + + # Check that user entering the reverify flow was logged + self.mock_server_track.assert_called_once_with( + sentinel.request, + 'edx.course.enrollment.reverify.started', + { + 'user_id': self.user.id, + 'course_id': self.course_id, + 'mode': "verified", + } + ) + self.mock_server_track.reset_mock() + + self.assertEquals(response.status_code, 200) + ((_template, context), _kwargs) = render_mock.call_args + self.assertFalse(context['error']) + + @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) + def test_midcourse_reverify_post_success(self): + window = MidcourseReverificationWindowFactory(course_id=self.course_id) + url = reverse('verify_student_midcourse_reverify', kwargs={'course_id': self.course_id}) + + response = self.client.post(url, {'face_image': ','}) + + # Check that submission event was logged + self.mock_server_track.assert_called_once_with( + sentinel.request, + 'edx.course.enrollment.reverify.submitted', + { + 'user_id': self.user.id, + 'course_id': self.course_id, + 'mode': "verified", + } + ) + self.mock_server_track.reset_mock() + + self.assertEquals(response.status_code, 302) + try: + verification_attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user, window=window) + self.assertIsNotNone(verification_attempt) + except ObjectDoesNotExist: + self.fail('No verification object generated') + + @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) + def test_midcourse_reverify_post_failure_expired_window(self): + window = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=100), + end_date=datetime.now(pytz.UTC) - timedelta(days=50), + ) + url = reverse('verify_student_midcourse_reverify', kwargs={'course_id': self.course_id}) + response = self.client.post(url, {'face_image': ','}) + self.assertEquals(response.status_code, 302) + with self.assertRaises(ObjectDoesNotExist): + SoftwareSecurePhotoVerification.objects.get(user=self.user, window=window) + + @patch('verify_student.views.render_to_response', render_mock) + def test_midcourse_reverify_dash(self): + url = reverse('verify_student_midcourse_reverify_dash') + response = self.client.get(url) + # not enrolled in any courses + self.assertEquals(response.status_code, 200) + + enrollment = CourseEnrollment.get_or_create_enrollment(self.user, self.course_id) + enrollment.update_enrollment(mode="verified", is_active=True) + MidcourseReverificationWindowFactory(course_id=self.course_id) + response = self.client.get(url) + # enrolled in a verified course, and the window is open + self.assertEquals(response.status_code, 200) diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index 843ebf9602..799264cda1 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -13,7 +13,7 @@ urlpatterns = patterns( url( r'^verify/(?P[^/]+/[^/]+/[^/]+)$', - views.VerifyView.as_view(), + views.VerifyView.as_view(), # pylint: disable=E1120 name="verify_student_verify" ), @@ -41,9 +41,39 @@ urlpatterns = patterns( name="verify_student_reverify" ), + url( + r'^midcourse_reverify/(?P[^/]+/[^/]+/[^/]+)$', + views.MidCourseReverifyView.as_view(), # pylint: disable=E1120 + name="verify_student_midcourse_reverify" + ), + url( r'^reverification_confirmation$', views.reverification_submission_confirmation, name="verify_student_reverification_confirmation" ), + + url( + r'^midcourse_reverification_confirmation$', + views.midcourse_reverification_confirmation, + name="verify_student_midcourse_reverification_confirmation" + ), + + url( + r'^midcourse_reverify_dash$', + views.midcourse_reverify_dash, + name="verify_student_midcourse_reverify_dash" + ), + + url( + r'^reverification_window_expired$', + views.reverification_window_expired, + name="verify_student_reverification_window_expired" + ), + + url( + r'^toggle_failed_banner_off$', + views.toggle_failed_banner_off, + name="verify_student_toggle_failed_banner_off" + ), ) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 2ac798bbf2..f8e5b464e9 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -5,6 +5,10 @@ Views for the verification flow import json import logging import decimal +import datetime +import crum +from track.views import server_track +from pytz import UTC from edxmako.shortcuts import render_to_response @@ -22,16 +26,25 @@ from django.contrib.auth.decorators import login_required from course_modes.models import CourseMode from student.models import CourseEnrollment -from student.views import course_from_id +from student.views import course_from_id, reverification_info from shoppingcart.models import Order, CertificateItem from shoppingcart.processors.CyberSource import ( get_signed_purchase_params, get_purchase_endpoint ) -from verify_student.models import SoftwareSecurePhotoVerification +from verify_student.models import ( + SoftwareSecurePhotoVerification, +) +from reverification.models import MidcourseReverificationWindow import ssencrypt +from xmodule.modulestore.exceptions import ItemNotFoundError +from .exceptions import WindowExpiredException log = logging.getLogger(__name__) +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' + class VerifyView(View): @method_decorator(login_required) @@ -42,7 +55,6 @@ class VerifyView(View): - Taking the id photo - Confirming that the photos and payment price are correct before proceeding to payment - """ upgrade = request.GET.get('upgrade', False) @@ -245,6 +257,13 @@ def results_callback(request): "Result {} not understood. Known results: PASS, FAIL, SYSTEM FAIL".format(result) ) + # If this is a reverification, log an event + if attempt.window: + course_id = window.course_id + course = course_from_id(course_id) + course_enrollment = CourseEnrollment.get_or_create_enrollment(attempt.user, course_id) + course_enrollment.emit_event(EVENT_NAME_USER_REVERIFICATION_REVIEWED_BY_SOFTWARESECURE) + return HttpResponse("OK!") @@ -323,10 +342,136 @@ class ReverifyView(View): return render_to_response("verify_student/photo_reverification.html", context) +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 + """ + course = course_from_id(course_id) + 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) + context = { + "user_full_name": request.user.profile.name, + "error": False, + "course_id": course_id, + "course_name": course.display_name_with_default, + "course_org": course.display_org_with_default, + "course_num": course.display_number_with_default, + "reverify": True, + } + + 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) + window = MidcourseReverificationWindow.get_window(course_id, now) + if window is None: + raise WindowExpiredException + attempt = SoftwareSecurePhotoVerification(user=request.user, window=window) + 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() + 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) + return HttpResponseRedirect(reverse('verify_student_midcourse_reverification_confirmation')) + + 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')) + + 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) + + +@login_required +def midcourse_reverify_dash(request): + """ + 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. + """ + user = request.user + course_enrollment_pairs = [] + for enrollment in CourseEnrollment.enrollments_for_user(user): + try: + course_enrollment_pairs.append((course_from_id(enrollment.course_id), enrollment)) + except ItemNotFoundError: + log.error("User {0} enrolled in non-existent course {1}" + .format(user.username, enrollment.course_id)) + + statuses = ["approved", "pending", "must_reverify", "denied"] + + reverifications = reverification_info(course_enrollment_pairs, user, statuses) + + context = { + "user_full_name": user.profile.name, + 'reverifications': reverifications, + 'referer': request.META.get('HTTP_REFERER'), + 'billing_email': settings.PAYMENT_SUPPORT_EMAIL, + } + return render_to_response("verify_student/midcourse_reverify_dash.html", context) + + +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. + """ + user_id = request.POST.get('user_id') + SoftwareSecurePhotoVerification.display_off(user_id) + + @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") + + +@login_required +def midcourse_reverification_confirmation(_request): # pylint: disable=C0103 + """ + Shows the user a confirmation page if the submission to SoftwareSecure was successful + """ + return render_to_response("verify_student/midcourse_reverification_confirmation.html") + + +@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") diff --git a/lms/envs/common.py b/lms/envs/common.py index 7d830c4547..543a47737b 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1068,7 +1068,12 @@ INSTALLED_APPS = ( # Dark-launching languages 'dark_lang', + + # Microsite configuration 'microsite_configuration', + + # Student Identity Reverification + 'reverification', ) ######################### MARKETING SITE ############################### diff --git a/lms/static/js/verify_student/photocapture.js b/lms/static/js/verify_student/photocapture.js index 4db15e9d51..f8c89ae0db 100644 --- a/lms/static/js/verify_student/photocapture.js +++ b/lms/static/js/verify_student/photocapture.js @@ -35,15 +35,22 @@ var submitReverificationPhotos = function() { } +var submitMidcourseReverificationPhotos = function() { + $('').attr({ + type: 'hidden', + name: 'face_image', + value: $("#face_image")[0].src, + }).appendTo("#reverify_form"); + $("#reverify_form").submit(); +} + var submitToPaymentProcessing = function() { var contribution_input = $("input[name='contribution']:checked") var contribution = 0; - if(contribution_input.attr('id') == 'contribution-other') - { + if(contribution_input.attr('id') == 'contribution-other') { contribution = $("input[name='contribution-other-amt']").val(); } - else - { + else { contribution = contribution_input.val(); } var course_id = $("input[name='course_id']").val(); @@ -276,11 +283,16 @@ $(document).ready(function() { submitReverificationPhotos(); }); + $("#midcourse_reverify_button").click(function() { + submitMidcourseReverificationPhotos(); + }); + // 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'); + $("#midcourse_reverify_button").toggleClass('disabled'); }); diff --git a/lms/static/sass/application-extend1.scss.mako b/lms/static/sass/application-extend1.scss.mako index 310abf9f2d..cb993e14b6 100644 --- a/lms/static/sass/application-extend1.scss.mako +++ b/lms/static/sass/application-extend1.scss.mako @@ -12,8 +12,8 @@ // base - utilities @import 'base/reset'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index 9473a41e4a..cfb6a1b698 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -12,8 +12,8 @@ // base - utilities @import 'base/reset'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- @@ -41,6 +41,7 @@ // base - elements @import 'elements/typography'; @import 'elements/controls'; +@import 'elements/system-feedback'; // base - specific views @import 'views/verification'; diff --git a/lms/static/sass/application.scss.mako b/lms/static/sass/application.scss.mako index 7d6da444ce..5365ec52ac 100644 --- a/lms/static/sass/application.scss.mako +++ b/lms/static/sass/application.scss.mako @@ -11,8 +11,8 @@ // base - utilities @import 'base/reset'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss index 5a5a4fde94..22a1d96dbd 100644 --- a/lms/static/sass/base/_mixins.scss +++ b/lms/static/sass/base/_mixins.scss @@ -54,6 +54,30 @@ // ==================== + +// extends - UI - used for page/view-level wrappers (for centering/grids) +%ui-wrapper { + @include clearfix(); + @include box-sizing(border-box); + width: 100%; +} + +// extends - UI - window +%ui-window { + @include clearfix(); + border-radius: 3px; + box-shadow: 0 1px 2px 1px $shadow-l1; + margin-bottom: $baseline; + border: 1px solid $light-gray; + background: $white; +} + +// extends - UI archetypes - well +%ui-well { + box-shadow: inset 0 1px 2px 1px $shadow-l1; + padding: ($baseline*0.75) $baseline; +} + // extends - UI - visual link %ui-fake-link { cursor: pointer; diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 5dda2071b5..98a45a2281 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -224,6 +224,15 @@ $error-color: $error-red; $warning-color: $m-pink; $confirm-color: $m-green; +// Notifications +$notify-banner-bg-1: rgb(56,56,56); +$notify-banner-bg-2: rgb(136,136,136); +$notify-banner-bg-3: rgb(223,223,223); + +$alert-color: rgb(212, 64, 64); //rich red +$warning-color: rgb(237, 189, 60); //rich yellow +$success-color: rgb(37, 184, 90); //rich green + // ==================== // MISC: visual horizontal rules @@ -308,3 +317,8 @@ $video-thumb-url: '../images/courses/video-thumb.jpg'; $f-serif: 'Bree Serif', Georgia, Cambria, 'Times New Roman', Times, serif; $f-sans-serif: 'Open Sans','Helvetica Neue', Helvetica, Arial, sans-serif; $f-monospace: 'Bitstream Vera Sans Mono', Consolas, Courier, monospace; + +// SPLINT: colors + +$msg-bg: $action-primary-bg; + diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index 4bd8cfe4bd..2cdd1c2031 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -2,8 +2,8 @@ @import 'base/reset'; @import 'base/font_face'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- diff --git a/lms/static/sass/elements/_system-feedback.scss b/lms/static/sass/elements/_system-feedback.scss new file mode 100644 index 0000000000..5b041d0a56 --- /dev/null +++ b/lms/static/sass/elements/_system-feedback.scss @@ -0,0 +1,143 @@ +// lms - elements - system feedback +// ==================== + +// messages + +// UI : message +.wrapper-msg { + display: block; + margin-bottom: ($baseline/4); + box-shadow: 0 0 5px $shadow-d1 inset; + background: $notify-banner-bg-1; + padding: $baseline ($baseline*1.5); + + &.is-hidden { + display: none; + } + + // basic object + .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; + } + + .msg-content { + + .title { + @extend %t-title5; + @extend %t-weight4; + margin-bottom: ($baseline/4); + color: inherit; + text-transform: none; + letter-spacing: 0; + } + + .copy { + @extend %t-copy-sub1; + color: inherit; + + p { // nasty reset + @extend %t-copy-sub1; + color: inherit; + } + } + } + + .has-actions { + + .msg-content { + width: flex-grid(10,12); + } + + .nav-actions { + width: flex-grid(2,12); + display: inline-block; + vertical-align: middle; + text-align: right; + + .action-primary { + @extend %btn-primary-green; + } + } + } + + .is-dismissable { + + .msg-content { + width: flex-grid(11,12); + } + + .action-dismiss { + width: flex-grid(1,12); + display: inline-block; + vertical-align: top; + text-align: right; + + .button-dismiss { //ugly reset on button element + @extend %t-icon4; + background: none; + box-shadow: none; + border: none; + text-shadow: none; + color: inherit; + + &:hover { + color: $action-primary-bg; + } + } + } + } + + // object variations + &.urgency-high { + background: $notify-banner-bg-1; + + .msg { + color: $white; + } + } + + &.urgency-mid { + background: $notify-banner-bg-2; + + .msg { + color: $white; + } + } + + &.urgency-low { + background: $notify-banner-bg-3; + + .msg { + color: $black; + } + } + + &.alert { + border-top: 3px solid $alert-color; + } + + &.warning { + border-top: 3px solid $warning-color; + } + + &.success { + border-top: 3px solid $success-color; + } +} + + +// prompts + +// notifications + +// alerts diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 6d6a782b73..ef864cb392 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -133,6 +133,52 @@ } } } + + .reverify-status-list { + padding: 0 0 0 ($baseline/2); + margin: ($baseline/4) 0; + + .status-item { + @extend %t-copy-sub2; + margin-bottom: 7px; + border-bottom: 0; + padding: 0; + + [class^="icon-"] { + display: inline-block; + vertical-align: top; + margin: ($baseline/10) ($baseline/4) 0 0; + } + + &.is-open [class^="icon-"] { + color: $action-primary-bg; + } + + &.is-pending [class^="icon-"] { + color: $warning-color; + } + + &.is-approved [class^="icon-"] { + color: $success-color; + } + + &.is-denied [class^="icon-"] { + color: $alert-color; + } + + .label { + @extend %text-sr; + } + + .course-name { + @include line-height(12); + display: inline-block; + vertical-align: top; + width: 80%; + color: inherit; + } + } + } } .news-carousel { diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 9bd1274928..fe337c556a 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -1,87 +1,6 @@ // lms - views - verification flow // ==================== -// MISC: extends - type -// application: canned headings -%hd-lv1 { - @extend %t-title1; - @extend %t-weight1; - color: $m-gray-d4; - margin: 0 0 ($baseline*2) 0; -} - -%hd-lv2 { - @extend %t-title4; - @extend %t-weight1; - margin: 0 0 ($baseline*0.75) 0; - border-bottom: 1px solid $m-gray-l4; - padding-bottom: ($baseline/2); - color: $m-gray-d4; -} - -%hd-lv3 { - @extend %t-title6; - @extend %t-weight4; - margin: 0 0 ($baseline/4) 0; - color: $m-gray-d4; -} - -%hd-lv4 { - @extend %t-title6; - @extend %t-weight2; - margin: 0 0 $baseline 0; - color: $m-gray-d4; -} - -%hd-lv5 { - @extend %t-title7; - @extend %t-weight4; - margin: 0 0 ($baseline/4) 0; - color: $m-gray-d4; -} - -// application: canned copy -%copy-base { - @extend %t-copy-base; - color: $m-gray-d2; -} - -%copy-lead1 { - @extend %t-copy-lead2; - color: $m-gray; -} - -%copy-detail { - @extend %t-copy-sub1; - @extend %t-weight3; - color: $m-gray-d1; -} - -%copy-metadata { - @extend %t-copy-sub2; - color: $m-gray-d1; - - - %copy-metadata-value { - @extend %t-weight2; - } - - %copy-metadata-value { - @extend %t-weight4; - } -} - -// application: canned links -%copy-link { - border-bottom: 1px dotted transparent; - - &:hover, &:active { - border-color: $link-color-d1; - } -} - -// ==================== - // MISC: extends - button %btn-verify-primary { @extend %btn-primary-green; @@ -89,26 +8,6 @@ // ==================== -// MISC: extends - UI - window -%ui-window { - @include clearfix(); - border-radius: ($baseline/10); - box-shadow: 0 1px 2px 1px $shadow-l1; - margin-bottom: $baseline; - border: 1px solid $m-gray-l3; - background: $white; -} - -// ==================== - -// MISC: extends - UI - well -%ui-well { - box-shadow: inset 0 1px 2px 1px $shadow-l1; - padding: ($baseline*0.75) $baseline; -} - -// ==================== - // MISC: expandable UI .is-expandable { @@ -153,7 +52,8 @@ // ==================== // VIEW: all verification steps -.verification-process { +.verification-process, +.midcourse-reverification-process { // reset: box-sizing (making things so right its scary) * { @@ -1894,7 +1794,477 @@ } } } + + // VIEW: midcourse re-verification + &.midcourse-reverification-process { + + // step-dash + + &.step-dash { + + .content-main > .title { + @extend %t-title7; + display: block; + font-weight: 600; + color: $m-gray; + } + + .wrapper-reverify-open, + .wrapper-reverify-status { + display: inline-block; + vertical-align: top; + width: 48%; + } + + .copy .title { + @extend %t-title6; + font-weight: 600; + } + + .wrapper-reverify-status .title { + @extend %t-title6; + font-weight: normal; + color: $m-gray; + } + + .action-reverify { + padding: ($baseline/2) ($baseline*0.75); + } + + .reverification-list { + margin-right: ($baseline*1.5); + padding: 0; + list-style-type: none; + + .item { + box-shadow: 0 2px 5px 0 $shadow-l1 inset; + margin: ($baseline*.75) ($baseline*.75) ($baseline*.75) 0; + border: 1px solid $m-gray-t2; + + &.complete { + border: 1px solid $success-color; + + .course-info { + opacity: .5; + + .course-name { + font-weight: normal; + } + } + + .reverify-status { + @extend %t-weight4; + border-top: 1px solid $light-gray; + background-color: $m-gray-l4; + color: $success-color; + } + } + + &.pending { + border: 1px solid $warning-color; + + .course-info { + opacity: .5; + + .course-name { + font-weight: normal; + } + } + + .reverify-status { + @extend %t-weight4; + border-top: 1px solid $light-gray; + background-color: $m-gray-l4; + color: $warning-color; + } + } + + &.failed { + border: 1px solid $alert-color; + + .course-info { + opacity: .5; + + .course-name { + font-weight: normal; + } + } + + .reverify-status { + @extend %t-weight4; + border-top: 1px solid $light-gray; + background-color: $m-gray-l4; + color: $alert-color; + } + } + } + + .course-info { + margin-bottom: ($baseline/2); + padding: ($baseline/2) ($baseline*.75); + } + + .course-name { + @extend %t-title5; + display: block; + font-weight: bold; + } + + .deadline { + @extend %copy-detail; + display: block; + margin-top: ($baseline/4); + } + + .reverify-status { + background-color: $light-gray; + padding: ($baseline/2) ($baseline*.75); + } + } + + .support { + margin-top: $baseline; + @extend %t-copy-sub1; + } + + .wrapper-reverification-help { + margin-top: $baseline; + border-top: 1px solid $light-gray; + padding-top: ($baseline*1.5); + + .faq-item { + display: inline-block; + vertical-align: top; + width: flex-grid(4,12); + padding-right: $baseline; + + &:last-child { + padding-right: 0; + } + + .faq-answer { + @extend %t-copy-sub1; + } + } + } + } + + // step-photos + &.step-photos { + + .block-photo .title { + @extend %t-title4; + color: $m-blue-d1; + } + + .wrapper-task { + @include clearfix(); + width: flex-grid(12,12); + margin: $baseline 0; + + .wrapper-help { + float: right; + width: flex-grid(6,12); + padding: 0 $baseline; + + .help { + margin-bottom: ($baseline*1.5); + + &:last-child { + margin-bottom: 0; + } + + .title { + @extend %hd-lv3; + } + + .copy { + @extend %copy-detail; + } + + .example { + color: $m-gray-l2; + } + + // help - general list + .list-help { + margin-top: ($baseline/2); + color: $black; + + .help-item { + margin-bottom: ($baseline/4); + border-bottom: 1px solid $m-gray-l4; + padding-bottom: ($baseline/4); + + &:last-child { + margin-bottom: 0; + border-bottom: none; + padding-bottom: 0; + } + } + + .help-item-emphasis { + @extend %t-weight4; + } + } + + // help - faq + .list-faq { + margin-bottom: $baseline; + } + } + } + + .task { + @extend %ui-window; + float: left; + width: flex-grid(6,12); + margin-right: flex-gutter(); + } + + .controls { + padding: ($baseline*0.75) $baseline; + background: $m-gray-l4; + + .list-controls { + position: relative; + } + + .control { + position: absolute; + + .action { + @extend %btn-primary-blue; + padding: ($baseline/2) ($baseline*0.75); + + *[class^="icon-"] { + @extend %t-icon4; + padding: ($baseline*.25) ($baseline*.5); + display: block; + } + } + + // STATE: hidden + &.is-hidden { + visibility: hidden; + } + + // STATE: shown + &.is-shown { + visibility: visible; + } + + // STATE: approved + &.approved { + + .action { + @extend %btn-verify-primary; + padding: ($baseline/2) ($baseline*0.75); + } + } + } + + // control - redo + .control-redo { + position: absolute; + left: ($baseline/2); + } + + // control - take/do + .control-do { + left: 45%; + } + + // control - approve + .control-approve { + position: absolute; + right: ($baseline/2); + } + } + + .msg { + @include clearfix(); + margin-top: ($baseline*2); + + .copy { + float: left; + width: flex-grid(8,12); + margin-right: flex-gutter(); + } + + .list-actions { + position: relative; + top: -($baseline/2); + float: left; + width: flex-grid(4,12); + text-align: right; + + .action-retakephotos a { + @extend %btn-primary-blue; + @include font-size(14); + padding: ($baseline/2) ($baseline*.75); + } + } + } + + .msg-followup { + border-top: ($baseline/10) solid $m-gray-t0; + padding-top: $baseline; + } + } + + .review-task { + margin-bottom: ($baseline*1.5); + padding: ($baseline*0.75) $baseline; + border-radius: ($baseline/10); + background: $m-gray-l4; + + &:last-child { + margin-bottom: 0; + } + + > .title { + @extend %hd-lv3; + } + + .copy { + @extend %copy-base; + + strong { + @extend %t-weight5; + color: $m-gray-d4; + } + } + } + + + // individual task - name + .review-task-name { + @include clearfix(); + border: 1px solid $light-gray; + + .copy { + float: left; + width: flex-grid(8,12); + margin-right: flex-gutter(); + } + + .list-actions { + position: relative; + top: -($baseline); + float: left; + width: flex-grid(4,12); + text-align: right; + + .action-editname a { + @extend %btn-primary-blue; + @include font-size(14); + padding: ($baseline/2) ($baseline*.75); + } + } + } + + .nav-wizard { + padding: ($baseline*.75) $baseline; + + .prompt-verify { + float: left; + width: flex-grid(6,12); + margin: 0 flex-gutter() 0 0; + + .title { + @extend %hd-lv4; + margin-bottom: ($baseline/4); + } + + .copy { + @extend %t-copy-sub1; + @extend %t-weight3; + } + + .list-actions { + margin-top: ($baseline/2); + } + + .action-verify label { + @extend %t-copy-sub1; + } + } + + .wizard-steps { + margin-top: ($baseline/2); + + .wizard-step { + margin-right: flex-gutter(); + display: inline-block; + vertical-align: middle; + + &:last-child { + margin-right: 0; + } + } + } + } + + + .modal { + + fieldset { + margin-top: $baseline; + } + + .close-modal { + @include font-size(24); + color: $m-blue-d3; + + &:hover, &:focus { + color: $m-blue-d1; + border: none; + } + } + } + + } + } + + &.step-confirmation { + + .instruction { + display: inline-block; + width: flex-grid(8,12); + vertical-align: top; + } + + .actions-next { + display: inline-block; + width: flex-grid(4,12); + vertical-align: top; + margin-top: $baseline; + } + + .nav-item { + display: block; + margin: 0 0 $baseline 0; + text-align: center; + + &.conditional:after { + content: "or"; + display: block; + margin: $baseline 0; + } + } + + } } + + +//reverify notification special styles +.msg-reverify { + .reverify-list { + margin: ($baseline/4) 0; + } +} + // ==================== // STATE: already verified diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 450eea94a2..a4fb15a69f 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -181,6 +181,7 @@ ${fragment.foot_html()} % endif % if accordion: + <%include file="/dashboard/_dashboard_prompt_midcourse_reverify.html" /> <%include file="/courseware/course_navigation.html" args="active_page='courseware'" /> % endif diff --git a/lms/templates/courseware/info.html b/lms/templates/courseware/info.html index 518cb0f784..612ac5c08b 100644 --- a/lms/templates/courseware/info.html +++ b/lms/templates/courseware/info.html @@ -4,13 +4,16 @@ <%inherit file="/main.html" /> <%namespace name='static' file='/static_content.html'/> -<%block name="pagetitle">${_("{course.display_number_with_default} Course Info").format(course=course) | h} +<%block name="pagetitle">${__("{course_number} Course Info").format(course_number=course.display_number_with_default)} <%block name="headextra"> <%static:css group='style-course-vendor'/> <%static:css group='style-course'/> +<%block name="title">${_("{course_number} Course Info").format(course_number=course.display_number_with_default)} + +<%include file="/dashboard/_dashboard_prompt_midcourse_reverify.html" /> <%include file="/courseware/course_navigation.html" args="active_page='info'" /> diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index 534f7222fa..59eedd21c4 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -26,6 +26,7 @@ ${progress_graph.body(grade_summary, course.grade_cutoffs, "grade-detail-graph", +<%include file="/dashboard/_dashboard_prompt_midcourse_reverify.html" /> <%include file="/courseware/course_navigation.html" args="active_page='progress'" />
diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 33470d1d0b..a2104b5d7a 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -23,6 +23,15 @@ $(this).closest('.message.is-expandable').toggleClass('is-expanded'); } + $("#failed-verification-button-dismiss").click(function(event) { + $.ajax({ + url: "${reverse('verify_student_toggle_failed_banner_off')}", + type: "post", + data: { 'user_id': ${user.id}, } + }) + $("#failed-verification-banner").addClass('is-hidden'); + }) + $("#upgrade-to-verified").click(function(event) { user = $(event.target).data("user"); course = $(event.target).data("course-id"); @@ -152,6 +161,12 @@ +% if reverifications["must_reverify"] or reverifications["denied"]: +
+ <%include file='dashboard/_dashboard_prompt_midcourse_reverify.html' /> +
+% endif +
%if message: @@ -189,6 +204,8 @@ <%include file='dashboard/_dashboard_status_verification.html' /> + <%include file='dashboard/_dashboard_reverification_sidebar.html' /> +
diff --git a/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html b/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html new file mode 100644 index 0000000000..8e200db401 --- /dev/null +++ b/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html @@ -0,0 +1,82 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +% if reverifications["must_reverify"]: + % if len(reverifications["must_reverify"]) > 1: + +
+
+
+

${_("You need to re-verify to continue")}

+
+

+ ${_("To continue in the ID Verified track in the following courses, you need to re-verify your identity:")} +

+
    + % for item in reverifications["must_reverify"]: +
  • + ${_('{course_name}: Re-verify by {date}').format(course_name="item.course_name", date=item.date)} +
  • + % endfor +
+
+
+ +
+
+ + + % elif reverifications["must_reverify"]: +
+
+
+

${_("You need to re-verify to continue")}

+ % for item in reverifications["must_reverify"]: +
+

+ ${_('To continue in the ID Verified track in {course_name}, you need to re-verify your identity by {date}.').format(course_name="" + item.course_name + "", date=item.date)} +

+
+
+ +
+
+ + % endfor + %endif +%endif + +%if reverifications["denied"] and denied_banner: +
+
+
+

${_("Your re-verification failed")}

+ % for item in reverifications["denied"]: + % if item.display: +
+

+ ${_('Your re-verification for {course_name} failed and you are no longer eligible for a Verified Certificate. If you think this is in error, please contact us at {email}.').format(course_name="" + item.course_name+ "", email='{email}'.format( + email=billing_email + ))} +

+
+
+
+ +
+
+
+ +% endif +% endfor +%endif diff --git a/lms/templates/dashboard/_dashboard_reverification_sidebar.html b/lms/templates/dashboard/_dashboard_reverification_sidebar.html new file mode 100644 index 0000000000..7d9ca6c820 --- /dev/null +++ b/lms/templates/dashboard/_dashboard_reverification_sidebar.html @@ -0,0 +1,37 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + + + +% if reverifications["must_reverify"] or reverifications["pending"] or reverifications["denied"] or reverifications["approved"]: +
  • + ${_("Re-verification now open for:")} + +
      + + % if reverifications["must_reverify"]: + % for item in reverifications["must_reverify"]: +
    • ${_('Re-verify now:')} ${item.course_name}
    • + % endfor + %endif + + % if reverifications["pending"]: + % for item in reverifications["pending"]: +
    • ${_('Pending:')} ${item.course_name}
    • + % endfor + %endif + + % if reverifications["denied"]: + % for item in reverifications["denied"]: +
    • ${_('Denied:')} ${item.course_name}
    • + % endfor + %endif + + % if reverifications["approved"]: + % for item in reverifications["approved"]: +
    • ${_('Approved:')} ${item.course_name}
    • + % endfor + %endif +
    +
  • +%endif diff --git a/lms/templates/verify_student/_reverification_support.html b/lms/templates/verify_student/_reverification_support.html index 44bf0c89d8..4e6bda98ff 100644 --- a/lms/templates/verify_student/_reverification_support.html +++ b/lms/templates/verify_student/_reverification_support.html @@ -6,12 +6,7 @@
  • ${_("Why Do I Need to Re-Verify?")}

    -

    ${_("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''.format( - address=settings.DEFAULT_FEEDBACK_EMAIL, - subject_line=_('Problem with ID re-verification')), - link_end=u'')}

    +

    ${_("At key points in a course, the professor will ask you to re-verify your identity. We will send the new photo to be matched up with the photo of the original ID you submitted when you signed up for the course.")}

  • diff --git a/lms/templates/verify_student/_verification_header.html b/lms/templates/verify_student/_verification_header.html index 80a1e939b0..682652b870 100644 --- a/lms/templates/verify_student/_verification_header.html +++ b/lms/templates/verify_student/_verification_header.html @@ -4,6 +4,8 @@

    %if upgrade: ${_("You are upgrading your registration for")} + %elif reverify: + ${_("You are re-verifying for")} %else: ${_("You are registering for")} %endif @@ -19,6 +21,8 @@ %if upgrade: ${_("Upgrading to:")} ${_("ID Verified")} + %elif reverify: + ${_("Re-verifying for:")} ${_("ID Verified")} %else: ${_("Registering as: ")} ${_("ID Verified")} %endif diff --git a/lms/templates/verify_student/midcourse_photo_reverification.html b/lms/templates/verify_student/midcourse_photo_reverification.html new file mode 100644 index 0000000000..535b5f7639 --- /dev/null +++ b/lms/templates/verify_student/midcourse_photo_reverification.html @@ -0,0 +1,194 @@ +<%! 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">midcourse-reverification-process is-not-verified step-photos register +<%block name="title">${_("Re-Verify")} + +<%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 + +
    +
    + +
    +
    + +
    + + <%include file="_verification_header.html" args="course_name=course_name" /> + +
    +
    +

    ${_("Re-Take Your Photo")}

    +
    +

    ${_("Use your webcam to take a picture of your face so we can match it with your original verification.")}

    +
    + +
    + +
    +
    + +
    +

    ${_("Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission.")}

    +
    + +
    + +
    + + +
    + +
    +
    +

    ${_("Tips on taking a successful photo")}

    + +
    +
      +
    • ${_("Make sure your face is well-lit")}
    • +
    • ${_("Be sure your entire face is inside the frame")}
    • +
    • ${_("Can we match the photo you took with the one on your ID?")}
    • +
    • ${_("Once in position, use the camera button")} () ${_("to capture your picture")}
    • +
    • ${_("Use the checkmark button")} () ${_("once you are happy with the photo")}
    • +
    +
    +
    + +
    +

    ${_("Common Questions")}

    + +
    +
    +
    ${_("Why do you need my photo?")}
    +
    ${_("As part of the verification process, we need your photo to confirm that you are you.")}
    + +
    ${_("What do you do with this picture?")}
    +
    ${_("We only use it to verify your identity. It is not displayed anywhere.")}
    +
    +
    +
    +
    +
    + + +
    + +

    ${_("Check Your Name")}

    + +
    +

    ${_("Make sure your full name on your edX account ({full_name}) matches the ID you originally submitted. We will also use this as the name on your certificate.").format(full_name="" + user_full_name + "")}

    +
    + + +
    + + + + + +
    +
    +
    +
    +
    + + <%include file="_reverification_support.html" /> +
    +
    + +<%include file="_modal_editname.html" /> + diff --git a/lms/templates/verify_student/midcourse_reverification_confirmation.html b/lms/templates/verify_student/midcourse_reverification_confirmation.html new file mode 100644 index 0000000000..7cfadc6283 --- /dev/null +++ b/lms/templates/verify_student/midcourse_reverification_confirmation.html @@ -0,0 +1,47 @@ + +<%! 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="content"> + +
    +
    + +
    +
    +
    +
    +
    +

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

    + +
    +

    ${_("We have received your re-verification details and submitted them for review. Your dashboard will show the notification status once the review is complete.")}

    +

    ${_("Please note: The professor may ask you to re-verify again at other key points in the course.")}

    +
    + + + +
    +
    +
    +
    +
    + + <%include file="_reverification_support.html" /> +
    +
    + diff --git a/lms/templates/verify_student/midcourse_reverify_dash.html b/lms/templates/verify_student/midcourse_reverify_dash.html new file mode 100644 index 0000000000..06cf47ba65 --- /dev/null +++ b/lms/templates/verify_student/midcourse_reverify_dash.html @@ -0,0 +1,147 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%block name="bodyclass">midcourse-reverification-process step-dash register +<%block name="title"> + + ${_("Reverification Status")} + + + +<%block name="content"> +
    +
    +
    +
    + +

    ${_("You are in the ID Verified track")}

    + +
    + + % if reverifications["must_reverify"]: # If you have reverifications to do + % if len(reverifications["must_reverify"]) > 1: # If you have >1 reverifications +
    +

    ${_("You currently need to re-verify for the following courses:")}

    +
      + % for item in reverifications["must_reverify"]: # for 1st +
    • +
      +

      ${item.course_name} (${item.course_number})

      +

      ${_('Re-verify by {date}').format(date="" + item.date + "")}

      +
      +

      Re-verify for ${item.course_number}

      +
    • + % endfor +
    +
    + + % else: # You only have one reverification +
    +

    ${_("You currently need to re-verify for the following course:")}

    + +
      + % for item in reverifications["must_reverify"]: +
    • +
      +

      ${item.course_name} (${item.course_number})

      +

      ${_('Re-verify by {date}').format(date="" + item.date + "")}

      +
      +

      Re-verify for ${item.course_number}

      +
    • + % endfor +
    +
    + %endif + % else: +
    +

    ${_("You have no re-verifications at present.")}

    +
    + %endif + + % if reverifications["pending"] or reverifications["approved"] or reverifications["denied"]: +
    +

    ${_("The status of your submitted re-verifications:")}

    +
      + + % for item in reverifications["pending"]: +
    • +
      +

      ${item.course_name} (${item.course_number})

      +

      ${_('Re-verify by {date}').format(date="" + item.date + "")}

      +
      +

      ${_("Pending")}

      +
    • + % endfor + + % for item in reverifications["approved"]: +
    • +
      +

      ${item.course_name} (${item.course_number})

      +

      ${_('Re-verify by {date}').format(date="" + item.date + "")}

      +
      +

      ${_("Complete")}

      +
    • + % endfor + + % for item in reverifications["denied"]: +
    • +
      +

      ${item.course_name} (${item.course_number})

      +

      ${_('Re-verify by {date}').format(date="" + item.date + "")}

      +
      +

      ${_("Failed")}

      +
    • + % endfor + +
    +
    + % endif + + % if reverifications["must_reverify"]: +

    ${_("Don't want to re-verify right now? {a_start}Return to where you left off{a_end}").format( + a_start=''.format(url=referer), + a_end="", + )}

    + % else: +

    ${_("{a_start}Return to where you left off{a_end}").format( + a_start=''.format(url=referer), + a_end="", + )}

    + % endif + +
    + +
    + +
    +

    ${_("Why do I need to re-verify?")}

    +
    + +

    ${_("At key points in a course, the professor will ask you to re-verify your identity by submitting a new photo of your face. We will send the new photo to be matched up with the photo of the original ID you submitted when you signed up for the course. If you are taking multiple courses, you may need to re-verify multiple times, once for every important point in each course you are taking as a verified student.")}

    +
    +
    + +
    +

    ${_("What will I need to re-verify?")}

    +
    + +

    ${_("Because you are just confirming that you are still you, the only thing you will need to do to re-verify is to submit a new photo of your face with your webcam. The process is quick and you will be brought back to where you left off so you can keep on learning.")}

    + +

    ${_("If you changed your name during the semester and it no longer matches the original ID you submitted, you will need to re-edit your name to match as well.")}

    +
    +
    + +
    +

    ${_("What if I have trouble with my re-verification?")}

    +
    +

    ${_('Because of the short time that re-verification is open, you will not be able to correct a failed verification. If you think there was an error in the review, please contact us at {email}').format(email='{email}.'.format(email=billing_email))}

    +
    +
    +
    + +
    +
    + +
    +
    + diff --git a/lms/templates/verify_student/prompt_midcourse_reverify.html b/lms/templates/verify_student/prompt_midcourse_reverify.html new file mode 100644 index 0000000000..4b5f9f75e7 --- /dev/null +++ b/lms/templates/verify_student/prompt_midcourse_reverify.html @@ -0,0 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> +

    ${_("You need to re-verify to continue")}

    +

    + ${_("To continue in the ID Verified track in {course}, you need to re-verify your identity by {date}. Go to URL.").format(email)} +

    diff --git a/lms/templates/verify_student/reverification_window_expired.html b/lms/templates/verify_student/reverification_window_expired.html new file mode 100644 index 0000000000..b113f8d44f --- /dev/null +++ b/lms/templates/verify_student/reverification_window_expired.html @@ -0,0 +1,45 @@ + +<%! 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 Failed")} + +<%block name="js_extra"> + + + +<%block name="content"> + +
    +
    + +
    +
    +
    +
    +
    +

    ${_("Re-Verification Failed")}

    + +
    +

    ${_("Your re-verification was submitted after the re-verification deadline, and you can no longer be re-verified.")}

    +

    ${_("Please contact support if you believe this message to be in error.")}

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