From ecf4515b6e19c87d819c8dd4723307854ad347ea Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Tue, 20 Sep 2016 09:48:07 -0400 Subject: [PATCH] Introduce id verification step for proctored exams TNL-5083 --- cms/djangoapps/contentstore/signals.py | 10 +++++- common/lib/xmodule/xmodule/seq_module.py | 14 ++++++-- .../acceptance/pages/lms/pay_and_verify.py | 34 +++++++++++++++++++ .../tests/lms/test_lms_courseware.py | 27 ++++++++++++++- lms/djangoapps/courseware/module_render.py | 5 +-- .../courseware/tests/test_module_render.py | 4 +++ lms/djangoapps/verify_student/services.py | 32 +++++++++++++++++ lms/envs/bok_choy.py | 9 +++++ requirements/edx/github.txt | 2 +- 9 files changed, 130 insertions(+), 7 deletions(-) diff --git a/cms/djangoapps/contentstore/signals.py b/cms/djangoapps/contentstore/signals.py index 7f265abb3b..4be065214b 100644 --- a/cms/djangoapps/contentstore/signals.py +++ b/cms/djangoapps/contentstore/signals.py @@ -1,5 +1,6 @@ """ receivers of course_published and library_updated events in order to trigger indexing task """ +import logging from datetime import datetime from pytz import UTC @@ -13,6 +14,9 @@ from openedx.core.lib.gating import api as gating_api from util.module_utils import yield_dynamic_descriptor_descendants +log = logging.getLogger(__name__) + + @receiver(SignalHandler.course_published) def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument """ @@ -23,7 +27,11 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable= # first is to registered exams, the credit subsystem will assume that # all proctored exams have already been registered, so we have to do that first - register_special_exams(course_key) + try: + register_special_exams(course_key) + # pylint: disable=broad-except + except Exception as exception: + log.exception(exception) # then call into the credit subsystem (in /openedx/djangoapps/credit) # to perform any 'on_publish' workflow diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 53fac7893c..f343fe4c10 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -137,10 +137,11 @@ class ProctoringFields(object): @XBlock.wants('proctoring') +@XBlock.wants('verification') @XBlock.wants('milestones') @XBlock.wants('credit') -@XBlock.needs("user") -@XBlock.needs("bookmarks") +@XBlock.needs('user') +@XBlock.needs('bookmarks') class SequenceModule(SequenceFields, ProctoringFields, XModule): """ Layout module which lays out content in a temporal sequence @@ -433,6 +434,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): proctoring_service = self.runtime.service(self, 'proctoring') credit_service = self.runtime.service(self, 'credit') + verification_service = self.runtime.service(self, 'verification') # Is this sequence designated as a Timed Examination, which includes # Proctored Exams @@ -465,6 +467,14 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): 'credit_state': credit_state }) + # inject verification status + if verification_service: + verification_status, __ = verification_service.get_status(user_id) + context.update({ + 'verification_status': verification_status, + 'reverify_url': verification_service.reverify_url(), + }) + # See if the edx-proctoring subsystem wants to present # a special view to the student rather # than the actual sequence content diff --git a/common/test/acceptance/pages/lms/pay_and_verify.py b/common/test/acceptance/pages/lms/pay_and_verify.py index 28362eb3b0..25f59a9283 100644 --- a/common/test/acceptance/pages/lms/pay_and_verify.py +++ b/common/test/acceptance/pages/lms/pay_and_verify.py @@ -161,3 +161,37 @@ class FakePaymentPage(PageObject): self.q(css="input[value='Submit']").click() return PaymentAndVerificationFlow(self.browser, self._course_id, entry_point='payment-confirmation').wait_for_page() + + +class FakeSoftwareSecureVerificationPage(PageObject): + """ + This page is a page used for testing that allows the user to change the status of their most recent + verification. + """ + + url = BASE_URL + '/verify_student/software-secure-fake-response' + + def __init__(self, browser): + super(FakeSoftwareSecureVerificationPage, self).__init__(browser) + + def is_browser_on_page(self): + """ Determine if browser is on the page. """ + message = self.q(css='BODY').text[0] + match = re.search('Fake Software Secure page', message) + return True if match else False + + def mark_approved(self): + """ Mark the latest verification attempt as passing. """ + self.q(css='#btn_pass').click() + + def mark_denied(self): + """ Mark the latest verification attempt as denied. """ + self.q(css='#btn_denied').click() + + def mark_error(self): + """ Mark the latest verification attempt as an error. """ + self.q(css='#btn_error').click() + + def mark_unkown_error(self): + """ Mark the latest verification attempt as an unknown error. """ + self.q(css='#btn_unkonwn_error').click() diff --git a/common/test/acceptance/tests/lms/test_lms_courseware.py b/common/test/acceptance/tests/lms/test_lms_courseware.py index b9451315d0..5e5da26f39 100644 --- a/common/test/acceptance/tests/lms/test_lms_courseware.py +++ b/common/test/acceptance/tests/lms/test_lms_courseware.py @@ -17,7 +17,7 @@ from ...pages.lms.course_nav import CourseNavPage from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage from ...pages.lms.create_mode import ModeCreationPage from ...pages.lms.dashboard import DashboardPage -from ...pages.lms.pay_and_verify import PaymentAndVerificationFlow, FakePaymentPage +from ...pages.lms.pay_and_verify import PaymentAndVerificationFlow, FakePaymentPage, FakeSoftwareSecureVerificationPage from ...pages.lms.problem import ProblemPage from ...pages.lms.progress import ProgressPage from ...pages.lms.staff_view import StaffPage @@ -201,6 +201,28 @@ class ProctoredExamTest(UniqueCourseTest): # Submit payment self.fake_payment_page.submit_payment() + def _verify_user(self): + """ + Takes user through the verification flow and then marks the verification as 'approved'. + """ + # Immediately verify the user + self.immediate_verification_page.immediate_verification() + + # Take face photo and proceed to the ID photo step + self.payment_and_verification_flow.webcam_capture() + self.payment_and_verification_flow.next_verification_step(self.immediate_verification_page) + + # Take ID photo and proceed to the review photos step + self.payment_and_verification_flow.webcam_capture() + self.payment_and_verification_flow.next_verification_step(self.immediate_verification_page) + + # Submit photos and proceed to the enrollment confirmation step + self.payment_and_verification_flow.next_verification_step(self.immediate_verification_page) + + # Mark the verification as passing. + verification = FakeSoftwareSecureVerificationPage(self.browser).visit() + verification.mark_approved() + def test_can_create_proctored_exam_in_studio(self): """ Given that I am a staff member @@ -221,6 +243,7 @@ class ProctoredExamTest(UniqueCourseTest): select advanced settings tab When I Make the exam proctored. And I login as a verified student. + And I verify the user's ID. And visit the courseware as a verified student. Then I can see an option to take the exam as a proctored exam. """ @@ -235,6 +258,8 @@ class ProctoredExamTest(UniqueCourseTest): LogoutPage(self.browser).visit() self._login_as_a_verified_user() + self._verify_user() + self.courseware_page.visit() self.assertTrue(self.courseware_page.can_start_proctored_exam) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 5d3e663b3c..686c9275cf 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -51,7 +51,7 @@ from lms.djangoapps.lms_xblock.field_data import LmsFieldData from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig from openedx.core.djangoapps.bookmarks.services import BookmarksService from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem, unquote_slashes, quote_slashes -from lms.djangoapps.verify_student.services import ReverificationService +from lms.djangoapps.verify_student.services import VerificationService, ReverificationService from openedx.core.djangoapps.credit.services import CreditService from openedx.core.djangoapps.util.user_utils import SystemUser from openedx.core.lib.xblock_utils import ( @@ -747,7 +747,8 @@ def get_module_system_for_user(user, student_data, # TODO # pylint: disable=to 'fs': FSService(), 'field-data': field_data, 'user': DjangoXBlockUserService(user, user_is_staff=user_is_staff), - "reverification": ReverificationService(), + 'verification': VerificationService(), + 'reverification': ReverificationService(), 'proctoring': ProctoringService(), 'milestones': milestones_helpers.get_service(), 'credit': CreditService(), diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 590186963e..92fafe8a5f 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -67,9 +67,11 @@ from edx_proctoring.api import ( ) from edx_proctoring.runtime import set_runtime_service from edx_proctoring.tests.test_services import MockCreditService +from verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory from milestones.tests.utils import MilestonesTestCaseMixin + TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT @@ -740,6 +742,7 @@ class TestProctoringRendering(SharedModuleStoreTestCase): self.request = factory.get(chapter_url) self.request.user = UserFactory.create() self.user = UserFactory.create() + SoftwareSecurePhotoVerificationFactory.create(user=self.request.user) self.modulestore = self.store._get_modulestore_for_courselike(self.course_key) # pylint: disable=protected-access with self.modulestore.bulk_operations(self.course_key): self.toy_course = self.store.get_course(self.course_key, depth=2) @@ -1020,6 +1023,7 @@ class TestProctoringRendering(SharedModuleStoreTestCase): if attempt_status: create_exam_attempt(exam_id, self.request.user.id, taking_as_proctored=True) update_attempt_status(exam_id, self.request.user.id, attempt_status) + return usage_key def _find_url_name(self, toc, url_name): diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index 6758c0614b..8cdb8bb5a0 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -13,10 +13,42 @@ from opaque_keys.edx.keys import CourseKey from student.models import User, CourseEnrollment from lms.djangoapps.verify_student.models import VerificationCheckpoint, VerificationStatus, SkippedReverification +from .models import SoftwareSecurePhotoVerification log = logging.getLogger(__name__) +class VerificationService(object): + """ + Learner verification XBlock service + """ + + def get_status(self, user_id): + """ + Returns the user's current photo verification status. + + Args: + user_id: the user's id + + Returns: one of the following strings + 'none' - no such verification exists + 'expired' - verification has expired + 'approved' - verification has been approved + 'pending' - verification process is still ongoing + '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) + + def reverify_url(self): + """ + Returns the URL for a user to verify themselves. + """ + return reverse('verify_student_reverify') + + class ReverificationService(object): """ Reverification XBlock service diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index 1b3772583a..503ea475a6 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -132,6 +132,9 @@ FEATURES['LICENSING'] = True # Use the auto_auth workflow for creating users and logging them in FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True +# Open up endpoint for faking Software Secure responses +FEATURES['ENABLE_SOFTWARE_SECURE_FAKE'] = True + ########################### Entrance Exams ################################# FEATURES['MILESTONES_APP'] = True FEATURES['ENTRANCE_EXAMS'] = True @@ -176,6 +179,12 @@ MOCK_SEARCH_BACKING_FILE = ( TEST_ROOT / "index_file.dat" ).abspath() +# Verify student settings +VERIFY_STUDENT["SOFTWARE_SECURE"] = { + "API_ACCESS_KEY": "BBBBBBBBBBBBBBBBBBBB", + "API_SECRET_KEY": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", +} + # this secret key should be the same as cms/envs/bok_choy.py's SECRET_KEY = "very_secret_bok_choy_key" diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 831f1e09c7..5156ff9001 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -91,7 +91,7 @@ git+https://github.com/edx/xblock-utils.git@v1.0.3#egg=xblock-utils==1.0.3 -e git+https://github.com/edx/edx-reverification-block.git@0.0.5#egg=edx-reverification-block==0.0.5 git+https://github.com/edx/edx-user-state-client.git@1.0.1#egg=edx-user-state-client==1.0.1 git+https://github.com/edx/xblock-lti-consumer.git@v1.0.9#egg=xblock-lti-consumer==1.0.9 -git+https://github.com/edx/edx-proctoring.git@0.13.0#egg=edx-proctoring==0.13.0 +git+https://github.com/edx/edx-proctoring.git@0.14.0#egg=edx-proctoring==0.14.0 # Third Party XBlocks -e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga