From 0b158a8a422a5b10ba55e1de09a830ca68b0ecee Mon Sep 17 00:00:00 2001 From: Simon Chen Date: Thu, 3 Mar 2022 10:33:14 -0500 Subject: [PATCH] feat: provide new verification details endpoint for support-tools (#30004) Co-authored-by: Simon Chen --- lms/djangoapps/verify_student/services.py | 21 ++++ .../verify_student/tests/test_services.py | 48 +++++++++ openedx/core/djangoapps/user_api/urls.py | 11 +- .../verification_api/tests/test_views.py | 100 ++++++++++++++++-- .../user_api/verification_api/views.py | 19 ++++ 5 files changed, 192 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index 69e1dbe9e3..bdfa31fee6 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -8,6 +8,7 @@ from itertools import chain from urllib.parse import quote from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist from django.utils.timezone import now from django.utils.translation import gettext as _ @@ -235,3 +236,23 @@ class IDVerificationService: if course_id: location += f'?course_id={quote(str(course_id))}' return location + + @classmethod + def get_verification_details_by_id(cls, attempt_id): + """ + Returns a verification attempt object by attempt_id + If the verification object cannot be found, returns None + """ + verification = None + verification_models = [ + SoftwareSecurePhotoVerification, + SSOVerification, + ManualVerification, + ] + for ver_model in verification_models: + if not verification: + try: + verification = ver_model.objects.get(id=attempt_id) + except ObjectDoesNotExist: + pass + return verification diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py index b526357909..56f388b7c9 100644 --- a/lms/djangoapps/verify_student/tests/test_services.py +++ b/lms/djangoapps/verify_student/tests/test_services.py @@ -3,6 +3,8 @@ Tests for the service classes in verify_student. """ from datetime import datetime, timedelta, timezone +import itertools +from random import randint from unittest.mock import patch import ddt @@ -156,6 +158,52 @@ class TestIDVerificationService(ModuleStoreTestCase): expiration_datetime = IDVerificationService.get_expiration_datetime(user_a, ['approved']) assert expiration_datetime == newer_record.expiration_datetime + @ddt.data( + {'status': 'denied', 'error_msg': '[{"generalReasons": ["Name mismatch"]}]'}, + {'status': 'approved', 'error_msg': ''}, + {'status': 'submitted', 'error_msg': ''}, + ) + def test_get_verification_details_by_id(self, kwargs): + user = UserFactory.create() + kwargs['user'] = user + sspv = SoftwareSecurePhotoVerification.objects.create(**kwargs) + attempt = IDVerificationService.get_verification_details_by_id(sspv.id) + assert attempt.id == sspv.id + assert attempt.user.id == user.id + assert attempt.status == kwargs['status'] + assert attempt.error_msg == kwargs['error_msg'] + + @ddt.data( + *itertools.product( + [SSOVerification, ManualVerification], + [ + {'status': 'denied'}, + {'status': 'approved'}, + {'status': 'submitted'}, + ] + ) + ) + @ddt.unpack + def test_get_verification_details_other_types(self, verification_model, kwargs): + user = UserFactory.create() + kwargs['user'] = user + model_object = verification_model.objects.create(**kwargs) + + attempt = IDVerificationService.get_verification_details_by_id(model_object.id) + assert attempt.id == model_object.id + assert attempt.user.id == user.id + assert attempt.status == kwargs['status'] + + @ddt.data( + SoftwareSecurePhotoVerification, SSOVerification, ManualVerification + ) + def test_get_verification_details_not_found(self, verification_model): + user = UserFactory.create() + model_object = verification_model.objects.create(user=user) + not_found_id = model_object.id + randint(100, 200) + attempt = IDVerificationService.get_verification_details_by_id(not_found_id) + assert attempt is None + @patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) @ddt.ddt diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index d0db126367..e9f1b01a1b 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -22,7 +22,11 @@ from .accounts.views import ( from . import views as user_api_views from .models import UserPreference from .preferences.views import PreferencesDetailView, PreferencesView -from .verification_api.views import IDVerificationStatusView, IDVerificationStatusDetailsView +from .verification_api.views import ( + IDVerificationStatusView, + IDVerificationStatusDetailsView, + IDVerificationSupportView, +) ME = AccountViewSet.as_view({ 'get': 'get', @@ -146,6 +150,11 @@ urlpatterns = [ IDVerificationStatusDetailsView.as_view(), name='verification_details' ), + url( + r'^v1/accounts/verifications/(?P[0-9]+)/$', + IDVerificationSupportView.as_view(), + name='verification_for_support' + ), url( fr'^v1/accounts/{settings.USERNAME_PATTERN}/retirement_status/$', RETIREMENT_RETRIEVE, diff --git a/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py b/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py index 9d8fa0c702..1a5d4fe46b 100644 --- a/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py @@ -4,22 +4,23 @@ import datetime import json +import ddt import freezegun from django.conf import settings from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse +from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification from lms.djangoapps.verify_student.tests.factories import SSOVerificationFactory -from common.djangoapps.student.tests.factories import UserFactory FROZEN_TIME = '2015-01-01' VERIFY_STUDENT = {'DAYS_GOOD_FOR': 365, 'EXPIRING_SOON_WINDOW': 20} -class VerificationStatusViewTestsMixin: - """ Base class for the tests on verification status views """ +class VerificationViewTestsMixinBase: + """ Base class for the tests on verification views """ VIEW_NAME = None CREATED_AT = datetime.datetime.strptime(FROZEN_TIME, '%Y-%m-%d') PASSWORD = 'test' @@ -32,9 +33,49 @@ class VerificationStatusViewTestsMixin: self.user = UserFactory(password=self.PASSWORD) self.staff = UserFactory(is_staff=True, password=self.PASSWORD) self.photo_verification = SoftwareSecurePhotoVerification.objects.create(user=self.user, status='submitted') - self.path = reverse(self.VIEW_NAME, kwargs={'username': self.user.username}) self.client.login(username=self.staff.username, password=self.PASSWORD) + @property + def path(self): + raise NotImplementedError + + def get_expected_response(self, *args, **kwargs): + raise NotImplementedError + + def assert_verification_returned(self, verified=False): + """ Assert the path returns HTTP 200 """ + response = self.client.get(self.path) + assert response.status_code == 200 + + return response + + def test_authentication_required(self): + """ The endpoint should return HTTP 403 if the user is not authenticated. """ + self.client.logout() + response = self.client.get(self.path) + assert response.status_code == 401 + + def test_staff_user(self): + """ The endpoint should be accessible to staff users. """ + self.client.logout() + self.client.login(username=self.staff.username, password=self.PASSWORD) + self.assert_verification_returned() + + def test_non_owner_nor_staff_user(self): + """ The endpoint should NOT be accessible if the request is not made by the submitter or staff user. """ + user = UserFactory() + self.client.login(username=user.username, password=self.PASSWORD) + response = self.client.get(self.path) + assert response.status_code == 403 + + +class VerificationStatusViewTestsMixin(VerificationViewTestsMixinBase): + """ Base class for the tests on verification status views """ + + @property + def path(self): + return reverse(self.VIEW_NAME, kwargs={'username': self.user.username}) + def assert_path_not_found(self, path): """ Assert the path returns HTTP 404. """ response = self.client.get(path) @@ -45,8 +86,7 @@ class VerificationStatusViewTestsMixin: def assert_verification_returned(self, verified=False): """ Assert the path returns HTTP 200 and returns appropriately-serialized data. """ - response = self.client.get(self.path) - assert response.status_code == 200 + response = super().assert_verification_returned() expected_expires = self.CREATED_AT + datetime.timedelta(settings.VERIFY_STUDENT['DAYS_GOOD_FOR']) expected = self.get_expected_response(verified=verified, expected_expires=expected_expires) @@ -218,3 +258,51 @@ class VerificationsDetailsViewTests(VerificationStatusViewTestsMixin, TestCase): }, ] assert json.loads(response.content.decode('utf-8')) == expected + + +@override_settings(VERIFY_STUDENT=VERIFY_STUDENT) +@ddt.ddt +class VerificationSupportViewTests(VerificationViewTestsMixinBase, TestCase): + """ + Tests for the verification_for_support view + """ + @property + def path(self): + return reverse('verification_for_support', kwargs={'attempt_id': self.photo_verification.id}) + + def get_expected_response(self, *args, **kwargs): + return { + 'type': 'Software Secure', + 'status': self.photo_verification.status, + 'expiration_datetime': '{}Z'.format(kwargs.get('expected_expires').isoformat()), + 'message': kwargs.get('error_msg'), + 'updated_at': f'{self.CREATED_AT.isoformat()}Z', + 'receipt_id': self.photo_verification.receipt_id, + } + + @ddt.data( + ('accepted', ''), + ('denied', '[{"generalReasons": ["Name mismatch"]}]'), + ('submitted', ''), + ('must_retry', ''), + ) + @ddt.unpack + def test_get_details(self, status, error_message): + self.photo_verification.status = status + self.photo_verification.error_msg = error_message + self.photo_verification.save() + self.client.login(username=self.staff.username, password=self.PASSWORD) + response = self.assert_verification_returned() + expected_expires = self.CREATED_AT + datetime.timedelta(settings.VERIFY_STUDENT['DAYS_GOOD_FOR']) + expected = self.get_expected_response(expected_expires=expected_expires, error_msg=error_message) + assert json.loads(response.content.decode('utf-8')) == expected + + @ddt.data( + 0, + 234324, + 'not_a_number', + ) + def test_not_found(self, attempt_id): + not_found_path = self.path.replace(str(self.photo_verification.id), str(attempt_id)) + response = self.client.get(not_found_path) + assert response.status_code == 404 diff --git a/openedx/core/djangoapps/user_api/verification_api/views.py b/openedx/core/djangoapps/user_api/verification_api/views.py index 1ef159150c..584479596c 100644 --- a/openedx/core/djangoapps/user_api/verification_api/views.py +++ b/openedx/core/djangoapps/user_api/verification_api/views.py @@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model from django.http import Http404 from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.permissions import IsStaff from rest_framework.authentication import SessionAuthentication from rest_framework.generics import ListAPIView from rest_framework.response import Response @@ -60,3 +61,21 @@ class IDVerificationStatusDetailsView(ListAPIView): return sorted(verifications, key=lambda x: x.updated_at, reverse=True) except User.DoesNotExist: raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + + +class IDVerificationSupportView(APIView): + """ IDVerification endpoint for support-tool""" + authentication_classes = (JwtAuthentication, BearerAuthentication, SessionAuthentication,) + permission_classes = (IsStaff,) + + def get(self, request, **kwargs): + """ + Get IDV attempt details by attempt_id. Only accessible by global staff. + """ + attempt_id = kwargs.get('attempt_id') + verification_detail = IDVerificationService.get_verification_details_by_id(attempt_id) + if not verification_detail: + raise Http404 + return Response( + IDVerificationDetailsSerializer(verification_detail).data + )