From e2069f97a247beef33d0b864be876b5818e2445b Mon Sep 17 00:00:00 2001 From: Simon Chen Date: Wed, 26 Aug 2020 17:23:04 -0400 Subject: [PATCH] MST-348 Add the API to provide detailed ID Verification information (#24846) --- .../core/djangoapps/user_api/serializers.py | 31 +++- openedx/core/djangoapps/user_api/urls.py | 7 +- .../verification_api/tests/test_views.py | 158 +++++++++++++++--- .../user_api/verification_api/views.py | 32 +++- 4 files changed, 196 insertions(+), 32 deletions(-) diff --git a/openedx/core/djangoapps/user_api/serializers.py b/openedx/core/djangoapps/user_api/serializers.py index 6ddc05eba2..edcdcd9d8f 100644 --- a/openedx/core/djangoapps/user_api/serializers.py +++ b/openedx/core/djangoapps/user_api/serializers.py @@ -7,7 +7,12 @@ from django.contrib.auth.models import User from django.utils.timezone import now from rest_framework import serializers -from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification +from lms.djangoapps.verify_student.models import ( + IDVerificationAttempt, + ManualVerification, + SoftwareSecurePhotoVerification, + SSOVerification +) from .models import UserPreference @@ -130,3 +135,27 @@ class ManualVerificationSerializer(IDVerificationSerializer): class Meta(object): fields = ('status', 'expiration_datetime', 'is_verified') model = ManualVerification + + +class IDVerificationDetailsSerializer(serializers.Serializer): + type = serializers.SerializerMethodField() + status = serializers.CharField() + expiration_datetime = serializers.DateTimeField() + message = serializers.SerializerMethodField() + updated_at = serializers.DateTimeField() + + def get_type(self, obj): + if isinstance(obj, SoftwareSecurePhotoVerification): + return 'Software Secure' + elif isinstance(obj, ManualVerification): + return 'Manual' + else: + return 'SSO' + + def get_message(self, obj): + if isinstance(obj, SoftwareSecurePhotoVerification): + return obj.error_msg + elif isinstance(obj, ManualVerification): + return obj.reason + else: + return '' diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index 36aa80d7c8..0f7485a481 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -18,7 +18,7 @@ from .accounts.views import ( UsernameReplacementView ) from .preferences.views import PreferencesDetailView, PreferencesView -from .verification_api.views import IDVerificationStatusView +from .verification_api.views import IDVerificationStatusView, IDVerificationStatusDetailsView ME = AccountViewSet.as_view({ 'get': 'get', @@ -106,6 +106,11 @@ urlpatterns = [ IDVerificationStatusView.as_view(), name='verification_status' ), + url( + r'^v1/accounts/{}/verifications/$'.format(settings.USERNAME_PATTERN), + IDVerificationStatusDetailsView.as_view(), + name='verification_details' + ), url( r'^v1/accounts/{}/retirement_status/$'.format(settings.USERNAME_PATTERN), 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 dbfc8aa8fc..309d588a1d 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 @@ -10,16 +10,17 @@ from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification +from lms.djangoapps.verify_student.tests.factories import SSOVerificationFactory from student.tests.factories import UserFactory FROZEN_TIME = '2015-01-01' VERIFY_STUDENT = {'DAYS_GOOD_FOR': 365} -@override_settings(VERIFY_STUDENT=VERIFY_STUDENT) -class PhotoVerificationStatusViewTests(TestCase): - """ Tests for the PhotoVerificationStatusView endpoint. """ +class VerificationStatusViewTestsMixin: + """ Base class for the tests on verification status views """ + VIEW_NAME = None CREATED_AT = datetime.datetime.strptime(FROZEN_TIME, '%Y-%m-%d') PASSWORD = 'test' @@ -27,12 +28,11 @@ class PhotoVerificationStatusViewTests(TestCase): freezer = freezegun.freeze_time(FROZEN_TIME) freezer.start() self.addCleanup(freezer.stop) - - super(PhotoVerificationStatusViewTests, self).setUp() + super().setUp() self.user = UserFactory(password=self.PASSWORD) self.staff = UserFactory(is_staff=True, password=self.PASSWORD) - self.verification = SoftwareSecurePhotoVerification.objects.create(user=self.user, status='submitted') - self.path = reverse('verification_status', kwargs={'username': self.user.username}) + 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) def assert_path_not_found(self, path): @@ -40,36 +40,30 @@ class PhotoVerificationStatusViewTests(TestCase): response = self.client.get(path) self.assertEqual(response.status_code, 404) + def get_expected_response(self, *args, **kwargs): + raise NotImplementedError + def assert_verification_returned(self, verified=False): """ Assert the path returns HTTP 200 and returns appropriately-serialized data. """ response = self.client.get(self.path) self.assertEqual(response.status_code, 200) expected_expires = self.CREATED_AT + datetime.timedelta(settings.VERIFY_STUDENT['DAYS_GOOD_FOR']) - expected = { - 'status': self.verification.status, - 'expiration_datetime': '{}Z'.format(expected_expires.isoformat()), - 'is_verified': verified - } + expected = self.get_expected_response(verified=verified, expected_expires=expected_expires) self.assertEqual(json.loads(response.content.decode('utf-8')), expected) - def test_non_existent_user(self): - """ The endpoint should return HTTP 404 if the user does not exist. """ - path = reverse('verification_status', kwargs={'username': 'abc123'}) - self.assert_path_not_found(path) - - def test_no_verifications(self): - """ The endpoint should return HTTP 404 if the user has no verifications. """ - user = UserFactory() - path = reverse('verification_status', kwargs={'username': user.username}) - self.assert_path_not_found(path) - 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) self.assertEqual(response.status_code, 401) + def test_no_verifications(self): + """ The endpoint should return HTTP 404 if the user has no verifications. """ + user = UserFactory() + path = reverse(self.VIEW_NAME, kwargs={'username': user.username}) + self.assert_path_not_found(path) + def test_staff_user(self): """ The endpoint should be accessible to staff users. """ self.client.logout() @@ -82,17 +76,127 @@ class PhotoVerificationStatusViewTests(TestCase): self.client.login(username=self.user.username, password=self.PASSWORD) self.assert_verification_returned() - def test_non_owner_or_staff_user(self): + 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) self.assertEqual(response.status_code, 403) + def test_non_existent_user(self): + """ The endpoint should return HTTP 404 if the user does not exist. """ + path = reverse(self.VIEW_NAME, kwargs={'username': 'abc123'}) + self.assert_path_not_found(path) + + +@override_settings(VERIFY_STUDENT=VERIFY_STUDENT) +class PhotoVerificationStatusViewTests(VerificationStatusViewTestsMixin, TestCase): + """ Tests for the PhotoVerificationStatusView endpoint. """ + VIEW_NAME = 'verification_status' + + def get_expected_response(self, *args, **kwargs): + return { + 'status': self.photo_verification.status, + 'expiration_datetime': '{}Z'.format(kwargs.get('expected_expires').isoformat()), + 'is_verified': kwargs.get('verified') + } + def test_approved_verification(self): """ The endpoint should return that the user is verified if the user's verification is accepted. """ - self.verification.status = 'approved' - self.verification.save() + self.photo_verification.status = 'approved' + self.photo_verification.save() self.client.logout() self.client.login(username=self.user.username, password=self.PASSWORD) self.assert_verification_returned(verified=True) + + +@override_settings(VERIFY_STUDENT=VERIFY_STUDENT) +class VerificationsDetailsViewTests(VerificationStatusViewTestsMixin, TestCase): + """ Tests for the IDVerificationDetails endpoint. """ + VIEW_NAME = 'verification_details' + + 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': '', + 'updated_at': '{}Z'.format(self.CREATED_AT.isoformat()) + }] + + def test_multiple_verification_types(self): + self.manual_verification = ManualVerification.objects.create( + user=self.user, + status='approved', + reason='testing' + ) + self.sso_verification = SSOVerificationFactory(user=self.user, status='approved') + self.photo_verification.error_msg = 'tested_error' + self.photo_verification.error_code = 'error_code' + self.photo_verification.status = 'denied' + self.photo_verification.save() + response = self.client.get(self.path) + self.assertEqual(response.status_code, 200) + expected_expires = self.CREATED_AT + datetime.timedelta(settings.VERIFY_STUDENT['DAYS_GOOD_FOR']) + + expected = [ + { + 'type': 'Software Secure', + 'status': self.photo_verification.status, + 'expiration_datetime': '{}Z'.format(expected_expires.isoformat()), + 'message': self.photo_verification.error_msg, + 'updated_at': '{}Z'.format(self.CREATED_AT.isoformat()), + }, + { + 'type': 'SSO', + 'status': self.sso_verification.status, + 'expiration_datetime': '{}Z'.format(expected_expires.isoformat()), + 'message': '', + 'updated_at': '{}Z'.format(self.CREATED_AT.isoformat()), + }, + { + 'type': 'Manual', + 'status': self.manual_verification.status, + 'expiration_datetime': '{}Z'.format(expected_expires.isoformat()), + 'message': self.manual_verification.reason, + 'updated_at': '{}Z'.format(self.CREATED_AT.isoformat()), + }, + ] + self.assertEqual(json.loads(response.content.decode('utf-8')), expected) + + def test_multiple_verification_instances(self): + self.sso_verification = SSOVerificationFactory(user=self.user, status='approved') + second_ss_photo_verification = SoftwareSecurePhotoVerification.objects.create( + user=self.user, + status='denied', + error_msg='test error message for denial', + error_code='plain_code' + ) + response = self.client.get(self.path) + self.assertEqual(response.status_code, 200) + expected_expires = self.CREATED_AT + datetime.timedelta(settings.VERIFY_STUDENT['DAYS_GOOD_FOR']) + + expected = [ + { + 'type': 'Software Secure', + 'status': self.photo_verification.status, + 'expiration_datetime': '{}Z'.format(expected_expires.isoformat()), + 'message': self.photo_verification.error_msg, + 'updated_at': '{}Z'.format(self.CREATED_AT.isoformat()), + }, + { + 'type': 'Software Secure', + 'status': second_ss_photo_verification.status, + 'expiration_datetime': '{}Z'.format(expected_expires.isoformat()), + 'message': second_ss_photo_verification.error_msg, + 'updated_at': '{}Z'.format(self.CREATED_AT.isoformat()), + }, + { + 'type': 'SSO', + 'status': self.sso_verification.status, + 'expiration_datetime': '{}Z'.format(expected_expires.isoformat()), + 'message': '', + 'updated_at': '{}Z'.format(self.CREATED_AT.isoformat()), + }, + ] + self.assertEqual(json.loads(response.content.decode('utf-8')), expected) diff --git a/openedx/core/djangoapps/user_api/verification_api/views.py b/openedx/core/djangoapps/user_api/verification_api/views.py index f2ec317a83..759fac1e93 100644 --- a/openedx/core/djangoapps/user_api/verification_api/views.py +++ b/openedx/core/djangoapps/user_api/verification_api/views.py @@ -1,19 +1,21 @@ """ Verification API v1 views. """ - +from django.contrib.auth import get_user_model from django.http import Http404 from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from rest_framework.authentication import SessionAuthentication -from rest_framework.generics import RetrieveAPIView -from openedx.core.lib.api.authentication import BearerAuthentication +from rest_framework.generics import ListAPIView, RetrieveAPIView from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification +from lms.djangoapps.verify_student.services import IDVerificationService from lms.djangoapps.verify_student.utils import most_recent_verification from openedx.core.djangoapps.user_api.serializers import ( + IDVerificationDetailsSerializer, ManualVerificationSerializer, SoftwareSecurePhotoVerificationSerializer, SSOVerificationSerializer ) +from openedx.core.lib.api.authentication import BearerAuthentication from openedx.core.lib.api.permissions import IsStaffOrOwner @@ -53,3 +55,27 @@ class IDVerificationStatusView(RetrieveAPIView): return verification raise Http404 + + +class IDVerificationStatusDetailsView(ListAPIView): + """ IDVerificationStatusDeetails endpoint to retrieve more details about ID Verification status """ + authentication_classes = (JwtAuthentication, BearerAuthentication, SessionAuthentication,) + permission_classes = (IsStaffOrOwner,) + pagination_class = None # No need for pagination for this yet + + def get_serializer(self, *args, **kwargs): + kwargs['context'] = self.get_serializer_context() + return IDVerificationDetailsSerializer(*args, **kwargs) + + def get_queryset(self): + username = self.kwargs['username'] + User = get_user_model() + try: + user = User.objects.get(username=username) + verifications = IDVerificationService.verifications_for_user(user) + if not verifications: + raise Http404 + + return sorted(verifications, key=lambda x: x.updated_at, reverse=True) + except User.DoesNotExist: + raise Http404