Skip to content

Commit

Permalink
#895 Va iir combine permission lookup (#1998)
Browse files Browse the repository at this point in the history
Co-authored-by: Steve Long <2211897+stevelong00@users.noreply.github.com>
Co-authored-by: Kyle MacMillan <kyle.w.macmillan@gmail.com>
Co-authored-by: Kyle MacMillan <16893311+k-macmillan@users.noreply.github.com>
  • Loading branch information
4 people authored Sep 20, 2024
1 parent d3841fa commit 2bac605
Show file tree
Hide file tree
Showing 24 changed files with 2,455 additions and 115 deletions.
33 changes: 21 additions & 12 deletions .talismanrc
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
fileignoreconfig:
- filename: README.md
checksum: b2cbbb8508af49abccae8b35b317f7ce09215f3508430ed31440add78f450e5a
- filename: app/notifications/process_notifications.py
checksum: ae4e31c6eb56d91ec80ae09d13baf4558cf461c65f08893b93fee43f036a17a7
- filename: lambda_functions/pinpoint_callback/pinpoint_callback_lambda.py
checksum: 7bd4900e14b1fa789bbb2568b8a8d7a400e3c8350ba32fb44cc0b5b66a2df037
- filename: lambda_functions/ses_callback/ses_callback_lambda.py
checksum: b20c36921290a9609f158784e2a3278c36190887e6054ea548004a67675fd79b
- filename: poetry.lock
checksum: 34d12acdf749363555c31add4e7e7afa9e2a27afd792bd98c85f331b87bd7112
- filename: scripts/trigger_task.py
checksum: 0e9d244dbe285de23fc84bb643407963dacf7d25a3358373f01f6272fb217778
- filename: README.md
checksum: b2cbbb8508af49abccae8b35b317f7ce09215f3508430ed31440add78f450e5a
- filename: app/notifications/process_notifications.py
checksum: ae4e31c6eb56d91ec80ae09d13baf4558cf461c65f08893b93fee43f036a17a7
- filename: tests/app/notifications/test_process_notifications_for_profile_v3.py
checksum: 4e15e63d349635131173ffdd7aebcd547621db08de877ef926d3a41fde72d065
- filename: poetry.lock
checksum: 34d12acdf749363555c31add4e7e7afa9e2a27afd792bd98c85f331b87bd7112
- filename: app/template/rest.py
checksum: 1e5bdac8bc694d50f8f656dec127dd036b7b1b5b6156e3282d3411956c71ba0b
- filename: app/service/rest.py
checksum: b42aefd1ae0e6ea76e75db4cf14d425facd0941943b17f7ba2e41f850ad1ec23
- filename: app/celery/contact_information_tasks.py
checksum: 80d0acf88bafb1358583016b9e143f4523ef1160d6eacdc9754ca68859b90eae
- filename: lambda_functions/pinpoint_callback/pinpoint_callback_lambda.py
checksum: 7bd4900e14b1fa789bbb2568b8a8d7a400e3c8350ba32fb44cc0b5b66a2df037
- filename: lambda_functions/ses_callback/ses_callback_lambda.py
checksum: b20c36921290a9609f158784e2a3278c36190887e6054ea548004a67675fd79b
- filename: scripts/trigger_task.py
checksum: 0e9d244dbe285de23fc84bb643407963dacf7d25a3358373f01f6272fb217778

version: "1.0"
218 changes: 190 additions & 28 deletions app/celery/contact_information_tasks.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
from celery import Task
from flask import current_app
from notifications_utils.statsd_decorators import statsd
from requests import Timeout


from app import notify_celery, va_profile_client
from app.celery.common import can_retry, handle_max_retries_exceeded
from app.celery.exceptions import AutoRetryException
from app.celery.service_callback_tasks import check_and_queue_callback_task
from app.va.identifier import IdentifierType
from app.va.va_profile import VAProfileRetryableException, VAProfileNonRetryableException, NoContactInfoException
from app.dao.notifications_dao import get_notification_by_id, dao_update_notification, update_notification_status_by_id
from app.models import NOTIFICATION_PERMANENT_FAILURE, EMAIL_TYPE, SMS_TYPE
from app.dao.notifications_dao import (
get_notification_by_id,
dao_update_notification,
update_notification_status_by_id,
)
from app.exceptions import NotificationTechnicalFailureException, NotificationPermanentFailureException
from app.va.va_profile.exceptions import VAProfileIDNotFoundException
from notifications_utils.statsd_decorators import statsd
from requests import Timeout
from app.feature_flags import FeatureFlag, is_feature_enabled
from app.models import (
NOTIFICATION_PERMANENT_FAILURE,
NOTIFICATION_PREFERENCES_DECLINED,
EMAIL_TYPE,
SMS_TYPE,
Notification,
RecipientIdentifier,
)
from app.va.identifier import IdentifierType
from app.va.va_profile import (
VAProfileRetryableException,
VAProfileNonRetryableException,
NoContactInfoException,
VAProfileResult,
)
from app.va.va_profile.exceptions import VAProfileIDNotFoundException, CommunicationItemNotFoundException


@notify_celery.task(
Expand All @@ -24,55 +44,197 @@
)
@statsd(namespace='tasks')
def lookup_contact_info(
self,
notification_id,
self: Task,
notification_id: str,
):
"""
Celery task to look up contact information (email/phone number) for a given notification.
If the feature flag, VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, is enabled,
also check for related communication permissions.
Args:
self (Task): The Celery task instance.
notification_id (str): The ID of the notification for which to look up contact information.
Raises:
AutoRetryException: If a retryable exception occurs during the lookup process.
NotificationTechnicalFailureException: If the maximum retries have been exceeded.
NotificationPermanentFailureException: If the exception indicates a permanent failure.
Exception: If an unhandled exception occurs.
Returns:
None
"""
current_app.logger.info('Looking up contact information for notification_id: %s.', notification_id)

notification = get_notification_by_id(notification_id)
recipient_identifier = notification.recipient_identifiers[IdentifierType.VA_PROFILE_ID.value]
should_send = notification.default_send

try:
if EMAIL_TYPE == notification.notification_type:
recipient = va_profile_client.get_email(recipient_identifier)
elif SMS_TYPE == notification.notification_type:
recipient = va_profile_client.get_telephone(recipient_identifier)
if is_feature_enabled(FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP):
result = get_profile_result(notification, recipient_identifier)
recipient = result.recipient
should_send = result.communication_allowed
permission_message = result.permission_message
else:
raise NotImplementedError(
f'The task lookup_contact_info failed for notification {notification_id}. '
f'{notification.notification_type} is not supported'
recipient = get_recipient(
notification.notification_type,
notification_id,
recipient_identifier,
)
except (Timeout, VAProfileRetryableException) as e:
if can_retry(self.request.retries, self.max_retries, notification_id):
current_app.logger.warning('Unable to get contact info for notification id: %s, retrying', notification_id)
except Exception as e:
handle_lookup_contact_info_exception(self, notification, recipient_identifier, e)

notification.to = recipient
dao_update_notification(notification)

if not should_send:
handle_communication_not_allowed(notification, recipient_identifier, permission_message)


def get_recipient(
notification_type: str,
notification_id: str,
recipient_identifier: RecipientIdentifier,
) -> str:
"""
Retrieve the recipient email or phone number.
Args:
notification_type (str): The type of recipient info requested.
notification_id (str): The notification ID associated with this request.
recipient_identifier (RecipientIdentifier): The VA profile ID to retrieve the profile for.
Returns:
str: The recipient email or phone number.
"""
if notification_type == EMAIL_TYPE:
return va_profile_client.get_email(recipient_identifier)
elif notification_type == SMS_TYPE:
return va_profile_client.get_telephone(recipient_identifier)
else:
raise NotImplementedError(
f'The task lookup_contact_info failed for notification {notification_id}. '
f'{notification_type} is not supported'
)


def get_profile_result(
notification: Notification,
recipient_identifier: RecipientIdentifier,
) -> VAProfileResult:
"""
Retrieve the result of looking up contact info from VA Profile.
Args:
notification (Notification): The Notification object to get contact info and permissions for.
recipient_identifier (RecipientIdentifier): The VA profile ID to retrieve the profile for.
Returns:
VAProfileResult: The contact info result from VA Profile.
"""
if notification.notification_type == EMAIL_TYPE:
return va_profile_client.get_email_with_permission(recipient_identifier, notification)
elif notification.notification_type == SMS_TYPE:
return va_profile_client.get_telephone_with_permission(recipient_identifier, notification)
else:
raise NotImplementedError(
f'The task lookup_contact_info failed for notification {notification.id}. '
f'{notification.notification_type} is not supported'
)


def handle_lookup_contact_info_exception(
lookup_task: Task, notification: Notification, recipient_identifier: RecipientIdentifier, e: Exception
):
"""
Handles exceptions that occur during the lookup of contact information.
Args:
lookup_task (Task): The task object that is performing the lookup.
notification (Notification): The notification object associated with the lookup.
recipient_identifier (RecipientIdentifier): The identifier of the recipient.
e (Exception): The exception that was raised during the lookup.
Raises:
AutoRetryException: If the exception is retryable and the task can be retried.
NotificationTechnicalFailureException: If the maximum retries have been exceeded.
NotificationPermanentFailureException: If the exception indicates a permanent failure.
Exception: If an unhandled exception occurs.
Returns:
str or None: A message indicating the result of the exception handling, or None if no action is needed.
"""
if isinstance(e, (Timeout, VAProfileRetryableException)):
if can_retry(lookup_task.request.retries, lookup_task.max_retries, notification.id):
current_app.logger.warning('Unable to get contact info for notification id: %s, retrying', notification.id)
raise AutoRetryException(f'Found {type(e).__name__}, autoretrying...', e, e.args)
else:
msg = handle_max_retries_exceeded(notification_id, 'lookup_contact_info')
msg = handle_max_retries_exceeded(notification.id, 'lookup_contact_info')
check_and_queue_callback_task(notification)
raise NotificationTechnicalFailureException(msg)
except NoContactInfoException as e:
elif isinstance(e, NoContactInfoException):
message = (
f"Can't proceed after querying VA Profile for contact information for {notification_id}. "
f"Can't proceed after querying VA Profile for contact information for {notification.id}. "
'Stopping execution of following tasks. Notification has been updated to permanent-failure.'
)
current_app.logger.warning('%s - %s: %s', e.__class__.__name__, str(e), message)

update_notification_status_by_id(
notification_id, NOTIFICATION_PERMANENT_FAILURE, status_reason=e.failure_reason
notification.id, NOTIFICATION_PERMANENT_FAILURE, status_reason=e.failure_reason
)
check_and_queue_callback_task(notification)
raise NotificationPermanentFailureException(message) from e
except (VAProfileIDNotFoundException, VAProfileNonRetryableException) as e:
elif isinstance(e, (VAProfileIDNotFoundException, VAProfileNonRetryableException)):
current_app.logger.exception(e)
message = (
f'The task lookup_contact_info failed for notification {notification_id}. '
f'The task lookup_contact_info failed for notification {notification.id}. '
'Notification has been updated to permanent-failure'
)
update_notification_status_by_id(
notification_id, NOTIFICATION_PERMANENT_FAILURE, status_reason=e.failure_reason
notification.id, NOTIFICATION_PERMANENT_FAILURE, status_reason=e.failure_reason
)
check_and_queue_callback_task(notification)
raise NotificationPermanentFailureException(message) from e
elif isinstance(e, CommunicationItemNotFoundException):
current_app.logger.info(
'Communication item for recipient %s not found on notification %s',
recipient_identifier.id_value,
notification.id,
)

notification.to = recipient
dao_update_notification(notification)
return None if notification.default_send else 'No recipient opt-in found for explicit preference'
else:
current_app.logger.exception(f'Unhandled exception for notification {notification.id}: {e}')
raise e


def handle_communication_not_allowed(
notification: Notification, recipient_identifier: RecipientIdentifier, permission_message: str
):
"""
Handles the scenario where communication is not allowed for a given notification.
Args:
notification (Notification): The notification object associated with the communication.
recipient_identifier (RecipientIdentifier): The identifier of the recipient.
permission_message (str): The message indicating the reason for permission denial.
Raises:
NotificationPermanentFailureException: If the recipient has declined permission to receive notifications.
"""
if is_feature_enabled(FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP):
current_app.logger.info(
'Permission denied for recipient %s for notification %s',
recipient_identifier.id_value,
notification.id,
)
reason = permission_message if permission_message is not None else 'Contact preferences set to false'
update_notification_status_by_id(notification.id, NOTIFICATION_PREFERENCES_DECLINED, status_reason=reason)

message = f'The recipient for notification {notification.id} has declined permission to receive notifications.'
current_app.logger.info(message)

check_and_queue_callback_task(notification)
raise NotificationPermanentFailureException(message)
59 changes: 38 additions & 21 deletions app/models.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import datetime
import uuid
import itertools
from typing import Dict, Any
from app import (
DATETIME_FORMAT,
encryption,
)
from app.db import db
from app.encryption import (
check_hash,
hashpw,
)
from app.history_meta import Versioned
from app.model import User, EMAIL_AUTH_TYPE
from app.va.identifier import IdentifierType
from flask import url_for, current_app
import uuid
from typing import Any, Dict, Optional

from flask import current_app, url_for

from sqlalchemy import CheckConstraint, Index, UniqueConstraint, and_, select
from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm.collections import InstrumentedList, attribute_mapped_collection

from notifications_utils.columns import Columns
from notifications_utils.letter_timings import get_letter_timings
from notifications_utils.recipients import (
Expand All @@ -30,12 +27,13 @@
SMSMessageTemplate,
)
from notifications_utils.timezones import convert_local_timezone_to_utc, convert_utc_to_local_timezone
from sqlalchemy import and_, CheckConstraint, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm.collections import attribute_mapped_collection, InstrumentedList

from app import DATETIME_FORMAT, encryption
from app.db import db
from app.encryption import check_hash, hashpw
from app.history_meta import Versioned
from app.model import EMAIL_AUTH_TYPE, User
from app.va.identifier import IdentifierType

EMAIL_TYPE = 'email'
LETTER_TYPE = 'letter'
Expand Down Expand Up @@ -1386,6 +1384,25 @@ class Notification(db.Model):
{},
)

@property
def communication_item(self) -> Optional['CommunicationItem']:
if self.template and self.template.communication_item_id:
communication_item = db.session.scalar(
select(CommunicationItem).where(CommunicationItem.id == self.template.communication_item_id)
)
return communication_item

@property
def va_profile_item_id(self):
if self.communication_item:
return self.communication_item.va_profile_item_id

@property
def default_send(self):
if self.communication_item:
return self.communication_item.default_send_indicator
return True

@property
def personalisation(self):
if self._personalisation:
Expand Down
5 changes: 4 additions & 1 deletion app/notifications/process_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,10 @@ def send_to_queue_for_recipient_info_based_on_recipient_identifier(

tasks.append(lookup_contact_info.si(notification.id).set(queue=QueueNames.LOOKUP_CONTACT_INFO))

if communication_item_id:
if (
not is_feature_enabled(FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP)
and communication_item_id
):
tasks.append(
lookup_recipient_communication_permissions.si(notification.id).set(
queue=QueueNames.COMMUNICATION_ITEM_PERMISSIONS
Expand Down
5 changes: 4 additions & 1 deletion app/va/va_profile/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@
VAProfileNonRetryableException,
NoContactInfoException,
)
from .va_profile_client import VAProfileClient # noqa: F401
from .va_profile_client import ( # noqa: F401
VAProfileClient,
VAProfileResult,
)
Loading

0 comments on commit 2bac605

Please sign in to comment.