Skip to content

Commit

Permalink
feat: integrate with verification attempt events (#226)
Browse files Browse the repository at this point in the history
Adds support for handling events/signals on the new VerificationAttempt model added to edx-platform. This handling replaces this libraries support for reacting to changes on a SoftwareSecurePhotoVerification since those models are slated for deprecation
  • Loading branch information
zacharis278 authored Oct 4, 2024
1 parent 97fb8bc commit cef4654
Show file tree
Hide file tree
Showing 14 changed files with 233 additions and 209 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ Change Log
Unreleased
~~~~~~~~~~

[3.0.0] - 2024-09-30
~~~~~~~~~~~~~~~~~~~~
* Add platform verification id field to the VerifiedName model
* Integrate platform verification id into app
* Added event handlers for new IDV events on the VerifiedName model
* Removed event handlers for SoftwareSecurePhotoVerification updates. This is a breaking change.

[2.4.0] - 2024-04-23
~~~~~~~~~~~~~~~~~~~~
Expand Down
2 changes: 1 addition & 1 deletion edx_name_affirmation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Django app housing name affirmation logic.
"""

__version__ = '2.4.2'
__version__ = '3.0.0'
8 changes: 2 additions & 6 deletions edx_name_affirmation/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,9 @@ class EdxNameAffirmationConfig(AppConfig):
'relative_path': 'handlers',
'receivers': [
{
'receiver_func_name': 'idv_attempt_handler',
'signal_path': 'lms.djangoapps.verify_student.signals.signals.idv_update_signal',
},
{
'receiver_func_name': 'idv_delete_handler',
'receiver_func_name': 'platform_verification_delete_handler',
'signal_path': 'django.db.models.signals.post_delete',
'sender_path': 'lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification',
'sender_path': 'lms.djangoapps.verify_student.models.VerificationAttempt',
},
{
'receiver_func_name': 'proctoring_attempt_handler',
Expand Down
89 changes: 50 additions & 39 deletions edx_name_affirmation/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

import logging

from openedx_events.learning.signals import (
IDV_ATTEMPT_APPROVED,
IDV_ATTEMPT_CREATED,
IDV_ATTEMPT_DENIED,
IDV_ATTEMPT_PENDING
)

from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.dispatch.dispatcher import receiver
Expand Down Expand Up @@ -35,56 +42,60 @@ def verified_name_approved(sender, instance, **kwargs): # pylint: disable=unuse
)


def idv_attempt_handler(attempt_id, user_id, status, photo_id_name, full_name, **kwargs):
@receiver(IDV_ATTEMPT_APPROVED)
@receiver(IDV_ATTEMPT_CREATED)
@receiver(IDV_ATTEMPT_DENIED)
@receiver(IDV_ATTEMPT_PENDING)
def handle_idv_event(sender, signal, **kwargs): # pylint: disable=unused-argument
"""
Receiver for IDV attempt updates
Args:
attempt_id(int): ID associated with the IDV attempt
user_id(int): ID associated with the IDV attempt's user
status(str): status in IDV language for the IDV attempt
photo_id_name(str): name to be used as verified name
full_name(str): user's pending name change or current profile name
Trigger update to verified names based on open edX IDV events.
"""
trigger_status = VerifiedNameStatus.trigger_state_change_from_idv(status)

# only trigger celery task if status is relevant to name affirmation
if trigger_status:
log.info('VerifiedName: idv_attempt_handler triggering Celery task for user %(user_id)s '
'with photo_id_name %(photo_id_name)s and status %(status)s',
{
'user_id': user_id,
'photo_id_name': photo_id_name,
'status': status
}
)
idv_update_verified_name_task.delay(attempt_id, user_id, trigger_status, photo_id_name, full_name)
event_data = kwargs.get('idv_attempt')
user = User.objects.get(id=event_data.user.id)

# If the user has a pending name change, use that as the full name
try:
user_full_name = user.pending_name_change
except AttributeError:
user_full_name = event_data.user.pii.name

status = None
if signal == IDV_ATTEMPT_APPROVED:
status = VerifiedNameStatus.APPROVED
elif signal == IDV_ATTEMPT_CREATED:
status = VerifiedNameStatus.PENDING
elif signal == IDV_ATTEMPT_PENDING:
status = VerifiedNameStatus.SUBMITTED
elif signal == IDV_ATTEMPT_DENIED:
status = VerifiedNameStatus.DENIED
else:
log.info('VerifiedName: idv_attempt_handler will not trigger Celery task for user %(user_id)s '
'with photo_id_name %(photo_id_name)s because of status %(status)s',
{
'user_id': user_id,
'photo_id_name': photo_id_name,
'status': status
}
)
log.info(f'IDV_ATTEMPT {signal} signal not recognized') # driven by receiver decorator so should never happen
return

log.info(f'IDV_ATTEMPT {status} signal triggering Celery task for user {user.id} '
f'with name {event_data.name}')
idv_update_verified_name_task.delay(
event_data.attempt_id,
user.id,
status,
event_data.name,
user_full_name,
)

def idv_delete_handler(sender, instance, signal, **kwargs): # pylint: disable=unused-argument
"""
Receiver for IDV attempt deletions

Args:
attempt_id(int): ID associated with the IDV attempt
def platform_verification_delete_handler(sender, instance, signal, **kwargs): # pylint: disable=unused-argument
"""
Receiver for VerificationAttempt deletions
"""
idv_attempt_id = instance.id
platform_verification_attempt_id = instance.id
log.info(
'VerifiedName: idv_delete_handler triggering Celery task for idv_attempt_id=%(idv_attempt_id)s',
'VerifiedName: platform_verification_delete_handler triggering Celery task for '
'platform_verification_attempt_id=%(platform_verification_attempt_id)s',
{
'idv_attempt_id': idv_attempt_id,
'platform_verification_attempt_id': platform_verification_attempt_id,
}
)
delete_verified_name_task.delay(idv_attempt_id, None)
delete_verified_name_task.delay(platform_verification_attempt_id, None)


def proctoring_attempt_handler(
Expand Down
16 changes: 0 additions & 16 deletions edx_name_affirmation/statuses.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,6 @@ class VerifiedNameStatus(str, Enum):
APPROVED = "approved"
DENIED = "denied"

@classmethod
def trigger_state_change_from_idv(cls, idv_status):
"""
Return the translated IDV status if it should trigger a state transition, otherwise return None
"""
# mapping from an idv status (key) to it's associated verified name status (value). We only want to
# include idv statuses that would cause a status transition for a verified name
idv_state_transition_mapping = {
'created': cls.PENDING,
'submitted': cls.SUBMITTED,
'approved': cls.APPROVED,
'denied': cls.DENIED
}

return idv_state_transition_mapping.get(idv_status, None)

@classmethod
def trigger_state_change_from_proctoring(cls, proctoring_status):
"""
Expand Down
44 changes: 25 additions & 19 deletions edx_name_affirmation/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,58 +43,64 @@ def idv_update_verified_name_task(self, attempt_id, user_id, name_affirmation_st
# want to grab all verified names for the same user and name combination, because
# some of those records may already be associated with a different IDV attempt.
verified_names = VerifiedName.objects.filter(
(Q(verification_attempt_id=attempt_id) | Q(verification_attempt_id__isnull=True))
(Q(platform_verification_attempt_id=attempt_id) | Q(platform_verification_attempt_id__isnull=True))
& Q(user__id=user_id)
& Q(verified_name=photo_id_name)
).order_by('-created')
verified_names_updated = False
if verified_names:
# if there are VerifiedName objects, we want to update existing entries
# for each attempt with no attempt id (either proctoring or idv), update attempt id
updated_for_attempt_id = verified_names.filter(
proctored_exam_attempt_id=None,
verification_attempt_id=None
).update(verification_attempt_id=attempt_id)
verification_attempt_id=None,
platform_verification_attempt_id=None
).update(platform_verification_attempt_id=attempt_id)

if updated_for_attempt_id:
verified_names_updated = True
log.info(
'Updated VerifiedNames for user={user_id} to verification_attempt_id={attempt_id}'.format(
'Updated VerifiedNames for user={user_id} to platform_verification_attempt_id={attempt_id}'.format(
user_id=user_id,
attempt_id=attempt_id,
)
)

# then for all matching attempt ids, update the status
verified_name_qs = verified_names.filter(
verification_attempt_id=attempt_id,
platform_verification_attempt_id=attempt_id,
verification_attempt_id=None,
proctored_exam_attempt_id=None
)

# Individually update to ensure that post_save signals send
for verified_name_obj in verified_name_qs:
verified_name_obj.status = name_affirmation_status
verified_name_obj.save()
verified_names_updated = True

log.info(
'Updated VerifiedNames for user={user_id} with verification_attempt_id={attempt_id} to '
'Updated VerifiedNames for user={user_id} with platform_verification_attempt_id={attempt_id} to '
'have status={status}'.format(
user_id=user_id,
attempt_id=attempt_id,
status=name_affirmation_status
)
)
else:
# otherwise if there are no entries, we want to create one.

# if there are no entries to update, we want to create one.
if not verified_names_updated:
user = User.objects.get(id=user_id)
verified_name = VerifiedName.objects.create(
user=user,
verified_name=photo_id_name,
profile_name=full_name,
verification_attempt_id=attempt_id,
platform_verification_attempt_id=attempt_id,
status=name_affirmation_status,
)
log.error(
'Created VerifiedName for user={user_id} to have status={status} '
'and verification_attempt_id={attempt_id}, because no matching '
'and platform_verification_attempt_id={attempt_id}, because no matching '
'attempt_id or verified_name were found.'.format(
user_id=user_id,
attempt_id=attempt_id,
Expand Down Expand Up @@ -187,23 +193,23 @@ def proctoring_update_verified_name_task(
bind=True, autoretry_for=(Exception,), default_retry_delay=DEFAULT_RETRY_SECONDS, max_retries=MAX_RETRIES,
)
@set_code_owner_attribute
def delete_verified_name_task(self, idv_attempt_id, proctoring_attempt_id):
def delete_verified_name_task(self, platform_verification_attempt_id, proctoring_attempt_id):
"""
Celery task to delete a verified name based on an idv or proctoring attempt
"""
# this case shouldn't happen, but should log as an error in case
if (idv_attempt_id and proctoring_attempt_id) or (not idv_attempt_id and not proctoring_attempt_id):
if (proctoring_attempt_id, platform_verification_attempt_id).count(None) != 1:
log.error(
'A maximum of one attempt id should be provided for either a proctored exam attempt or IDV attempt.'
'A maximum of one attempt id should be provided'
)
return

log_message = {'field_name': '', 'attempt_id': ''}

if idv_attempt_id:
verified_names = VerifiedName.objects.filter(verification_attempt_id=idv_attempt_id)
log_message['field_name'] = 'verification_attempt_id'
log_message['attempt_id'] = idv_attempt_id
if platform_verification_attempt_id:
verified_names = VerifiedName.objects.filter(platform_verification_attempt_id=platform_verification_attempt_id)
log_message['field_name'] = 'platform_verification_attempt_id'
log_message['attempt_id'] = platform_verification_attempt_id
else:
verified_names = VerifiedName.objects.filter(proctored_exam_attempt_id=proctoring_attempt_id)
log_message['field_name'] = 'proctored_exam_attempt_id'
Expand All @@ -212,10 +218,10 @@ def delete_verified_name_task(self, idv_attempt_id, proctoring_attempt_id):
if verified_names:
log.info(
'Deleting {num_names} VerifiedName(s) associated with {field_name}='
'{verification_attempt_id}'.format(
'{platform_verification_attempt_id}'.format(
num_names=len(verified_names),
field_name=log_message['field_name'],
verification_attempt_id=log_message['attempt_id'],
platform_verification_attempt_id=log_message['attempt_id'],
)
)
verified_names.delete()
Expand Down
Loading

0 comments on commit cef4654

Please sign in to comment.