Skip to content

Commit

Permalink
API functions for the new generic VerificationAttempt model in the ve…
Browse files Browse the repository at this point in the history
…rify_student app (#35338)

* feat: add VerificationAttempt model to verify_student application

This commits adds a VerificationAttempt model to store implementation and provider agnostic information about identity verification attempts in the platform.

* feat: add api for VerificationAttempt model

* fix: error handling for update

- added tests accordingly
- also took care of some nits

* chore: lint

* chore: lint for equals spaces

* feat: using generic update function instead

- can now update name, status, and exp. date on generic attempts
- changed tests accordingly
- a few nits

* chore: fix docstring args

* fix: corrected status validation

- reverted to old status validation method
- fixed tests accordingly

* fix: datetime, status, and annotation fixes

- expiration_datetime can be updated to None
- VerificationAttemptStatus is now StrEnum
- Added type annotations for api functions

---------

Co-authored-by: michaelroytman <mroytman@edx.org>
  • Loading branch information
ilee2u and MichaelRoytman authored Sep 9, 2024
1 parent fbd7d4c commit a3871cd
Show file tree
Hide file tree
Showing 6 changed files with 251 additions and 12 deletions.
92 changes: 92 additions & 0 deletions lms/djangoapps/verify_student/api.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
"""
API module.
"""
import logging

from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _

from datetime import datetime
from typing import Optional

from lms.djangoapps.verify_student.emails import send_verification_approved_email
from lms.djangoapps.verify_student.exceptions import VerificationAttemptInvalidStatus
from lms.djangoapps.verify_student.models import VerificationAttempt
from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus
from lms.djangoapps.verify_student.tasks import send_verification_status_email

log = logging.getLogger(__name__)

User = get_user_model()


def send_approval_email(attempt):
"""
Expand All @@ -33,3 +46,82 @@ def send_approval_email(attempt):
else:
email_context = {'user': attempt.user, 'expiration_datetime': expiration_datetime.strftime("%m/%d/%Y")}
send_verification_approved_email(context=email_context)


def create_verification_attempt(user: User, name: str, status: str, expiration_datetime: Optional[datetime] = None):
"""
Create a verification attempt.
This method is intended to be used by IDV implementation plugins to create VerificationAttempt instances.
Args:
user (User): the user (usually a learner) performing the verification attempt
name (string): the name being ID verified
status (string): the initial status of the verification attempt
expiration_datetime (datetime, optional): When the verification attempt expires. Defaults to None.
Returns:
id (int): The id of the created VerificationAttempt instance
"""
verification_attempt = VerificationAttempt.objects.create(
user=user,
name=name,
status=status,
expiration_datetime=expiration_datetime,
)

return verification_attempt.id


def update_verification_attempt(
attempt_id: int,
name: Optional[str] = None,
status: Optional[str] = None,
expiration_datetime: Optional[datetime] = None
):
"""
Update a verification attempt.
This method is intended to be used by IDV implementation plugins to update VerificationAttempt instances.
Arguments:
* attempt_id (int): the verification attempt id of the attempt to update
* name (string, optional): the new name being ID verified
* status (string, optional): the new status of the verification attempt
* expiration_datetime (datetime, optional): The new expiration date and time
Returns:
* None
"""
try:
attempt = VerificationAttempt.objects.get(id=attempt_id)
except VerificationAttempt.DoesNotExist:
log.error(
f'VerificationAttempt with id {attempt_id} was not found '
f'when updating the attempt to status={status}',
)
raise

if name is not None:
attempt.name = name

if status is not None:
attempt.status = status

status_list = list(VerificationAttemptStatus)
if status not in status_list:
log.error(
'Attempted to call update_verification_attempt called with invalid status: %(status)s. '
'Status must be one of: %(status_list)s',
{
'status': status,
'status_list': VerificationAttempt.STATUS_CHOICES,
},
)
raise VerificationAttemptInvalidStatus

# NOTE: Generally, we only set the expiration date from the time that an IDV attempt is marked approved,
# so we allow expiration_datetime to = None for other status updates (e.g. pending).
attempt.expiration_datetime = expiration_datetime

attempt.save()
4 changes: 4 additions & 0 deletions lms/djangoapps/verify_student/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@

class WindowExpiredException(Exception):
pass


class VerificationAttemptInvalidStatus(Exception):
pass
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import time
from pprint import pformat

from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.management.base import BaseCommand, CommandError

from lms.djangoapps.verify_student.api import send_approval_email
Expand Down
8 changes: 4 additions & 4 deletions lms/djangoapps/verify_student/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1203,10 +1203,10 @@ class VerificationAttempt(TimeStampedModel):
name = models.CharField(blank=True, max_length=255)

STATUS_CHOICES = [
VerificationAttemptStatus.created,
VerificationAttemptStatus.pending,
VerificationAttemptStatus.approved,
VerificationAttemptStatus.denied,
VerificationAttemptStatus.CREATED,
VerificationAttemptStatus.PENDING,
VerificationAttemptStatus.APPROVED,
VerificationAttemptStatus.DENIED,
]
status = models.CharField(max_length=64, choices=[(status, status) for status in STATUS_CHOICES])

Expand Down
11 changes: 6 additions & 5 deletions lms/djangoapps/verify_student/statuses.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
"""
Status enums for verify_student.
"""
from enum import StrEnum, auto


class VerificationAttemptStatus:
class VerificationAttemptStatus(StrEnum):
"""This class describes valid statuses for a verification attempt to be in."""

# This is the initial state of a verification attempt, before a learner has started IDV.
created = "created"
CREATED = auto()

# A verification attempt is pending when it has been started but has not yet been completed.
pending = "pending"
PENDING = auto()

# A verification attempt is approved when it has been approved by some mechanism (e.g. automatic review, manual
# review, etc).
approved = "approved"
APPROVED = auto()

# A verification attempt is denied when it has been denied by some mechanism (e.g. automatic review, manual review,
# etc).
denied = "denied"
DENIED = auto()
147 changes: 145 additions & 2 deletions lms/djangoapps/verify_student/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,29 @@
"""
from unittest.mock import patch

from datetime import datetime, timezone
import ddt
from django.conf import settings
from django.core import mail
from django.test import TestCase

from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.verify_student.api import send_approval_email
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from lms.djangoapps.verify_student.api import (
create_verification_attempt,
send_approval_email,
update_verification_attempt,
)
from lms.djangoapps.verify_student.exceptions import VerificationAttemptInvalidStatus
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationAttempt
from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus


@ddt.ddt
class TestSendApprovalEmail(TestCase):
"""
Test cases for the send_approval_email API method.
"""

def setUp(self):
super().setUp()

Expand All @@ -41,3 +49,138 @@ def test_send_approval(self, use_ace):
with patch.dict(settings.VERIFY_STUDENT, {'USE_DJANGO_MAIL': use_ace}):
send_approval_email(self.attempt)
self._assert_verification_approved_email(self.attempt.expiration_datetime)


@ddt.ddt
class CreateVerificationAttempt(TestCase):
"""
Test cases for the create_verification_attempt API method.
"""

def setUp(self):
super().setUp()

self.user = UserFactory.create()
self.attempt = VerificationAttempt(
user=self.user,
name='Tester McTest',
status=VerificationAttemptStatus.CREATED,
expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc)
)
self.attempt.save()

def test_create_verification_attempt(self):
expected_id = 2
self.assertEqual(
create_verification_attempt(
user=self.user,
name='Tester McTest',
status=VerificationAttemptStatus.CREATED,
expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc)
),
expected_id
)
verification_attempt = VerificationAttempt.objects.get(id=expected_id)

self.assertEqual(verification_attempt.user, self.user)
self.assertEqual(verification_attempt.name, 'Tester McTest')
self.assertEqual(verification_attempt.status, VerificationAttemptStatus.CREATED)
self.assertEqual(verification_attempt.expiration_datetime, datetime(2024, 12, 31, tzinfo=timezone.utc))

def test_create_verification_attempt_no_expiration_datetime(self):
expected_id = 2
self.assertEqual(
create_verification_attempt(
user=self.user,
name='Tester McTest',
status=VerificationAttemptStatus.CREATED,
),
expected_id
)
verification_attempt = VerificationAttempt.objects.get(id=expected_id)

self.assertEqual(verification_attempt.user, self.user)
self.assertEqual(verification_attempt.name, 'Tester McTest')
self.assertEqual(verification_attempt.status, VerificationAttemptStatus.CREATED)
self.assertEqual(verification_attempt.expiration_datetime, None)


@ddt.ddt
class UpdateVerificationAttempt(TestCase):
"""
Test cases for the update_verification_attempt API method.
"""

def setUp(self):
super().setUp()

self.user = UserFactory.create()
self.attempt = VerificationAttempt(
user=self.user,
name='Tester McTest',
status=VerificationAttemptStatus.CREATED,
expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc)
)
self.attempt.save()

@ddt.data(
('Tester McTest', VerificationAttemptStatus.PENDING, datetime(2024, 12, 31, tzinfo=timezone.utc)),
('Tester McTest2', VerificationAttemptStatus.APPROVED, datetime(2025, 12, 31, tzinfo=timezone.utc)),
('Tester McTest3', VerificationAttemptStatus.DENIED, datetime(2026, 12, 31, tzinfo=timezone.utc)),
)
@ddt.unpack
def test_update_verification_attempt(self, name, status, expiration_datetime):
update_verification_attempt(
attempt_id=self.attempt.id,
name=name,
status=status,
expiration_datetime=expiration_datetime,
)

verification_attempt = VerificationAttempt.objects.get(id=self.attempt.id)

# Values should change as a result of this update.
self.assertEqual(verification_attempt.user, self.user)
self.assertEqual(verification_attempt.name, name)
self.assertEqual(verification_attempt.status, status)
self.assertEqual(verification_attempt.expiration_datetime, expiration_datetime)

def test_update_verification_attempt_none_values(self):
update_verification_attempt(
attempt_id=self.attempt.id,
name=None,
status=None,
expiration_datetime=None,
)

verification_attempt = VerificationAttempt.objects.get(id=self.attempt.id)

# Values should not change as a result of the values passed in being None, except for expiration_datetime.
self.assertEqual(verification_attempt.user, self.user)
self.assertEqual(verification_attempt.name, self.attempt.name)
self.assertEqual(verification_attempt.status, self.attempt.status)
self.assertEqual(verification_attempt.expiration_datetime, None)

def test_update_verification_attempt_not_found(self):
self.assertRaises(
VerificationAttempt.DoesNotExist,
update_verification_attempt,
attempt_id=999999,
status=VerificationAttemptStatus.APPROVED,
)

@ddt.data(
'completed',
'failed',
'submitted',
'expired',
)
def test_update_verification_attempt_invalid(self, status):
self.assertRaises(
VerificationAttemptInvalidStatus,
update_verification_attempt,
attempt_id=self.attempt.id,
name=None,
status=status,
expiration_datetime=None,
)

0 comments on commit a3871cd

Please sign in to comment.