diff --git a/common/djangoapps/reverification/tests/test_models.py b/common/djangoapps/reverification/tests/test_models.py index df0c34a10d..4f94fb5ef7 100644 --- a/common/djangoapps/reverification/tests/test_models.py +++ b/common/djangoapps/reverification/tests/test_models.py @@ -57,14 +57,15 @@ class TestMidcourseReverificationWindow(TestCase): ) def test_no_overlapping_windows(self): - MidcourseReverificationWindowFactory( + 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 = MidcourseReverificationWindowFactory( + 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) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 692bc0aac8..a78f7e0a23 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -84,7 +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') +ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=C0103 def csrf_token(context): @@ -203,18 +203,17 @@ def reverification_info(course_enrollment_pairs, user, statuses): reverifications = defaultdict(list) for (course, enrollment) in course_enrollment_pairs: info = single_course_reverification_info(user, course, enrollment) - for status in statuses: - if info and (status in info): - reverifications[status].append(info) + if info: + reverifications[info.status].append(info) # Sort the data by the reverification_end_date for status in statuses: if reverifications[status]: - reverifications[status] = sorted(reverifications[status], key=lambda x: x.date) + reverifications[status].sort(key=lambda x: x.date) return reverifications -def single_course_reverification_info(user, course, enrollment): +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 @@ -226,7 +225,7 @@ def single_course_reverification_info(user, course, enrollment): enrollment (CourseEnrollment): the object representing the type of enrollment user has in course Returns: - 5-namedtuple: (course_id, course_name, course_number, date, status) + 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)) @@ -238,6 +237,7 @@ def single_course_reverification_info(user, course, enrollment): 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), ) @@ -470,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, @@ -484,6 +488,7 @@ def dashboard(request): 'verification_status': verification_status, 'verification_msg': verification_msg, 'show_refund_option_for': show_refund_option_for, + 'denied_banner': denied_banner, } return render_to_response('dashboard.html', context) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index bb257f15a5..d87a3830e2 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -456,9 +456,15 @@ def course_info(request, course_id): masq = setup_masquerade(request, staff_access) # allow staff to toggle masquerade on info page reverifications = fetch_reverify_banner_info(request, course_id) - context = {'request': request, 'course_id': course_id, 'cache': None, - 'course': course, 'staff_access': staff_access, 'masquerade': masq, - 'reverifications': reverifications, } + 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) @@ -682,10 +688,7 @@ def fetch_reverify_banner_info(request, course_id): course = course_from_id(course_id) info = single_course_reverification_info(user, course, enrollment) if info: - if "must_reverify" in info: - reverifications["must_reverify"].append(info) - elif "denied" in info: - reverifications["denied"].append(info) + reverifications[info.status].append(info) return reverifications @login_required 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 e6ba91eea8..e8387dff08 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -149,6 +149,11 @@ class PhotoVerification(StatusModel): 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) @@ -223,10 +228,9 @@ class PhotoVerification(StatusModel): window is anything else, this will check for the reverification associated with that window. """ - if window: - valid_statuses = ['submitted', 'approved'] - else: - 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, @@ -465,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): """ @@ -518,7 +544,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification): """ 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): + if (not all_windows.exists()): return True for window in all_windows: diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index 6ffff950a0..beac953dec 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -381,6 +381,18 @@ class TestPhotoVerification(TestCase): 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() diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index 1fb7191908..799264cda1 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -70,4 +70,10 @@ urlpatterns = patterns( 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 c771144484..9b2d7b2381 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -416,10 +416,20 @@ def midcourse_reverify_dash(request): context = { "user_full_name": user.profile.name, 'reverifications': reverifications, + 'referer': request.META.get('HTTP_REFERER'), } 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): """ @@ -429,7 +439,7 @@ def reverification_submission_confirmation(_request): @login_required -def midcourse_reverification_confirmation(request): # pylint: disable=W0613 +def midcourse_reverification_confirmation(_request): # pylint: disable=C0103 """ Shows the user a confirmation page if the submission to SoftwareSecure was successful """ diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 1c24be8174..6461ab1a26 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").css("display","none"); + }) + $("#upgrade-to-verified").click(function(event) { user = $(event.target).data("user"); course = $(event.target).data("course-id"); @@ -152,7 +161,6 @@ - % if reverifications["must_reverify"] or reverifications["denied"]:
<%include file='dashboard/_dashboard_prompt_midcourse_reverify.html' /> diff --git a/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html b/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html index 1c437c8789..b4dd942579 100644 --- a/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html +++ b/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html @@ -58,12 +58,13 @@ %endif %endif -%if reverifications["denied"]: -
+%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 support@edx.org.').format(course_name=item.course_name)} @@ -71,10 +72,11 @@

- +
+% endif % endfor %endif diff --git a/lms/templates/dashboard/_dashboard_reverification_sidebar.html b/lms/templates/dashboard/_dashboard_reverification_sidebar.html index 106ef50cf8..7d9ca6c820 100644 --- a/lms/templates/dashboard/_dashboard_reverification_sidebar.html +++ b/lms/templates/dashboard/_dashboard_reverification_sidebar.html @@ -11,25 +11,25 @@ % if reverifications["must_reverify"]: % for item in reverifications["must_reverify"]: -
  • ${_('Re-verify now: {course_name}').format(course_name=item.course_name)}
  • +
  • ${_('Re-verify now:')} ${item.course_name}
  • % endfor %endif % if reverifications["pending"]: % for item in reverifications["pending"]: -
  • ${_('Pending: {course_name}').format(course_name=item.course_name)}
  • +
  • ${_('Pending:')} ${item.course_name}
  • % endfor %endif % if reverifications["denied"]: % for item in reverifications["denied"]: -
  • ${_('Denied: {course_name}').format(course_name=item.course_name)}
  • +
  • ${_('Denied:')} ${item.course_name}
  • % endfor %endif % if reverifications["approved"]: % for item in reverifications["approved"]: -
  • ${_('Approved: {course_name}').format(course_name=item.course_name)}
  • +
  • ${_('Approved:')} ${item.course_name}
  • % endfor %endif diff --git a/lms/templates/verify_student/midcourse_reverification_confirmation.html b/lms/templates/verify_student/midcourse_reverification_confirmation.html index c8654d1ecc..7cfadc6283 100644 --- a/lms/templates/verify_student/midcourse_reverification_confirmation.html +++ b/lms/templates/verify_student/midcourse_reverification_confirmation.html @@ -27,7 +27,7 @@