Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API functions for the new generic VerificationAttempt model in the verify_student app #35338

Merged
merged 9 commits into from
Sep 9, 2024
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,
)
Loading