From d31d9bd68444e1ba3fe128637e1e618b05924334 Mon Sep 17 00:00:00 2001 From: Brittney Exline Date: Thu, 29 Mar 2018 10:16:11 -0400 Subject: [PATCH] ENT-942 Implement an abstraction layer for SoftwareSecurePhotoVerification class methods --- common/djangoapps/student/helpers.py | 36 +-- common/djangoapps/student/views/dashboard.py | 11 +- lms/djangoapps/certificates/queue.py | 4 +- lms/djangoapps/certificates/signals.py | 4 +- lms/djangoapps/certificates/tasks.py | 4 +- .../certificates/tests/test_tasks.py | 2 +- lms/djangoapps/commerce/views.py | 4 +- lms/djangoapps/courseware/date_summary.py | 6 +- lms/djangoapps/courseware/module_render.py | 4 +- lms/djangoapps/courseware/tests/test_views.py | 16 +- lms/djangoapps/courseware/views/views.py | 4 +- .../instructor/tests/test_certificates.py | 7 +- lms/djangoapps/instructor_analytics/basic.py | 5 +- .../instructor_analytics/tests/test_basic.py | 2 +- .../instructor_task/tasks_helper/grades.py | 8 +- lms/djangoapps/verify_student/models.py | 241 +----------------- lms/djangoapps/verify_student/services.py | 214 +++++++++++++++- .../verify_student/tests/test_models.py | 194 -------------- .../verify_student/tests/test_services.py | 168 ++++++++++++ .../verify_student/tests/test_utils.py | 85 ++++++ lms/djangoapps/verify_student/utils.py | 60 +++++ lms/djangoapps/verify_student/views.py | 13 +- 22 files changed, 589 insertions(+), 503 deletions(-) create mode 100644 lms/djangoapps/verify_student/tests/test_services.py create mode 100644 lms/djangoapps/verify_student/tests/test_utils.py diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index f4de7da7d7..fcf2cc17b7 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -10,10 +10,10 @@ from datetime import datetime import django from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.core.exceptions import PermissionDenied from django.core.urlresolvers import NoReverseMatch, reverse -from django.core.validators import ValidationError, validate_email -from django.contrib.auth import authenticate, load_backend, login, logout +from django.core.validators import ValidationError +from django.contrib.auth import load_backend from django.contrib.auth.models import User from django.db import IntegrityError, transaction from django.utils import http @@ -35,13 +35,14 @@ from lms.djangoapps.certificates.models import ( # pylint: disable=import-error certificate_status_for_student ) from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline +from lms.djangoapps.verify_student.models import VerificationDeadline +from lms.djangoapps.verify_student.services import IDVerificationService +from lms.djangoapps.verify_student.utils import is_verification_expiring_soon, verification_for_datetime from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.theming.helpers import get_themes from student.models import ( - CourseEnrollment, LinkedInAddToProfileConfiguration, PasswordHistory, Registration, @@ -111,18 +112,18 @@ def check_verify_status_by_course(user, course_enrollments): # Retrieve all verifications for the user, sorted in descending # order by submission datetime - verifications = SoftwareSecurePhotoVerification.objects.filter(user=user) + 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 = SoftwareSecurePhotoVerification.user_has_valid_or_pending( + has_active_or_pending = IDVerificationService.user_has_valid_or_pending( user, queryset=verifications ) # Retrieve expiration_datetime of most recent approved verification # To avoid another database hit, we re-use the queryset we have already retrieved. - expiration_datetime = SoftwareSecurePhotoVerification.get_expiration_datetime(user, verifications) - verification_expiring_soon = SoftwareSecurePhotoVerification.is_verification_expiring_soon(expiration_datetime) + expiration_datetime = IDVerificationService.get_expiration_datetime(user, verifications) + verification_expiring_soon = is_verification_expiring_soon(expiration_datetime) # Retrieve verification deadlines for the enrolled courses enrolled_course_keys = [enrollment.course_id for enrollment in course_enrollments] @@ -140,7 +141,7 @@ def check_verify_status_by_course(user, course_enrollments): # This could be None if the course doesn't have a deadline. deadline = course_deadlines.get(enrollment.course_id) - relevant_verification = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, verifications) + relevant_verification = verification_for_datetime(deadline, verifications) # Picking the max verification datetime on each iteration only with approved status if relevant_verification is not None and relevant_verification.status == "approved": @@ -177,13 +178,12 @@ def check_verify_status_by_course(user, course_enrollments): ) if status is None and not submitted: if deadline is None or deadline > datetime.now(UTC): - if SoftwareSecurePhotoVerification.user_is_verified(user): - if verification_expiring_soon: - # The user has an active verification, but the verification - # is set to expire within "EXPIRING_SOON_WINDOW" days (default is 4 weeks). - # Tell the student to reverify. - status = VERIFY_STATUS_NEED_TO_REVERIFY - else: + if IDVerificationService.user_is_verified(user) and verification_expiring_soon: + # The user has an active verification, but the verification + # is set to expire within "EXPIRING_SOON_WINDOW" days (default is 4 weeks). + # Tell the student to reverify. + status = VERIFY_STATUS_NEED_TO_REVERIFY + elif not IDVerificationService.user_is_verified(user): status = VERIFY_STATUS_NEED_TO_VERIFY else: # If a user currently has an active or pending verification, @@ -525,7 +525,7 @@ def _cert_info(user, course_overview, cert_status): 'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES, } - if not status == default_status and course_overview.end_of_course_survey_url is not None: + if status != default_status and course_overview.end_of_course_survey_url is not None: status_dict.update({ 'show_survey_button': True, 'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)}) diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index 4b81553c6f..805ba14b74 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -11,10 +11,10 @@ from completion.utilities import get_key_to_last_completed_course_block from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy +from django.core.urlresolvers import reverse from django.shortcuts import redirect from django.utils.translation import ugettext as _ -from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie +from django.views.decorators.csrf import ensure_csrf_cookie from opaque_keys.edx.keys import CourseKey from pytz import UTC @@ -27,7 +27,7 @@ from courseware.access import has_access from edxmako.shortcuts import render_to_response, render_to_string from entitlements.models import CourseEntitlement from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error +from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps import monitoring_utils from openedx.core.djangoapps.catalog.utils import ( get_programs, @@ -339,6 +339,9 @@ def is_course_blocked(request, redeemed_registration_codes, course_key): def get_verification_error_reasons_for_display(verification_error_codes): + """ + Returns the display text for the given verification error codes. + """ verification_errors = [] verification_error_map = { 'photos_mismatched': _('Photos are mismatched'), @@ -713,7 +716,7 @@ def student_dashboard(request): # Verification Attempts # Used to generate the "you must reverify for course x" banner - verification_status, verification_error_codes = SoftwareSecurePhotoVerification.user_status(user) + verification_status, verification_error_codes = IDVerificationService.user_status(user) verification_errors = get_verification_error_reasons_for_display(verification_error_codes) # Gets data for midcourse reverifications, if any are necessary or have failed diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 66b8e35c87..e6faf4102f 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -21,7 +21,7 @@ from lms.djangoapps.certificates.models import ( ) from course_modes.models import CourseMode from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.services import IDVerificationService from student.models import CourseEnrollment, UserProfile from xmodule.modulestore.django import modulestore @@ -271,7 +271,7 @@ class XQueueCertInterface(object): course_grade = CourseGradeFactory().read(student, course) enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(student, course_id) mode_is_verified = enrollment_mode in GeneratedCertificate.VERIFIED_CERTS_MODES - user_is_verified = SoftwareSecurePhotoVerification.user_is_verified(student) + user_is_verified = IDVerificationService.user_is_verified(student) cert_mode = enrollment_mode is_eligible_for_certificate = is_whitelisted or CourseMode.is_eligible_for_certificate(enrollment_mode) unverified = False diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index c25b77acc8..589e0894cc 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -13,7 +13,7 @@ from lms.djangoapps.certificates.models import ( ) from lms.djangoapps.certificates.tasks import generate_certificate from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.certificates.api import auto_certificate_generation_enabled from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.signals import COURSE_PACING_CHANGED @@ -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, _ = SoftwareSecurePhotoVerification.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): diff --git a/lms/djangoapps/certificates/tasks.py b/lms/djangoapps/certificates/tasks.py index 2bb2c7fc16..69fa1083c4 100644 --- a/lms/djangoapps/certificates/tasks.py +++ b/lms/djangoapps/certificates/tasks.py @@ -3,7 +3,7 @@ from logging import getLogger from celery_utils.persist_on_failure import LoggedPersistOnFailureTask from django.contrib.auth.models import User -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.services import IDVerificationService from opaque_keys.edx.keys import CourseKey from .api import generate_user_certificates @@ -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, _ = SoftwareSecurePhotoVerification.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_tasks.py b/lms/djangoapps/certificates/tests/test_tasks.py index ce9d4c61e9..61d1db1712 100644 --- a/lms/djangoapps/certificates/tests/test_tasks.py +++ b/lms/djangoapps/certificates/tests/test_tasks.py @@ -40,7 +40,7 @@ class GenerateUserCertificateTest(TestCase): generate_certificate.apply_async(kwargs=kwargs).get() @patch('lms.djangoapps.certificates.tasks.generate_user_certificates') - @patch('lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.user_status') + @patch('lms.djangoapps.verify_student.services.IDVerificationService.user_status') def test_retry_until_verification_status_updates(self, user_status_mock, generate_user_certs_mock): course_key = 'course-v1:edX+CS101+2017_T2' student = UserFactory() diff --git a/lms/djangoapps/commerce/views.py b/lms/djangoapps/commerce/views.py index a9047e6083..c5dea7da03 100644 --- a/lms/djangoapps/commerce/views.py +++ b/lms/djangoapps/commerce/views.py @@ -12,7 +12,7 @@ from opaque_keys.edx.locator import CourseLocator from course_modes.models import CourseMode from edxmako.shortcuts import render_to_response -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site from shoppingcart.processors.CyberSource2 import is_user_payment_error @@ -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': SoftwareSecurePhotoVerification.verification_valid_or_pending(request.user).exists(), + 'verified': IDVerificationService.verification_valid_or_pending(request.user).exists(), '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 1313ee553f..5fb7b51d1c 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -19,7 +19,8 @@ from pytz import utc from course_modes.models import CourseMode, get_cosmetic_verified_display_price from lms.djangoapps.commerce.utils import EcommerceService -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline +from lms.djangoapps.verify_student.models import VerificationDeadline +from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.certificates.api import can_show_certificate_available_date_field from openedx.core.djangolib.markup import HTML, Text from openedx.features.course_experience import CourseHomeMessages, UPGRADE_DEADLINE_MESSAGE @@ -626,7 +627,8 @@ class VerificationDeadlineDate(DateSummary): @lazy def verification_status(self): """Return the verification status for this user.""" - return SoftwareSecurePhotoVerification.user_status(self.user)[0] + status, _ = IDVerificationService.user_status(self.user) + return status def must_retry(self): """Return True if the user must re-submit verification, False otherwise.""" diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index a4fd7da5e7..9443539b2d 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -46,7 +46,7 @@ from lms.djangoapps.grades.signals.signals import SCORE_PUBLISHED from lms.djangoapps.lms_xblock.field_data import LmsFieldData from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem -from lms.djangoapps.verify_student.services import VerificationService +from lms.djangoapps.verify_student.services import XBlockVerificationService from openedx.core.djangoapps.bookmarks.services import BookmarksService from openedx.core.djangoapps.crawlers.models import CrawlersConfig from openedx.core.djangoapps.credit.services import CreditService @@ -756,7 +756,7 @@ def get_module_system_for_user( 'fs': FSService(), 'field-data': field_data, 'user': DjangoXBlockUserService(user, user_is_staff=user_is_staff), - 'verification': VerificationService(), + 'verification': XBlockVerificationService(), 'proctoring': ProctoringService(), 'milestones': milestones_helpers.get_service(), 'credit': CreditService(), diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 8ffb61d45f..91d22ce92c 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -1363,7 +1363,7 @@ class ProgressPageTests(ProgressPageBaseTests): self.store.update_item(self.course, self.user.id) CourseEnrollment.enroll(self.user, self.course.id, mode="verified") with patch( - 'lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.user_is_verified' + 'lms.djangoapps.verify_student.services.IDVerificationService.user_is_verified' ) as user_verify: user_verify.return_value = True @@ -1412,7 +1412,7 @@ class ProgressPageTests(ProgressPageBaseTests): CourseEnrollment.enroll(self.user, self.course.id, mode="verified") with patch( - 'lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.user_is_verified' + 'lms.djangoapps.verify_student.services.IDVerificationService.user_is_verified' ) as user_verify: user_verify.return_value = True @@ -1472,7 +1472,7 @@ class ProgressPageTests(ProgressPageBaseTests): certs_api.set_cert_generation_enabled(self.course.id, True) CourseEnrollment.enroll(self.user, self.course.id, mode=course_mode) with patch( - 'lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.user_is_verified' + 'lms.djangoapps.verify_student.services.IDVerificationService.user_is_verified' ) as user_verify: user_verify.return_value = user_verified with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_create: @@ -1520,7 +1520,7 @@ class ProgressPageTests(ProgressPageBaseTests): self.store.update_item(self.course, self.user.id) CourseEnrollment.enroll(self.user, self.course.id, mode="verified") with patch( - 'lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.user_is_verified' + 'lms.djangoapps.verify_student.services.IDVerificationService.user_is_verified' ) as user_verify: user_verify.return_value = True @@ -1568,7 +1568,7 @@ class ProgressPageTests(ProgressPageBaseTests): ) CourseEnrollment.enroll(self.user, self.course.id, mode="verified") with patch( - 'lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.user_is_verified' + 'lms.djangoapps.verify_student.services.IDVerificationService.user_is_verified' ) as user_verify: user_verify.return_value = True @@ -1593,7 +1593,7 @@ class ProgressPageTests(ProgressPageBaseTests): ) CourseEnrollment.enroll(self.user, self.course.id, mode="verified") with patch( - 'lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.user_is_verified' + 'lms.djangoapps.verify_student.services.IDVerificationService.user_is_verified' ) as user_verify: user_verify.return_value = True @@ -1675,7 +1675,7 @@ class ProgressPageTests(ProgressPageBaseTests): ) CourseEnrollment.enroll(self.user, self.course.id, mode="verified") with patch( - 'lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.user_is_verified' + 'lms.djangoapps.verify_student.services.IDVerificationService.user_is_verified' ) as user_verify: user_verify.return_value = True @@ -1723,7 +1723,7 @@ class ProgressPageTests(ProgressPageBaseTests): ) CourseEnrollment.enroll(self.user, self.course.id, mode="verified") with patch( - 'lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.user_is_verified' + 'lms.djangoapps.verify_student.services.IDVerificationService.user_is_verified' ) as user_verify: user_verify.return_value = True with patch('lms.djangoapps.certificates.api.certificate_downloadable_status', diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 1b000144c1..f357021f6a 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -70,7 +70,7 @@ from lms.djangoapps.experiments.utils import get_experiment_user_metadata_contex from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.instructor.enrollment import uses_shib from lms.djangoapps.instructor.views.api import require_global_staff -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.catalog.utils import get_programs, get_programs_with_type from openedx.core.djangoapps.certificates import api as auto_certs_api from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -1017,7 +1017,7 @@ def _downloadable_certificate_message(course, cert_downloadable_status): def _missing_required_verification(student, enrollment_mode): return ( - enrollment_mode in CourseMode.VERIFIED_MODES and not SoftwareSecurePhotoVerification.user_is_verified(student) + enrollment_mode in CourseMode.VERIFIED_MODES and not IDVerificationService.user_is_verified(student) ) diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index 04850a0beb..e6fe57cae2 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -32,7 +32,7 @@ from lms.djangoapps.certificates.tests.factories import ( from course_modes.models import CourseMode from courseware.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory from lms.djangoapps.grades.tests.utils import mock_passing_grade -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.services import IDVerificationService from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory from student.models import CourseEnrollment from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase @@ -398,10 +398,9 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): # Create and assert user's ID verification record. SoftwareSecurePhotoVerificationFactory.create(user=self.user, status=id_verification_status) - actual_verification_status = SoftwareSecurePhotoVerification.verification_status_for_user( + actual_verification_status = IDVerificationService.verification_status_for_user( self.user, - self.course.id, - enrollment.mode, + enrollment.mode ) self.assertEquals(actual_verification_status, verification_output) diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index 490719a6d2..237e030ccd 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -20,7 +20,7 @@ import xmodule.graders as xmgraders from lms.djangoapps.certificates.models import CertificateStatuses, GeneratedCertificate from courseware.models import StudentModule from lms.djangoapps.grades.context import grading_context_for_course -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from shoppingcart.models import ( CouponRedemption, @@ -286,9 +286,8 @@ def enrolled_students_features(course_key, features): if include_enrollment_mode or include_verification_status: enrollment_mode = CourseEnrollment.enrollment_mode_for_user(student, course_key)[0] if include_verification_status: - student_dict['verification_status'] = SoftwareSecurePhotoVerification.verification_status_for_user( + student_dict['verification_status'] = IDVerificationService.verification_status_for_user( student, - course_key, enrollment_mode ) if include_enrollment_mode: diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index 309ead69c4..6d2b616e52 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -184,7 +184,7 @@ class TestAnalyticsBasic(ModuleStoreTestCase): # is returned by verification and enrollment code with patch("student.models.CourseEnrollment.enrollment_mode_for_user") as enrollment_patch: with patch( - "lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.verification_status_for_user" + "lms.djangoapps.verify_student.services.IDVerificationService.verification_status_for_user" ) as verify_patch: enrollment_patch.return_value = ["verified"] verify_patch.return_value = "dummy verification status" diff --git a/lms/djangoapps/instructor_task/tasks_helper/grades.py b/lms/djangoapps/instructor_task/tasks_helper/grades.py index a8de9b9dca..42e9e47e22 100644 --- a/lms/djangoapps/instructor_task/tasks_helper/grades.py +++ b/lms/djangoapps/instructor_task/tasks_helper/grades.py @@ -20,7 +20,7 @@ from lms.djangoapps.grades.context import grading_context, grading_context_for_c from lms.djangoapps.grades.models import PersistentCourseGrade from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.teams.models import CourseTeamMembership -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.content.block_structure.api import get_course_in_cache from openedx.core.djangoapps.course_groups.cohorts import bulk_cache_cohorts, get_cohort, is_course_cohorted from openedx.core.djangoapps.user_api.course_tag.api import BulkCourseTags @@ -170,8 +170,7 @@ class _EnrollmentBulkContext(object): def __init__(self, context, users): CourseEnrollment.bulk_fetch_enrollment_states(users, context.course_id) self.verified_users = [ - verified.user.id for verified in - SoftwareSecurePhotoVerification.verified_query().filter(user__in=users).select_related('user') + verified.user.id for verified in IDVerificationService.get_verified_users(users) ] @@ -386,9 +385,8 @@ class CourseGradeReport(object): given user. """ enrollment_mode = CourseEnrollment.enrollment_mode_for_user(user, context.course_id)[0] - verification_status = SoftwareSecurePhotoVerification.verification_status_for_user( + verification_status = IDVerificationService.verification_status_for_user( user, - context.course_id, enrollment_mode, user_is_verified=user.id in bulk_enrollments.verified_users, ) diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 149115e61e..11f66dfe45 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -27,13 +27,11 @@ from django.core.urlresolvers import reverse from django.db import models from django.dispatch import receiver from django.utils.functional import cached_property -from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy from model_utils import Choices from model_utils.models import StatusModel, TimeStampedModel from opaque_keys.edx.django.models import CourseKeyField -from course_modes.models import CourseMode from lms.djangoapps.verify_student.ssencrypt import ( encrypt_and_encode, generate_signed_message, @@ -41,9 +39,9 @@ from lms.djangoapps.verify_student.ssencrypt import ( rsa_encrypt ) from openedx.core.djangoapps.signals.signals import LEARNER_NOW_VERIFIED -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.storage import get_storage +from .utils import earliest_allowed_verification_date log = logging.getLogger(__name__) @@ -193,213 +191,6 @@ class PhotoVerification(StatusModel): abstract = True ordering = ['-created_at'] - ##### Methods listed in the order you'd typically call them - @classmethod - def _earliest_allowed_date(cls): - """ - Returns the earliest allowed date given the settings - - """ - days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] - return datetime.now(pytz.UTC) - timedelta(days=days_good_for) - - @classmethod - def user_is_verified(cls, user, earliest_allowed_date=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. - - This will check for the user's *initial* verification. - """ - return cls.verified_query(earliest_allowed_date).filter(user=user).exists() - - @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 cls.objects.filter( - status="approved", - created_at__gte=(earliest_allowed_date or cls._earliest_allowed_date()), - ) - - @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 = cls.objects.filter(user=user) - - return queryset.filter( - status__in=valid_statuses, - created_at__gte=( - earliest_allowed_date - or cls._earliest_allowed_date() - ) - ).order_by('-created_at') - - @classmethod - def get_expiration_datetime(cls, user, queryset=None): - """ - Check whether the user has an approved verification and return the - "expiration_datetime" of most recent "approved" verification. - - Arguments: - user (Object): User - queryset: If a queryset is provided, that will be used instead - of hitting the database. - - Returns: - expiration_datetime: expiration_datetime of most recent "approved" - verification. - """ - if queryset is None: - queryset = cls.objects.filter(user=user) - - photo_verification = queryset.filter(status='approved').first() - if photo_verification: - return photo_verification.expiration_datetime - - @classmethod - def user_has_valid_or_pending(cls, user, earliest_allowed_date=None, queryset=None): - """ - 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 = cls.objects.filter(user=user, status='ready').order_by('-created_at') - if active_attempts: - return active_attempts[0] - else: - return None - - @classmethod - def user_status(cls, user): - """ - Returns the status of the user based on their past verification attempts - - If no such verification exists, returns 'none' - If verification has expired, returns 'expired' - If the verification has been approved, returns 'approved' - If the verification process is still ongoing, returns 'pending' - If the verification has been denied and the user must resubmit photos, returns 'must_reverify' - - This checks initial verifications - """ - status = 'none' - error_msg = '' - - if cls.user_is_verified(user): - status = 'approved' - - elif cls.user_has_valid_or_pending(user): - # user_has_valid_or_pending does include 'approved', but if we are - # here, we know that the attempt is still pending - status = 'pending' - - else: - # we need to check the most recent attempt to see if we need to ask them to do - # a retry - try: - attempts = cls.objects.filter(user=user).order_by('-updated_at') - attempt = attempts[0] - except IndexError: - # we return 'none' - - return ('none', error_msg) - - if attempt.created_at < cls._earliest_allowed_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() - - return (status, error_msg) - - @classmethod - def verification_for_datetime(cls, deadline, candidates): - """Find a verification in a set that applied during a particular datetime. - - A verification is considered "active" during a datetime if: - 1) The verification was created before the datetime, and - 2) The verification is set to expire after the datetime. - - Note that verification status is *not* considered here, - just the start/expire dates. - - If multiple verifications were active at the deadline, - returns the most recently created one. - - Arguments: - deadline (datetime): The datetime at which the verification applied. - If `None`, then return the most recently created candidate. - candidates (list of `PhotoVerification`s): Potential verifications to search through. - - Returns: - PhotoVerification: A photo verification that was active at the deadline. - If no verification was active, return None. - - """ - if len(candidates) == 0: - return None - - # If there's no deadline, then return the most recently created verification - if deadline is None: - return candidates[0] - - # Otherwise, look for a verification that was in effect at the deadline, - # preferring recent verifications. - # If no such verification is found, implicitly return `None` - for verification in candidates: - if verification.active_at_datetime(deadline): - return verification - @property def expiration_datetime(self): """Datetime that the verification will expire. """ @@ -642,7 +433,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification): user=user, status__in=["submitted", "approved"], created_at__gte=( - earliest_allowed_date or cls._earliest_allowed_date() + earliest_allowed_date or earliest_allowed_verification_date() ) ).exclude(photo_id_key='') @@ -974,34 +765,6 @@ class SoftwareSecurePhotoVerification(PhotoVerification): return response - @classmethod - def verification_status_for_user(cls, user, course_id, user_enrollment_mode, user_is_verified=None): - """ - Returns the verification status for use in grade report. - """ - if user_enrollment_mode not in CourseMode.VERIFIED_MODES: - return 'N/A' - - if user_is_verified is None: - user_is_verified = cls.user_is_verified(user) - - if not user_is_verified: - return 'Not ID Verified' - else: - return 'ID Verified' - - @classmethod - def is_verification_expiring_soon(cls, expiration_datetime): - """ - Returns True if verification is expiring within EXPIRING_SOON_WINDOW. - """ - if expiration_datetime: - if (expiration_datetime - datetime.now(pytz.UTC)).days <= settings.VERIFY_STUDENT.get( - "EXPIRING_SOON_WINDOW"): - return True - - return False - class VerificationDeadline(TimeStampedModel): """ diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index 16dcde4264..0cfc20fa0a 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -1,21 +1,26 @@ """ -Implementation of "reverification" service to communicate with Reverification XBlock +Implementation of abstraction layer for other parts of the system to make queries related to ID Verification. """ import logging +from django.conf import settings from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ +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 log = logging.getLogger(__name__) -class VerificationService(object): +class XBlockVerificationService(object): """ - Learner verification XBlock service + Learner verification XBlock service. """ def get_status(self, user_id): @@ -33,12 +38,209 @@ class VerificationService(object): 'must_reverify' - verification has been denied and user must resubmit photos """ user = User.objects.get(id=user_id) - # TODO: provide a photo verification abstraction so that this - # isn't hard-coded to use Software Secure. - return SoftwareSecurePhotoVerification.user_status(user) + return IDVerificationService.user_status(user) def reverify_url(self): """ Returns the URL for a user to verify themselves. """ return reverse('verify_student_reverify') + + +class IDVerificationService(object): + """ + Learner verification service interface for callers within edx-platform. + """ + + @classmethod + def user_is_verified(cls, user, earliest_allowed_date=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. + + This will check for the user's *initial* verification. + """ + return cls.verified_query(earliest_allowed_date).filter(user=user).exists() + + @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()), + ) + + @classmethod + def verifications_for_user(cls, user): + """ + Return a query set for all records associated with the given user. + """ + return SoftwareSecurePhotoVerification.objects.filter(user=user) + + @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') + + @classmethod + def get_expiration_datetime(cls, user, queryset=None): + """ + Check whether the user has an approved verification and return the + "expiration_datetime" of most recent "approved" verification. + + Arguments: + user (Object): User + queryset: If a queryset is provided, that will be used instead + of hitting the database. + + Returns: + expiration_datetime: expiration_datetime of most recent "approved" + verification. + """ + if queryset is None: + queryset = SoftwareSecurePhotoVerification.objects.filter(user=user) + + photo_verification = queryset.filter(status='approved').first() + if photo_verification: + return photo_verification.expiration_datetime + + @classmethod + def user_has_valid_or_pending(cls, user, earliest_allowed_date=None, queryset=None): + """ + 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') + + if active_attempts: + return active_attempts[0] + else: + return None + + @classmethod + def user_status(cls, user): + """ + Returns the status of the user based on their past verification attempts, and any corresponding error messages. + + If no such verification exists, returns 'none' + If verification has expired, returns 'expired' + If the verification has been approved, returns 'approved' + If the verification process is still ongoing, returns 'pending' + If the verification has been denied and the user must resubmit photos, returns 'must_reverify' + + This checks initial verifications + """ + status = 'none' + error_msg = '' + + if cls.user_is_verified(user): + status = 'approved' + + elif cls.user_has_valid_or_pending(user): + # user_has_valid_or_pending does include 'approved', but if we are + # here, we know that the attempt is still pending + status = 'pending' + + else: + # we need to check the most recent attempt to see if we need to ask them to do + # a retry + try: + attempts = 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() + + return (status, error_msg) + + @classmethod + def verification_status_for_user(cls, user, user_enrollment_mode, user_is_verified=None): + """ + Returns the verification status for use in grade report. + """ + if user_enrollment_mode not in CourseMode.VERIFIED_MODES: + return 'N/A' + + if user_is_verified is None: + user_is_verified = cls.user_is_verified(user) + + if not user_is_verified: + return 'Not ID Verified' + else: + return 'ID Verified' diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index 8b92027792..fab3bd0551 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -12,8 +12,6 @@ from freezegun import freeze_time from mock import patch from nose.tools import ( # pylint: disable=no-name-in-module assert_equals, - assert_false, - assert_is_none, assert_raises, assert_true ) @@ -29,7 +27,6 @@ from lms.djangoapps.verify_student.models import ( from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory FAKE_SETTINGS = { "SOFTWARE_SECURE": { @@ -233,116 +230,6 @@ class TestPhotoVerification(MockS3Mixin, ModuleStoreTestCase): self.assertEqual(attempt.photo_id_key, "fake-photo-id-key") - 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(SoftwareSecurePhotoVerification.active_for_user(user)) - - # Create an attempt and mark it ready... - attempt = SoftwareSecurePhotoVerification(user=user) - attempt.mark_ready() - assert_equals(attempt, SoftwareSecurePhotoVerification.active_for_user(user)) - - # A new user won't see this... - user2 = UserFactory.create() - user2.save() - assert_is_none(SoftwareSecurePhotoVerification.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(SoftwareSecurePhotoVerification.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, SoftwareSecurePhotoVerification.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, SoftwareSecurePhotoVerification.active_for_user(user)) - - # Now we mark attempt_3 ready and expect it to come back - attempt_3.mark_ready() - assert_equals(attempt_3, SoftwareSecurePhotoVerification.active_for_user(user)) - - def test_user_is_verified(self): - """ - Test to make sure we correctly answer whether a user has been verified. - """ - user = UserFactory.create() - attempt = SoftwareSecurePhotoVerification(user=user) - attempt.save() - - # If it's any of these, they're not verified... - for status in ["created", "ready", "denied", "submitted", "must_retry"]: - attempt.status = status - attempt.save() - assert_false(SoftwareSecurePhotoVerification.user_is_verified(user), status) - - attempt.status = "approved" - attempt.save() - assert_true(SoftwareSecurePhotoVerification.user_is_verified(user), attempt.status) - - def test_user_has_valid_or_pending(self): - """ - Determine whether we have to prompt this user to verify, or if they've - already at least initiated a verification submission. - """ - user = UserFactory.create() - attempt = SoftwareSecurePhotoVerification(user=user) - - # If it's any of these statuses, they don't have anything outstanding - for status in ["created", "ready", "denied"]: - attempt.status = status - attempt.save() - assert_false(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user), status) - - # Any of these, and we are. Note the benefit of the doubt we're giving - # -- must_retry, and submitted both count until we hear otherwise - for status in ["submitted", "must_retry", "approved"]: - attempt.status = status - attempt.save() - assert_true(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user), status) - - def test_user_status(self): - # test for correct status when no error returned - user = UserFactory.create() - status = SoftwareSecurePhotoVerification.user_status(user) - self.assertEquals(status, ('none', '')) - - # test for when one has been created - attempt = SoftwareSecurePhotoVerification.objects.create(user=user, status='approved') - status = SoftwareSecurePhotoVerification.user_status(user) - self.assertEquals(status, ('approved', '')) - - # create another one for the same user, make sure the right one is - # returned - SoftwareSecurePhotoVerification.objects.create( - user=user, status='denied', error_msg='[{"photoIdReasons": ["Not provided"]}]' - ) - status = SoftwareSecurePhotoVerification.user_status(user) - self.assertEquals(status, ('approved', '')) - - # now delete the first one and verify that the denial is being handled - # properly - attempt.delete() - status = SoftwareSecurePhotoVerification.user_status(user) - self.assertEquals(status, ('must_reverify', ['id_image_missing'])) - # pylint: disable=line-too-long def test_parse_error_msg_success(self): user = UserFactory.create() @@ -384,87 +271,6 @@ class TestPhotoVerification(MockS3Mixin, ModuleStoreTestCase): attempt.save() self.assertFalse(attempt.active_at_datetime(datetime.now(pytz.UTC) + timedelta(days=1))) - def test_verification_for_datetime(self): - user = UserFactory.create() - now = datetime.now(pytz.UTC) - - # No attempts in the query set, so should return None - query = SoftwareSecurePhotoVerification.objects.filter(user=user) - result = SoftwareSecurePhotoVerification.verification_for_datetime(now, query) - self.assertIs(result, None) - - # Should also return None if no deadline specified - query = SoftwareSecurePhotoVerification.objects.filter(user=user) - result = SoftwareSecurePhotoVerification.verification_for_datetime(None, query) - self.assertIs(result, None) - - # Make an attempt - attempt = SoftwareSecurePhotoVerification.objects.create(user=user) - - # Before the created date, should get no results - before = attempt.created_at - timedelta(seconds=1) - query = SoftwareSecurePhotoVerification.objects.filter(user=user) - result = SoftwareSecurePhotoVerification.verification_for_datetime(before, query) - self.assertIs(result, None) - - # Immediately after the created date, should get the attempt - after_created = attempt.created_at + timedelta(seconds=1) - query = SoftwareSecurePhotoVerification.objects.filter(user=user) - result = SoftwareSecurePhotoVerification.verification_for_datetime(after_created, query) - self.assertEqual(result, attempt) - - # If no deadline specified, should return first available - query = SoftwareSecurePhotoVerification.objects.filter(user=user) - result = SoftwareSecurePhotoVerification.verification_for_datetime(None, query) - self.assertEqual(result, attempt) - - # Immediately before the expiration date, should get the attempt - expiration = attempt.created_at + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) - before_expiration = expiration - timedelta(seconds=1) - query = SoftwareSecurePhotoVerification.objects.filter(user=user) - result = SoftwareSecurePhotoVerification.verification_for_datetime(before_expiration, query) - self.assertEqual(result, attempt) - - # Immediately after the expiration date, should not get the attempt - attempt.created_at = attempt.created_at - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) - attempt.save() - after = datetime.now(pytz.UTC) + timedelta(days=1) - query = SoftwareSecurePhotoVerification.objects.filter(user=user) - result = SoftwareSecurePhotoVerification.verification_for_datetime(after, query) - self.assertIs(result, None) - - # Create a second attempt in the same window - second_attempt = SoftwareSecurePhotoVerification.objects.create(user=user) - - # Now we should get the newer attempt - deadline = second_attempt.created_at + timedelta(days=1) - query = SoftwareSecurePhotoVerification.objects.filter(user=user) - result = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, query) - self.assertEqual(result, second_attempt) - - @ddt.unpack - @ddt.data( - {'enrollment_mode': 'honor', 'status': None, 'output': 'N/A'}, - {'enrollment_mode': 'audit', 'status': None, 'output': 'N/A'}, - {'enrollment_mode': 'verified', 'status': False, 'output': 'Not ID Verified'}, - {'enrollment_mode': 'verified', 'status': True, 'output': 'ID Verified'}, - ) - def test_verification_status_for_user(self, enrollment_mode, status, output): - """ - Verify verification_status_for_user returns correct status. - """ - user = UserFactory.create() - course = CourseFactory.create() - - with patch( - 'lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.user_is_verified' - ) as mock_verification: - - mock_verification.return_value = status - - status = SoftwareSecurePhotoVerification.verification_status_for_user(user, course.id, enrollment_mode) - self.assertEqual(status, output) - def test_initial_verification_for_user(self): """Test that method 'get_initial_verification' of model 'SoftwareSecurePhotoVerification' always returns the initial diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py new file mode 100644 index 0000000000..7c27953347 --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_services.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +""" +Tests for the service classes in verify_student. +""" + +from datetime import timedelta + +import ddt +from django.conf import settings +from mock import patch +from nose.tools import ( + assert_equals, + assert_false, + assert_is_none, + assert_true +) + +from common.test.utils import MockS3Mixin +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.services import IDVerificationService +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +FAKE_SETTINGS = { + "DAYS_GOOD_FOR": 10, +} + + +@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) +@ddt.ddt +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. + """ + user = UserFactory.create() + attempt = SoftwareSecurePhotoVerification(user=user) + attempt.save() + + # If it's any of these, they're not verified... + for status in ["created", "ready", "denied", "submitted", "must_retry"]: + attempt.status = status + attempt.save() + assert_false(IDVerificationService.user_is_verified(user), status) + + attempt.status = "approved" + attempt.save() + assert_true(IDVerificationService.user_is_verified(user), attempt.status) + + def test_user_has_valid_or_pending(self): + """ + Determine whether we have to prompt this user to verify, or if they've + already at least initiated a verification submission. + """ + user = UserFactory.create() + attempt = SoftwareSecurePhotoVerification(user=user) + + # If it's any of these statuses, they don't have anything outstanding + for status in ["created", "ready", "denied"]: + attempt.status = status + attempt.save() + assert_false(IDVerificationService.user_has_valid_or_pending(user), status) + + # Any of these, and we are. Note the benefit of the doubt we're giving + # -- must_retry, and submitted both count until we hear otherwise + for status in ["submitted", "must_retry", "approved"]: + attempt.status = status + attempt.save() + assert_true(IDVerificationService.user_has_valid_or_pending(user), status) + + def test_user_status(self): + # test for correct status when no error returned + user = UserFactory.create() + status = IDVerificationService.user_status(user) + self.assertEquals(status, ('none', '')) + + # test for when one has been created + attempt = SoftwareSecurePhotoVerification.objects.create(user=user, status='approved') + status = IDVerificationService.user_status(user) + self.assertEquals(status, ('approved', '')) + + # create another one for the same user, make sure the right one is + # returned + SoftwareSecurePhotoVerification.objects.create( + user=user, status='denied', error_msg='[{"photoIdReasons": ["Not provided"]}]' + ) + status = IDVerificationService.user_status(user) + self.assertEquals(status, ('approved', '')) + + # now delete the first one and verify that the denial is being handled + # properly + attempt.delete() + status = IDVerificationService.user_status(user) + self.assertEquals(status, ('must_reverify', ['id_image_missing'])) + + @ddt.unpack + @ddt.data( + {'enrollment_mode': 'honor', 'status': None, 'output': 'N/A'}, + {'enrollment_mode': 'audit', 'status': None, 'output': 'N/A'}, + {'enrollment_mode': 'verified', 'status': False, 'output': 'Not ID Verified'}, + {'enrollment_mode': 'verified', 'status': True, 'output': 'ID Verified'}, + ) + def test_verification_status_for_user(self, enrollment_mode, status, output): + """ + Verify verification_status_for_user returns correct status. + """ + user = UserFactory.create() + CourseFactory.create() + + with patch( + 'lms.djangoapps.verify_student.services.IDVerificationService.user_is_verified' + ) as mock_verification: + + mock_verification.return_value = status + + status = IDVerificationService.verification_status_for_user(user, enrollment_mode) + self.assertEqual(status, output) diff --git a/lms/djangoapps/verify_student/tests/test_utils.py b/lms/djangoapps/verify_student/tests/test_utils.py new file mode 100644 index 0000000000..17c2041dbb --- /dev/null +++ b/lms/djangoapps/verify_student/tests/test_utils.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +""" +Tests for verify_student utility functions. +""" + +from datetime import datetime, timedelta + +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 student.tests.factories import UserFactory + +FAKE_SETTINGS = { + "DAYS_GOOD_FOR": 10, +} + + +@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) +@mark.django_db +class TestVerifyStudentUtils(unittest.TestCase): + """ + Tests for utility functions in verify_student. + """ + + def test_verification_for_datetime(self): + user = UserFactory.create() + now = datetime.now(pytz.UTC) + + # No attempts in the query set, so should return None + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = verification_for_datetime(now, query) + self.assertIs(result, None) + + # Should also return None if no deadline specified + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = verification_for_datetime(None, query) + self.assertIs(result, None) + + # Make an attempt + attempt = SoftwareSecurePhotoVerification.objects.create(user=user) + + # Before the created date, should get no results + before = attempt.created_at - timedelta(seconds=1) + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = verification_for_datetime(before, query) + self.assertIs(result, None) + + # Immediately after the created date, should get the attempt + after_created = attempt.created_at + timedelta(seconds=1) + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = verification_for_datetime(after_created, query) + self.assertEqual(result, attempt) + + # If no deadline specified, should return first available + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = verification_for_datetime(None, query) + self.assertEqual(result, attempt) + + # Immediately before the expiration date, should get the attempt + expiration = attempt.created_at + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + before_expiration = expiration - timedelta(seconds=1) + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = verification_for_datetime(before_expiration, query) + self.assertEqual(result, attempt) + + # Immediately after the expiration date, should not get the attempt + attempt.created_at = attempt.created_at - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + attempt.save() + after = datetime.now(pytz.UTC) + timedelta(days=1) + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = verification_for_datetime(after, query) + self.assertIs(result, None) + + # Create a second attempt in the same window + second_attempt = SoftwareSecurePhotoVerification.objects.create(user=user) + + # Now we should get the newer attempt + deadline = second_attempt.created_at + timedelta(days=1) + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = verification_for_datetime(deadline, query) + self.assertEqual(result, second_attempt) diff --git a/lms/djangoapps/verify_student/utils.py b/lms/djangoapps/verify_student/utils.py index 0596eb116b..55452b7e00 100644 --- a/lms/djangoapps/verify_student/utils.py +++ b/lms/djangoapps/verify_student/utils.py @@ -1,9 +1,11 @@ +# -*- coding: utf-8 -*- """ Common Utilities for the verify_student application. """ import datetime import logging +import pytz from django.conf import settings from django.core.mail import send_mail @@ -43,3 +45,61 @@ def send_verification_status_email(context): subject=context['subject'], user=context['user'].id )) + + +def is_verification_expiring_soon(expiration_datetime): + """ + Returns True if verification is expiring within EXPIRING_SOON_WINDOW. + """ + if expiration_datetime: + if (expiration_datetime - datetime.datetime.now(pytz.UTC)).days <= settings.VERIFY_STUDENT.get( + "EXPIRING_SOON_WINDOW"): + return True + + return False + + +def earliest_allowed_verification_date(): + """ + Returns the earliest allowed date given the settings + """ + days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] + return datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=days_good_for) + + +def verification_for_datetime(deadline, candidates): + """Find a verification in a set that applied during a particular datetime. + + A verification is considered "active" during a datetime if: + 1) The verification was created before the datetime, and + 2) The verification is set to expire after the datetime. + + Note that verification status is *not* considered here, + just the start/expire dates. + + If multiple verifications were active at the deadline, + returns the most recently created one. + + Arguments: + deadline (datetime): The datetime at which the verification applied. + If `None`, then return the most recently created candidate. + candidates (list of `PhotoVerification`s): Potential verifications to search through. + + Returns: + PhotoVerification: A photo verification that was active at the deadline. + If no verification was active, return None. + + """ + if not candidates: + return None + + # If there's no deadline, then return the most recently created verification + if deadline is None: + return candidates[0] + + # Otherwise, look for a verification that was in effect at the deadline, + # preferring recent verifications. + # If no such verification is found, implicitly return `None` + for verification in candidates: + if verification.active_at_datetime(deadline): + return verification diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 5be64b1581..bc1f13f74f 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -35,8 +35,9 @@ from edxmako.shortcuts import render_to_response, render_to_string from lms.djangoapps.commerce.utils import EcommerceService, is_account_activation_requirement_disabled from lms.djangoapps.verify_student.image import InvalidImageData, decode_image_data from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline -from lms.djangoapps.verify_student.utils import send_verification_status_email +from lms.djangoapps.verify_student.services import IDVerificationService from lms.djangoapps.verify_student.ssencrypt import has_valid_signature +from lms.djangoapps.verify_student.utils import is_verification_expiring_soon, send_verification_status_email from openedx.core.djangoapps.commerce.utils import ecommerce_api_client from openedx.core.djangoapps.embargo import api as embargo_api from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -645,7 +646,7 @@ class PayAndVerifyView(View): Returns: datetime object in string format """ - photo_verifications = SoftwareSecurePhotoVerification.verification_valid_or_pending(user) + photo_verifications = IDVerificationService.verification_valid_or_pending(user) # return 'expiration_datetime' of latest photo verification if found, # otherwise implicitly return '' if photo_verifications: @@ -664,7 +665,7 @@ class PayAndVerifyView(View): submitted photos within the expiration period. """ - return SoftwareSecurePhotoVerification.user_has_valid_or_pending(user) + return IDVerificationService.user_has_valid_or_pending(user) def _check_enrollment(self, user, course_key): """Check whether the user has an active enrollment and has paid. @@ -1202,12 +1203,12 @@ class ReverifyView(View): Most of the work is done client-side by composing the same Backbone views used in the initial verification flow. """ - status, __ = SoftwareSecurePhotoVerification.user_status(request.user) + status, __ = IDVerificationService.user_status(request.user) - expiration_datetime = SoftwareSecurePhotoVerification.get_expiration_datetime(request.user) + expiration_datetime = IDVerificationService.get_expiration_datetime(request.user) can_reverify = False if expiration_datetime: - if SoftwareSecurePhotoVerification.is_verification_expiring_soon(expiration_datetime): + if is_verification_expiring_soon(expiration_datetime): # The user has an active verification, but the verification # is set to expire within "EXPIRING_SOON_WINDOW" days (default is 4 weeks). # In this case user can resubmit photos for reverification.