feat: provide new verification details endpoint for support-tools (#30004)
Co-authored-by: Simon Chen <schen@edX-C02FW0GUML85.local>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user