feat: provide new verification details endpoint for support-tools (#30004)

Co-authored-by: Simon Chen <schen@edX-C02FW0GUML85.local>
This commit is contained in:
Simon Chen
2022-03-03 10:33:14 -05:00
committed by GitHub
parent c6cd064194
commit 0b158a8a42
5 changed files with 192 additions and 7 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<attempt_id>[0-9]+)/$',
IDVerificationSupportView.as_view(),
name='verification_for_support'
),
url(
fr'^v1/accounts/{settings.USERNAME_PATTERN}/retirement_status/$',
RETIREMENT_RETRIEVE,

View File

@@ -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

View File

@@ -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
)