diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index 9fba0e8010cb0b0f1220b7936cebce0cbc695e1c..e299a5bfb5ea2afa7aa9e38d2720cd2359add690 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -116,14 +116,10 @@ def check_verify_status_by_course(user, course_enrollments): verifications = IDVerificationService.verifications_for_user(user) # Check whether the user has an active or pending verification attempt - # To avoid another database hit, we re-use the queryset we have already retrieved. - has_active_or_pending = IDVerificationService.user_has_valid_or_pending( - user, queryset=verifications - ) + has_active_or_pending = IDVerificationService.user_has_valid_or_pending(user) # Retrieve expiration_datetime of most recent approved verification - # To avoid another database hit, we re-use the queryset we have already retrieved. - expiration_datetime = IDVerificationService.get_expiration_datetime(user, verifications) + expiration_datetime = IDVerificationService.get_expiration_datetime(user, ['approved']) verification_expiring_soon = is_verification_expiring_soon(expiration_datetime) # Retrieve verification deadlines for the enrolled courses @@ -154,9 +150,12 @@ def check_verify_status_by_course(user, course_enrollments): # By default, don't show any status related to verification status = None + should_display = True # Check whether the user was approved or is awaiting approval if relevant_verification is not None: + should_display = relevant_verification.should_display_status_to_user() + if relevant_verification.status == "approved": if verification_expiring_soon: status = VERIFY_STATUS_NEED_TO_REVERIFY @@ -214,7 +213,8 @@ def check_verify_status_by_course(user, course_enrollments): status_by_course[enrollment.course_id] = { 'status': status, - 'days_until_deadline': days_until_deadline + 'days_until_deadline': days_until_deadline, + 'should_display': should_display, } if recent_verification_datetime: diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index 805ba14b741fa65a02cc22613acf4952f0edf21e..78e2b4bd2c609df6d33a6ff6ae3a69417753522d 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -716,8 +716,8 @@ def student_dashboard(request): # Verification Attempts # Used to generate the "you must reverify for course x" banner - verification_status, verification_error_codes = IDVerificationService.user_status(user) - verification_errors = get_verification_error_reasons_for_display(verification_error_codes) + verification_status = IDVerificationService.user_status(user) + verification_errors = get_verification_error_reasons_for_display(verification_status['error']) # Gets data for midcourse reverifications, if any are necessary or have failed statuses = ["approved", "denied", "pending", "must_reverify"] @@ -770,7 +770,9 @@ def student_dashboard(request): redirect_message = '' valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired'] - display_sidebar_on_dashboard = len(order_history_list) or verification_status in valid_verification_statuses + display_sidebar_on_dashboard = (len(order_history_list) or + (verification_status['status'] in valid_verification_statuses and + verification_status['should_display'])) # Filter out any course enrollment course cards that are associated with fulfilled entitlements for entitlement in [e for e in course_entitlements if e.enrollment_course_run is not None]: @@ -802,7 +804,8 @@ def student_dashboard(request): 'credit_statuses': _credit_statuses(user, course_enrollments), 'show_email_settings_for': show_email_settings_for, 'reverifications': reverifications, - 'verification_status': verification_status, + 'verification_display': verification_status['should_display'], + 'verification_status': verification_status['status'], 'verification_status_by_course': verify_status_by_course, 'verification_errors': verification_errors, 'block_courses': block_courses, diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index b657f481600475ae5a7dbb1472f78a7435ef836d..5203d749278ec5bef599f9bf425ed7627fae006b 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -598,9 +598,9 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): # inject verification status if verification_service: - verification_status, __ = verification_service.get_status(user_id) + verification_status = verification_service.get_status(user_id) context.update({ - 'verification_status': verification_status, + 'verification_status': verification_status['status'], 'reverify_url': verification_service.reverify_url(), }) diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index 589e0894ccce840eb12b26f0eb6ed4c60604dc6d..dc68c1ee21946c8aa582f21f0f2ef43b0fb4843f 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -82,7 +82,7 @@ def _listen_for_id_verification_status_changed(sender, user, **kwargs): # pylin user_enrollments = CourseEnrollment.enrollments_for_user(user=user) grade_factory = CourseGradeFactory() - expected_verification_status, _ = IDVerificationService.user_status(user) + expected_verification_status = IDVerificationService.user_status(user) for enrollment in user_enrollments: if grade_factory.read(user=user, course=enrollment.course_overview).passed: if fire_ungenerated_certificate_task(user, enrollment.course_id, expected_verification_status): @@ -93,7 +93,7 @@ def _listen_for_id_verification_status_changed(sender, user, **kwargs): # pylin log.info(message.format( user=user.id, course=enrollment.course_id, - status=expected_verification_status + status=expected_verification_status['status'] )) diff --git a/lms/djangoapps/certificates/tasks.py b/lms/djangoapps/certificates/tasks.py index 69fa1083c4c804b51416b56a871023e767469d5d..537108cc880bcd9dc8d465a54fcc7c409504d40c 100644 --- a/lms/djangoapps/certificates/tasks.py +++ b/lms/djangoapps/certificates/tasks.py @@ -31,7 +31,7 @@ def generate_certificate(self, **kwargs): course_key = CourseKey.from_string(kwargs.pop('course_key')) expected_verification_status = kwargs.pop('expected_verification_status', None) if expected_verification_status: - actual_verification_status, _ = IDVerificationService.user_status(student) + actual_verification_status = IDVerificationService.user_status(student) if expected_verification_status != actual_verification_status: raise self.retry(kwargs=original_kwargs) generate_user_certificates(student=student, course_key=course_key, **kwargs) diff --git a/lms/djangoapps/certificates/tests/test_signals.py b/lms/djangoapps/certificates/tests/test_signals.py index 8212b04fbf4f477569cad18b63fb25282189e447..5c2deb051acbb0825b367e90057cb597d54bca72 100644 --- a/lms/djangoapps/certificates/tests/test_signals.py +++ b/lms/djangoapps/certificates/tests/test_signals.py @@ -256,12 +256,17 @@ class LearnerTrackChangeCertsTest(ModuleStoreTestCase): status='submitted' ) attempt.approve() + expected_verification_status = { + 'status': 'approved', + 'error': '', + 'should_display': True, + } mock_generate_certificate_apply_async.assert_called_with( countdown=CERTIFICATE_DELAY_SECONDS, kwargs={ 'student': unicode(self.user_one.id), 'course_key': unicode(self.course_one.id), - 'expected_verification_status': SoftwareSecurePhotoVerification.STATUS.approved + 'expected_verification_status': unicode(expected_verification_status), } ) @@ -277,12 +282,17 @@ class LearnerTrackChangeCertsTest(ModuleStoreTestCase): status='submitted' ) attempt.approve() + expected_verification_status = { + 'status': 'approved', + 'error': '', + 'should_display': True, + } mock_generate_certificate_apply_async.assert_called_with( countdown=CERTIFICATE_DELAY_SECONDS, kwargs={ 'student': unicode(self.user_two.id), 'course_key': unicode(self.course_two.id), - 'expected_verification_status': SoftwareSecurePhotoVerification.STATUS.approved + 'expected_verification_status': unicode(expected_verification_status), } ) diff --git a/lms/djangoapps/certificates/tests/test_tasks.py b/lms/djangoapps/certificates/tests/test_tasks.py index 61d1db1712be9ac149b1b780cd24432f1d2475fd..75658079534fad4b24d2facd793d13ea82f68962 100644 --- a/lms/djangoapps/certificates/tests/test_tasks.py +++ b/lms/djangoapps/certificates/tests/test_tasks.py @@ -45,13 +45,22 @@ class GenerateUserCertificateTest(TestCase): course_key = 'course-v1:edX+CS101+2017_T2' student = UserFactory() + expected_verification_status = { + 'status': 'approved', + 'error': '', + 'should_display': True, + } + kwargs = { 'student': student.id, 'course_key': course_key, - 'expected_verification_status': 'approved' + 'expected_verification_status': expected_verification_status, } - user_status_mock.side_effect = [('pending', ''), ('approved', '')] + user_status_mock.side_effect = [ + {'status': 'pending', 'error': '', 'should_display': True}, + {'status': 'approved', 'error': '', 'should_display': True} + ] generate_certificate.apply_async(kwargs=kwargs).get() diff --git a/lms/djangoapps/commerce/views.py b/lms/djangoapps/commerce/views.py index c5dea7da032ac37dada79bd84c47c54bd40d41f0..0d059cf5dcc157aca8da181a26c503bf23e7e6e2 100644 --- a/lms/djangoapps/commerce/views.py +++ b/lms/djangoapps/commerce/views.py @@ -92,7 +92,7 @@ def checkout_receipt(request): 'page_title': page_title, 'is_payment_complete': is_payment_complete, 'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), - 'verified': IDVerificationService.verification_valid_or_pending(request.user).exists(), + 'verified': IDVerificationService.user_has_valid_or_pending(request.user), 'error_summary': error_summary, 'error_text': error_text, 'for_help_text': for_help_text, diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py index 5fb7b51d1cb466b173c30ac3100a7e48f42c70ac..85dda380742f4147c28a2877637780403fc43c97 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -627,8 +627,8 @@ class VerificationDeadlineDate(DateSummary): @lazy def verification_status(self): """Return the verification status for this user.""" - status, _ = IDVerificationService.user_status(self.user) - return status + verification_status = IDVerificationService.user_status(self.user) + return verification_status['status'] def must_retry(self): """Return True if the user must re-submit verification, False otherwise.""" diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 4da83349c7e125b54d1e38fd6f286def26b5e6ca..992a115fab74ab2893b65c9d628b57d58e668d9b 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -396,7 +396,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase): RequestCache.clear_request_cache() - expected_query_count = 41 + expected_query_count = 42 with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'): with check_mongo_calls(mongo_count): with self.assertNumQueries(expected_query_count): @@ -1999,7 +1999,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): 'failed': 3, 'skipped': 2 } - with self.assertNumQueries(106): + with self.assertNumQueries(114): self.assertCertificatesGenerated(task_input, expected_results) expected_results = { diff --git a/lms/djangoapps/verify_student/migrations/0009_remove_id_verification_aggregate.py b/lms/djangoapps/verify_student/migrations/0009_remove_id_verification_aggregate.py new file mode 100644 index 0000000000000000000000000000000000000000..613c8033a0380e059af4d19ca791d32e4431b1ff --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0009_remove_id_verification_aggregate.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-04-27 16:27 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('verify_student', '0008_populate_idverificationaggregate'), + ] + + operations = [ + migrations.RemoveField( + model_name='idverificationaggregate', + name='content_type', + ), + migrations.RemoveField( + model_name='idverificationaggregate', + name='user', + ), + migrations.DeleteModel( + name='IDVerificationAggregate', + ), + ] diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index b2d51606b0181eec127e844e8b2993e7a8b40a8f..abf2859bdce33b071a5717d1b6989a1bc0ec2d75 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -120,30 +120,24 @@ class IDVerificationAttempt(StatusModel): days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] return self.created_at + timedelta(days=days_good_for) + def should_display_status_to_user(self): + """Whether or not the status from this attempt should be displayed to the user.""" + raise NotImplementedError -class IDVerificationAggregate(IDVerificationAttempt): - """ - IDVerificationAggregate is the source of truth for all instances of IDVerificationAttempt. This - includes all types of verification, including PhotoVerification and SSOVerification. A generic - relation is used to refer to the appropriate Model object. - """ - content_type = models.ForeignKey(ContentType) - object_id = models.PositiveIntegerField() - content_object = GenericForeignKey('content_type', 'object_id') + def active_at_datetime(self, deadline): + """Check whether the verification was active at a particular datetime. - # override these fields so we can set the value - created_at = models.DateTimeField(db_index=True) - updated_at = models.DateTimeField(db_index=True) + Arguments: + deadline (datetime): The date at which the verification was active + (created before and expiration datetime is after today). - class Meta(object): - app_label = "verify_student" - ordering = ['-created_at'] + Returns: + bool - def __unicode__(self): - return 'IDVerificationAggregate for {name} - type: {type}, status: {status}'.format( - name=self.name, - type=self.content_type, - status=self.status, + """ + return ( + self.created_at < deadline and + self.expiration_datetime > datetime.now(pytz.UTC) ) @@ -188,6 +182,10 @@ class SSOVerification(IDVerificationAttempt): status=self.status, ) + def should_display_status_to_user(self): + """Whether or not the status from this attempt should be displayed to the user.""" + return False + class PhotoVerification(IDVerificationAttempt, DeletableByUserValue): """ @@ -281,22 +279,6 @@ class PhotoVerification(IDVerificationAttempt, DeletableByUserValue): abstract = True ordering = ['-created_at'] - def active_at_datetime(self, deadline): - """Check whether the verification was active at a particular datetime. - - Arguments: - deadline (datetime): The date at which the verification was active - (created before and expiration datetime is after today). - - Returns: - bool - - """ - return ( - self.created_at < deadline and - self.expiration_datetime > datetime.now(pytz.UTC) - ) - def parsed_error_msg(self): """ Sometimes, the error message we've received needs to be parsed into @@ -849,6 +831,10 @@ class SoftwareSecurePhotoVerification(PhotoVerification): return response + def should_display_status_to_user(self): + """Whether or not the status from this attempt should be displayed to the user.""" + return True + class VerificationDeadline(TimeStampedModel): """ diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index 0cfc20fa0a04360af11a30cbf16f78190fe9a958..3105db64dbb90b28875d212a6e84442efdcab728 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -4,6 +4,7 @@ Implementation of abstraction layer for other parts of the system to make querie import logging +from itertools import chain from django.conf import settings from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ @@ -12,8 +13,8 @@ from course_modes.models import CourseMode from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from student.models import User -from .models import SoftwareSecurePhotoVerification -from .utils import earliest_allowed_verification_date +from .models import SoftwareSecurePhotoVerification, SSOVerification +from .utils import earliest_allowed_verification_date, most_recent_verification log = logging.getLogger(__name__) @@ -61,121 +62,84 @@ class IDVerificationService(object): This will check for the user's *initial* verification. """ - return cls.verified_query(earliest_allowed_date).filter(user=user).exists() + filter_kwargs = { + 'user': user, + 'status': 'approved', + 'created_at__gte': (earliest_allowed_date or earliest_allowed_verification_date()) + } - @classmethod - def verified_query(cls, earliest_allowed_date=None): - """ - Return a query set for all records with 'approved' state - that are still valid according to the earliest_allowed_date - value or policy settings. - """ - return SoftwareSecurePhotoVerification.objects.filter( - status="approved", - created_at__gte=(earliest_allowed_date or earliest_allowed_verification_date()), - ) + return (SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).exists() or + SSOVerification.objects.filter(**filter_kwargs).exists()) @classmethod def verifications_for_user(cls, user): """ - Return a query set for all records associated with the given user. + Return a list of all verifications associated with the given user. """ - return SoftwareSecurePhotoVerification.objects.filter(user=user) + verifications = [] + for verification in chain(SoftwareSecurePhotoVerification.objects.filter(user=user), + SSOVerification.objects.filter(user=user)): + verifications.append(verification) + return verifications @classmethod def get_verified_users(cls, users): """ - Return the list of user ids that have non expired verifications from the given list of users. - """ - return cls.verified_query().filter(user__in=users).select_related('user') - - @classmethod - def verification_valid_or_pending(cls, user, earliest_allowed_date=None, queryset=None): - """ - Check 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. - This will check for the user's *initial* verification. - - Arguments: - user: - earliest_allowed_date: earliest allowed date given in the - settings - queryset: If a queryset is provided, that will be used instead - of hitting the database. - - Returns: - queryset: queryset of 'PhotoVerification' sorted by 'created_at' in - descending order. - """ - - valid_statuses = ['submitted', 'approved', 'must_retry'] - - if queryset is None: - queryset = SoftwareSecurePhotoVerification.objects.filter(user=user) - - return queryset.filter( - status__in=valid_statuses, - created_at__gte=( - earliest_allowed_date - or earliest_allowed_verification_date() - ) - ).order_by('-created_at') + Return the list of users that have non-expired verifications of either type from + the given list of users. + """ + filter_kwargs = { + 'user__in': users, + 'status': 'approved', + 'created_at__gte': (earliest_allowed_verification_date()) + } + return chain( + SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).select_related('user'), + SSOVerification.objects.filter(**filter_kwargs).select_related('user') + ) @classmethod - def get_expiration_datetime(cls, user, queryset=None): + def get_expiration_datetime(cls, user, statuses): """ - Check whether the user has an approved verification and return the - "expiration_datetime" of most recent "approved" verification. + Check whether the user has a verification with one of the given + statuses and return the "expiration_datetime" of most recent verification that + matches one of the given statuses. Arguments: user (Object): User - queryset: If a queryset is provided, that will be used instead - of hitting the database. + statuses: List of verification statuses (e.g., ['approved']) Returns: - expiration_datetime: expiration_datetime of most recent "approved" - verification. + expiration_datetime: expiration_datetime of most recent verification that + matches one of the given statuses. """ - if queryset is None: - queryset = SoftwareSecurePhotoVerification.objects.filter(user=user) + filter_kwargs = { + 'user': user, + 'status__in': statuses, + } + + photo_id_verifications = SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs) + sso_id_verifications = SSOVerification.objects.filter(**filter_kwargs) - photo_verification = queryset.filter(status='approved').first() - if photo_verification: - return photo_verification.expiration_datetime + attempt = most_recent_verification(photo_id_verifications, sso_id_verifications, 'updated_at') + return attempt and attempt.expiration_datetime @classmethod - def user_has_valid_or_pending(cls, user, earliest_allowed_date=None, queryset=None): + def user_has_valid_or_pending(cls, user): """ Check whether the user has an active or pending verification attempt Returns: bool: True or False according to existence of valid verifications """ - return cls.verification_valid_or_pending(user, earliest_allowed_date, queryset).exists() - - @classmethod - def active_for_user(cls, user): - """ - 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). - - This checks for the original verification. - """ - # 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 = SoftwareSecurePhotoVerification.objects.filter( - user=user, - status='ready' - ).order_by('-created_at') + filter_kwargs = { + 'user': user, + 'status__in': ['submitted', 'approved', 'must_retry'], + 'created_at__gte': earliest_allowed_verification_date() + } - if active_attempts: - return active_attempts[0] - else: - return None + return (SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).exists() or + SSOVerification.objects.filter(**filter_kwargs).exists()) @classmethod def user_status(cls, user): @@ -188,46 +152,56 @@ class IDVerificationService(object): If the verification process is still ongoing, returns 'pending' If the verification has been denied and the user must resubmit photos, returns 'must_reverify' - This checks initial verifications - """ - status = 'none' - error_msg = '' + This checks most recent verification + """ + # should_display only refers to displaying the verification attempt status to a user + # once a verification attempt has been made, otherwise we will display a prompt to complete ID verification. + user_status = { + 'status': 'none', + 'error': '', + 'should_display': True, + } + + # We need to check the user's most recent attempt. + try: + photo_id_verifications = SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-updated_at') + sso_id_verifications = SSOVerification.objects.filter(user=user).order_by('-updated_at') + + attempt = most_recent_verification(photo_id_verifications, sso_id_verifications, 'updated_at') + except IndexError: + # The user has no verification attempts, return the default set of data. + return user_status + + if not attempt: + return user_status + + user_status['should_display'] = attempt.should_display_status_to_user() + if attempt.created_at < earliest_allowed_verification_date(): + if user_status['should_display']: + user_status['status'] = 'expired' + user_status['error'] = _("Your {platform_name} verification has expired.").format( + platform_name=configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), + ) + else: + # If we have a verification attempt that never would have displayed to the user, + # and that attempt is expired, then we should treat it as if the user had never verified. + return user_status - if cls.user_is_verified(user): - status = 'approved' + # If someone is denied their original verification attempt, they can try to reverify. + elif attempt.status == 'denied': + user_status['status'] = 'must_reverify' + if hasattr(attempt, 'error_msg') and attempt.error_msg: + user_status['error'] = attempt.parsed_error_msg() - elif cls.user_has_valid_or_pending(user): + elif attempt.status == 'approved': + user_status['status'] = 'approved' + + elif attempt.status in ['submitted', 'approved', 'must_retry']: # 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 = SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-updated_at') - attempt = attempts[0] - except IndexError: - # we return 'none' - - return ('none', error_msg) - - if attempt.created_at < earliest_allowed_verification_date(): - return ( - 'expired', - _("Your {platform_name} verification has expired.").format( - platform_name=configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), - ) - ) - - # If someone is denied their original verification attempt, they can try to reverify. - if attempt.status == 'denied': - status = 'must_reverify' - - if attempt.error_msg: - error_msg = attempt.parsed_error_msg() + user_status['status'] = 'pending' - return (status, error_msg) + return user_status @classmethod def verification_status_for_user(cls, user, user_enrollment_mode, user_is_verified=None): diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index dde18aa15c95f7294822b93a9cd28fffa676175f..c7a52dfca7a70153f96a313babb3c3e843770af9 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -8,6 +8,7 @@ import mock import pytz import requests.exceptions from django.conf import settings +from django.test import TestCase from freezegun import freeze_time from mock import patch from nose.tools import ( # pylint: disable=no-name-in-module @@ -21,6 +22,7 @@ from testfixtures import LogCapture from common.test.utils import MockS3Mixin from lms.djangoapps.verify_student.models import ( SoftwareSecurePhotoVerification, + SSOVerification, VerificationDeadline, VerificationException ) @@ -95,11 +97,38 @@ def mock_software_secure_post_unavailable(url, headers=None, data=None, **kwargs raise requests.exceptions.ConnectionError +class TestVerification(TestCase): + """ + Common tests across all types of Verications (e.g., SoftwareSecurePhotoVerication, SSOVerification) + """ + def verification_active_at_datetime(self, attempt): + """ + Tests to ensure the Verification is active or inactive at the appropriate datetimes. + """ + # Not active before the created date + before = attempt.created_at - timedelta(seconds=1) + self.assertFalse(attempt.active_at_datetime(before)) + + # Active immediately after created date + after_created = attempt.created_at + timedelta(seconds=1) + self.assertTrue(attempt.active_at_datetime(after_created)) + + # Active immediately before expiration date + expiration = attempt.created_at + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + before_expiration = expiration - timedelta(seconds=1) + self.assertTrue(attempt.active_at_datetime(before_expiration)) + + # Not active after the expiration date + attempt.created_at = attempt.created_at - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + attempt.save() + self.assertFalse(attempt.active_at_datetime(datetime.now(pytz.UTC) + timedelta(days=1))) + + # Lots of patching to stub in our own settings, and HTTP posting @patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) @patch('lms.djangoapps.verify_student.models.requests.post', new=mock_software_secure_post) @ddt.ddt -class TestPhotoVerification(MockS3Mixin, ModuleStoreTestCase): +class TestPhotoVerification(TestVerification, MockS3Mixin, ModuleStoreTestCase): def setUp(self): super(TestPhotoVerification, self).setUp() @@ -252,24 +281,7 @@ class TestPhotoVerification(MockS3Mixin, ModuleStoreTestCase): def test_active_at_datetime(self): user = UserFactory.create() attempt = SoftwareSecurePhotoVerification.objects.create(user=user) - - # Not active before the created date - before = attempt.created_at - timedelta(seconds=1) - self.assertFalse(attempt.active_at_datetime(before)) - - # Active immediately after created date - after_created = attempt.created_at + timedelta(seconds=1) - self.assertTrue(attempt.active_at_datetime(after_created)) - - # Active immediately before expiration date - expiration = attempt.created_at + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) - before_expiration = expiration - timedelta(seconds=1) - self.assertTrue(attempt.active_at_datetime(before_expiration)) - - # Not active after the expiration date - attempt.created_at = attempt.created_at - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) - attempt.save() - self.assertFalse(attempt.active_at_datetime(datetime.now(pytz.UTC) + timedelta(days=1))) + self.verification_active_at_datetime(attempt) def test_initial_verification_for_user(self): """Test that method 'get_initial_verification' of model @@ -340,6 +352,16 @@ class TestPhotoVerification(MockS3Mixin, ModuleStoreTestCase): self.assertFalse(SoftwareSecurePhotoVerification.delete_by_user_value(user, "user")) +class SSOVerificationTest(TestVerification): + """ + Tests for the SSOVerification model + """ + def test_active_at_datetime(self): + user = UserFactory.create() + attempt = SSOVerification.objects.create(user=user) + self.verification_active_at_datetime(attempt) + + class VerificationDeadlineTest(CacheIsolationTestCase): """ Tests for the VerificationDeadline model. diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py index 7c2795334722e66eb8e4957eeb1310f386540de1..3aaaa2774f53008fb8bc15078e63455144c78728 100644 --- a/lms/djangoapps/verify_student/tests/test_services.py +++ b/lms/djangoapps/verify_student/tests/test_services.py @@ -16,7 +16,7 @@ from nose.tools import ( ) from common.test.utils import MockS3Mixin -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification from lms.djangoapps.verify_student.services import IDVerificationService from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -34,52 +34,6 @@ class TestIDVerificationService(MockS3Mixin, ModuleStoreTestCase): Tests for IDVerificationService. """ - def test_active_for_user(self): - """ - Make sure we can retrive a user's active (in progress) verification - attempt. - """ - user = UserFactory.create() - - # This user has no active at the moment... - assert_is_none(IDVerificationService.active_for_user(user)) - - # Create an attempt and mark it ready... - attempt = SoftwareSecurePhotoVerification(user=user) - attempt.mark_ready() - assert_equals(attempt, IDVerificationService.active_for_user(user)) - - # A new user won't see this... - user2 = UserFactory.create() - user2.save() - assert_is_none(IDVerificationService.active_for_user(user2)) - - # If it's got a different status, it doesn't count - for status in ["submitted", "must_retry", "approved", "denied"]: - attempt.status = status - attempt.save() - assert_is_none(IDVerificationService.active_for_user(user)) - - # But if we create yet another one and mark it ready, it passes again. - attempt_2 = SoftwareSecurePhotoVerification(user=user) - attempt_2.mark_ready() - assert_equals(attempt_2, IDVerificationService.active_for_user(user)) - - # And if we add yet another one with a later created time, we get that - # one instead. We always want the most recent attempt marked ready() - attempt_3 = SoftwareSecurePhotoVerification( - user=user, - created_at=attempt_2.created_at + timedelta(days=1) - ) - attempt_3.save() - - # We haven't marked attempt_3 ready yet, so attempt_2 still wins - assert_equals(attempt_2, IDVerificationService.active_for_user(user)) - - # Now we mark attempt_3 ready and expect it to come back - attempt_3.mark_ready() - assert_equals(attempt_3, IDVerificationService.active_for_user(user)) - def test_user_is_verified(self): """ Test to make sure we correctly answer whether a user has been verified. @@ -123,26 +77,31 @@ class TestIDVerificationService(MockS3Mixin, ModuleStoreTestCase): # test for correct status when no error returned user = UserFactory.create() status = IDVerificationService.user_status(user) - self.assertEquals(status, ('none', '')) + self.assertEquals(status, {'status': 'none', 'error': '', 'should_display': True}) - # test for when one has been created - attempt = SoftwareSecurePhotoVerification.objects.create(user=user, status='approved') + # test for when photo verification has been created + SoftwareSecurePhotoVerification.objects.create(user=user, status='approved') status = IDVerificationService.user_status(user) - self.assertEquals(status, ('approved', '')) + self.assertEquals(status, {'status': 'approved', 'error': '', 'should_display': True}) - # create another one for the same user, make sure the right one is - # returned + # create another photo verification for the same user, make sure the denial + # is handled properly SoftwareSecurePhotoVerification.objects.create( user=user, status='denied', error_msg='[{"photoIdReasons": ["Not provided"]}]' ) status = IDVerificationService.user_status(user) - self.assertEquals(status, ('approved', '')) + self.assertEquals(status, {'status': 'must_reverify', 'error': ['id_image_missing'], 'should_display': True}) + + # test for when sso verification has been created + SSOVerification.objects.create(user=user, status='approved') + status = IDVerificationService.user_status(user) + self.assertEquals(status, {'status': 'approved', 'error': '', 'should_display': False}) - # now delete the first one and verify that the denial is being handled - # properly - attempt.delete() + # create another sso verification for the same user, make sure the denial + # is handled properly + SSOVerification.objects.create(user=user, status='denied') status = IDVerificationService.user_status(user) - self.assertEquals(status, ('must_reverify', ['id_image_missing'])) + self.assertEquals(status, {'status': 'must_reverify', 'error': '', 'should_display': False}) @ddt.unpack @ddt.data( diff --git a/lms/djangoapps/verify_student/tests/test_utils.py b/lms/djangoapps/verify_student/tests/test_utils.py index 17c2041dbb2bfe0c37442d46647d7d2229739af1..7ce101600c81a9764258f6f039962ce5fbdeeedf 100644 --- a/lms/djangoapps/verify_student/tests/test_utils.py +++ b/lms/djangoapps/verify_student/tests/test_utils.py @@ -5,13 +5,14 @@ Tests for verify_student utility functions. from datetime import datetime, timedelta +import ddt import unittest import pytz from mock import patch from pytest import mark from django.conf import settings -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification -from lms.djangoapps.verify_student.utils import verification_for_datetime +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification +from lms.djangoapps.verify_student.utils import verification_for_datetime, most_recent_verification from student.tests.factories import UserFactory FAKE_SETTINGS = { @@ -19,6 +20,7 @@ FAKE_SETTINGS = { } +@ddt.ddt @patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) @mark.django_db class TestVerifyStudentUtils(unittest.TestCase): @@ -83,3 +85,46 @@ class TestVerifyStudentUtils(unittest.TestCase): query = SoftwareSecurePhotoVerification.objects.filter(user=user) result = verification_for_datetime(deadline, query) self.assertEqual(result, second_attempt) + + @ddt.data( + (False, False, None, None), + (True, False, None, 'photo'), + (False, True, None, 'sso'), + (True, True, 'photo', 'sso'), + (True, True, 'sso', 'photo'), + ) + @ddt.unpack + def test_most_recent_verification( + self, + create_photo_verification, + create_sso_verification, + first_verification, + expected_verification): + user = UserFactory.create() + photo_verification = None + sso_verification = None + + if not first_verification: + if create_photo_verification: + photo_verification = SoftwareSecurePhotoVerification.objects.create(user=user) + if create_sso_verification: + sso_verification = SSOVerification.objects.create(user=user) + elif first_verification == 'photo': + photo_verification = SoftwareSecurePhotoVerification.objects.create(user=user) + sso_verification = SSOVerification.objects.create(user=user) + else: + sso_verification = SSOVerification.objects.create(user=user) + photo_verification = SoftwareSecurePhotoVerification.objects.create(user=user) + + most_recent = most_recent_verification( + SoftwareSecurePhotoVerification.objects.all(), + SSOVerification.objects.all(), + 'created_at' + ) + + if not expected_verification: + self.assertEqual(most_recent, None) + elif expected_verification == 'photo': + self.assertEqual(most_recent, photo_verification) + else: + self.assertEqual(most_recent, sso_verification) diff --git a/lms/djangoapps/verify_student/utils.py b/lms/djangoapps/verify_student/utils.py index 943f761ce906b708bd94daf66bfaa2413fbd4272..f6f8c1b041163b743c8bd23a9f1d5e8911310289 100644 --- a/lms/djangoapps/verify_student/utils.py +++ b/lms/djangoapps/verify_student/utils.py @@ -95,3 +95,30 @@ def send_verification_status_email(context): subject=context['subject'], email=context['email'] )) + + +def most_recent_verification(photo_id_verifications, sso_id_verifications, most_recent_key): + """ + Return the most recent verification given querysets for both photo and sso verifications. + + Arguments: + photo_id_verifications: Queryset containing photo verifications + sso_id_verifications: Queryset containing sso verifications + most_recent_key: Either 'updated_at' or 'created_at' + + Returns: + The most recent verification. + """ + photo_id_verification = photo_id_verifications and photo_id_verifications.first() + sso_id_verification = sso_id_verifications and sso_id_verifications.first() + + if not photo_id_verification and not sso_id_verification: + return None + elif photo_id_verification and not sso_id_verification: + return photo_id_verification + elif sso_id_verification and not photo_id_verification: + return sso_id_verification + elif getattr(photo_id_verification, most_recent_key) > getattr(sso_id_verification, most_recent_key): + return photo_id_verification + else: + return sso_id_verification diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index fb7886fa128ea94c7db0423cecd3439278bed780..12411264c0c4ed825d4e60304d70b0da3224bd97 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -646,11 +646,13 @@ class PayAndVerifyView(View): Returns: datetime object in string format """ - photo_verifications = IDVerificationService.verification_valid_or_pending(user) + expiration_datetime = IDVerificationService.get_expiration_datetime( + user, ['submitted', 'approved', 'must_retry'] + ) # return 'expiration_datetime' of latest photo verification if found, # otherwise implicitly return '' - if photo_verifications: - return photo_verifications[0].expiration_datetime.strftime(date_format) + if expiration_datetime: + return expiration_datetime.strftime(date_format) return '' @@ -1226,9 +1228,9 @@ class ReverifyView(View): Most of the work is done client-side by composing the same Backbone views used in the initial verification flow. """ - status, __ = IDVerificationService.user_status(request.user) + verification_status = IDVerificationService.user_status(request.user) - expiration_datetime = IDVerificationService.get_expiration_datetime(request.user) + expiration_datetime = IDVerificationService.get_expiration_datetime(request.user, ['approved']) can_reverify = False if expiration_datetime: if is_verification_expiring_soon(expiration_datetime): @@ -1243,7 +1245,7 @@ class ReverifyView(View): # A photo verification is marked as 'pending' if its status is either # 'submitted' or 'must_retry'. - if status in ["none", "must_reverify", "expired", "pending"] or can_reverify: + if verification_status['status'] in ["none", "must_reverify", "expired", "pending"] or can_reverify: context = { "user_full_name": request.user.profile.name, "platform_name": configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), @@ -1252,6 +1254,6 @@ class ReverifyView(View): return render_to_response("verify_student/reverify.html", context) else: context = { - "status": status + "status": verification_status['status'] } return render_to_response("verify_student/reverify_not_allowed.html", context) diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index ca5525057793bd3f7adfca500db11aec0012e1b7..ff488593506204e2e3c86af5b961f3b63681183b 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -399,7 +399,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ <%include file="_dashboard_show_consent.html" args="course_overview=course_overview, course_target=course_target, enrollment=enrollment, enterprise_customer_name=enterprise_customer_name"/> %endif - % if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_RESUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY]: + % if verification_status.get('should_display') and verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_RESUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY]: <div class="message message-status wrapper-message-primary is-shown"> % if verification_status['status'] == VERIFY_STATUS_NEED_TO_VERIFY: <div class="verification-reminder"> diff --git a/lms/templates/dashboard/_dashboard_status_verification.html b/lms/templates/dashboard/_dashboard_status_verification.html index 2dce1a12aff2499e4d00044a53eebea31f7a5902..8ed8899a8f6cee55b6b1b8c97014e88c7ebde97c 100644 --- a/lms/templates/dashboard/_dashboard_status_verification.html +++ b/lms/templates/dashboard/_dashboard_status_verification.html @@ -5,42 +5,44 @@ from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ %> -%if verification_status == 'approved': - <li class="status status-verification is-accepted"> - <span class="title status-title">${_("Current Verification Status: Approved")}</span> - <p class="status-note">${_("Your edX verification has been approved. Your verification is effective for one year after submission.")}</p> - </li> -%elif verification_status == 'pending': - <li class="status status-verification is-pending"> - <span class="title status-title">${_("Current Verification Status: Pending")}</span> - <p class="status-note">${_("Your edX ID verification is pending. Your verification information has been submitted and will be reviewed shortly.")}</p> - </li> -%elif verification_status in ['denied','must_reverify', 'must_retry']: - <li class="status status-verification is-denied"> - <span class="title status-title">${_("Current Verification Status: Denied")}</span> - <p class="status-note"> - ${_("Your verification submission was not accepted. To receive a verified certificate, you must submit a new photo of yourself and your government-issued photo ID before the verification deadline for your course.")} +%if verification_display: + %if verification_status == 'approved': + <li class="status status-verification is-accepted"> + <span class="title status-title">${_("Current Verification Status: Approved")}</span> + <p class="status-note">${_("Your edX verification has been approved. Your verification is effective for one year after submission.")}</p> + </li> + %elif verification_status == 'pending': + <li class="status status-verification is-pending"> + <span class="title status-title">${_("Current Verification Status: Pending")}</span> + <p class="status-note">${_("Your edX ID verification is pending. Your verification information has been submitted and will be reviewed shortly.")}</p> + </li> + %elif verification_status in ['denied','must_reverify', 'must_retry']: + <li class="status status-verification is-denied"> + <span class="title status-title">${_("Current Verification Status: Denied")}</span> + <p class="status-note"> + ${_("Your verification submission was not accepted. To receive a verified certificate, you must submit a new photo of yourself and your government-issued photo ID before the verification deadline for your course.")} - %if verification_errors: - <br><br> - ${_("Your verification was denied for the following reasons:")}<br> - <ul> - %for error in verification_errors: - <li>${error}</li> - %endfor - </ul> - %endif - </p> - <div class="btn-reverify"> - <a href="${reverse('verify_student_reverify')}" class="action action-reverify">${_("Resubmit Verification")}</a> - </div> - </li> -%elif verification_status == 'expired': - <li class="status status-verification is-denied"> - <span class="title status-title">${_("Current Verification Status: Expired")}</span> - <p class="status-note">${_("Your verification has expired. To receive a verified certificate, you must submit a new photo of yourself and your government-issued photo ID before the verification deadline for your course.")}</p> + %if verification_errors: + <br><br> + ${_("Your verification was denied for the following reasons:")}<br> + <ul> + %for error in verification_errors: + <li>${error}</li> + %endfor + </ul> + %endif + </p> <div class="btn-reverify"> <a href="${reverse('verify_student_reverify')}" class="action action-reverify">${_("Resubmit Verification")}</a> </div> - </li> + </li> + %elif verification_status == 'expired': + <li class="status status-verification is-denied"> + <span class="title status-title">${_("Current Verification Status: Expired")}</span> + <p class="status-note">${_("Your verification has expired. To receive a verified certificate, you must submit a new photo of yourself and your government-issued photo ID before the verification deadline for your course.")}</p> + <div class="btn-reverify"> + <a href="${reverse('verify_student_reverify')}" class="action action-reverify">${_("Resubmit Verification")}</a> + </div> + </li> + %endif %endif diff --git a/openedx/core/djangoapps/user_api/serializers.py b/openedx/core/djangoapps/user_api/serializers.py index c4dcf4fa3bdaaa58d64eb22246a34faa8430a5bc..7b24ab35b50929dd36aad79bbd33941fa567c42c 100644 --- a/openedx/core/djangoapps/user_api/serializers.py +++ b/openedx/core/djangoapps/user_api/serializers.py @@ -5,7 +5,7 @@ from django.contrib.auth.models import User from django.utils.timezone import now from rest_framework import serializers -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification from .models import UserPreference @@ -94,9 +94,9 @@ class CountryTimeZoneSerializer(serializers.Serializer): # pylint: disable=abst description = serializers.CharField() -class SoftwareSecurePhotoVerificationSerializer(serializers.ModelSerializer): +class IDVerificationSerializer(serializers.ModelSerializer): """ - Serializer that generates a representation of a user's photo verification status. + Serializer that generates a representation of a user's ID verification status. """ is_verified = serializers.SerializerMethodField() @@ -106,6 +106,16 @@ class SoftwareSecurePhotoVerificationSerializer(serializers.ModelSerializer): """ return obj.status == 'approved' and obj.expiration_datetime > now() + +class SoftwareSecurePhotoVerificationSerializer(IDVerificationSerializer): + class Meta(object): fields = ('status', 'expiration_datetime', 'is_verified') model = SoftwareSecurePhotoVerification + + +class SSOVerificationSerializer(IDVerificationSerializer): + + class Meta(object): + fields = ('status', 'expiration_datetime', 'is_verified') + model = SSOVerification diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index 9ea27ec0776ade7caf38abf899290fbff8bc9cfb..2ac78c660dcfe554c82ad9044a533e90502ae0e5 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -14,7 +14,7 @@ from .accounts.views import ( DeactivateLogoutView ) from .preferences.views import PreferencesDetailView, PreferencesView -from .verification_api.views import PhotoVerificationStatusView +from .verification_api.views import IDVerificationStatusView from .validation.views import RegistrationValidationView ME = AccountViewSet.as_view({ @@ -81,7 +81,7 @@ urlpatterns = [ ), url( r'^v1/accounts/{}/verification_status/$'.format(settings.USERNAME_PATTERN), - PhotoVerificationStatusView.as_view(), + IDVerificationStatusView.as_view(), name='verification_status' ), url( diff --git a/openedx/core/djangoapps/user_api/verification_api/views.py b/openedx/core/djangoapps/user_api/verification_api/views.py index 5244915f8e30c260a7934013074986a0d8ca5403..eae82f74e11d6caea66359e7e4a27497dcdc7a80 100644 --- a/openedx/core/djangoapps/user_api/verification_api/views.py +++ b/openedx/core/djangoapps/user_api/verification_api/views.py @@ -5,23 +5,38 @@ from rest_framework.authentication import SessionAuthentication from rest_framework.generics import RetrieveAPIView from rest_framework_oauth.authentication import OAuth2Authentication -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification -from openedx.core.djangoapps.user_api.serializers import SoftwareSecurePhotoVerificationSerializer +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification +from lms.djangoapps.verify_student.utils import most_recent_verification +from openedx.core.djangoapps.user_api.serializers import ( + SoftwareSecurePhotoVerificationSerializer, SSOVerificationSerializer, +) from openedx.core.lib.api.permissions import IsStaffOrOwner -class PhotoVerificationStatusView(RetrieveAPIView): - """ PhotoVerificationStatus detail endpoint. """ +class IDVerificationStatusView(RetrieveAPIView): + """ IDVerificationStatus detail endpoint. """ authentication_classes = (JwtAuthentication, OAuth2Authentication, SessionAuthentication,) permission_classes = (IsStaffOrOwner,) - serializer_class = SoftwareSecurePhotoVerificationSerializer + + def get_serializer(self, *args, **kwargs): + """ + Overrides default get_serializer in order to choose the correct serializer for the instance. + """ + instance = args[0] + kwargs['context'] = self.get_serializer_context() + if isinstance(instance, SoftwareSecurePhotoVerification): + return SoftwareSecurePhotoVerificationSerializer(*args, **kwargs) + else: + return SSOVerificationSerializer(*args, **kwargs) def get_object(self): username = self.kwargs['username'] - verifications = SoftwareSecurePhotoVerification.objects.filter(user__username=username).order_by('-updated_at') + photo_verifications = SoftwareSecurePhotoVerification.objects.filter( + user__username=username).order_by('-updated_at') + sso_verifications = SSOVerification.objects.filter(user__username=username).order_by('-updated_at') - if len(verifications) > 0: - verification = verifications[0] + if photo_verifications or sso_verifications: + verification = most_recent_verification(photo_verifications, sso_verifications, 'updated_at') self.check_object_permissions(self.request, verification) return verification