From 851d5d37f6b02a7e409d4045e8948fc6f98cc15c Mon Sep 17 00:00:00 2001 From: KlemenSpruk Date: Mon, 6 Nov 2023 08:13:32 +0100 Subject: [PATCH] #653 Bulk SMS - enable email if user has no phone number (#165) * send email if user has no phone number * tox fix * removed debugging code * tox migration fix * black migration * black migration --- django_project_base/constants.py | 2 + django_project_base/licensing/logic.py | 2 +- .../notifications/base/channels/channel.py | 63 +++++++++++++++++-- .../notifications/base/notification.py | 57 ++++++++++++----- .../migrations/0007_auto_20231026_0555.py | 35 +++++++++++ ...8_deliveryreport_auxiliary_notification.py | 17 +++++ django_project_base/notifications/models.py | 10 +++ .../tests/api/test_create_mail.py | 4 +- .../notifications_transaction_test_case.py | 11 ++++ .../0009_projectsettings_pending_value.py | 2 - ...rojectsettings_action_required_and_more.py | 22 +++++++ 11 files changed, 201 insertions(+), 24 deletions(-) create mode 100644 django_project_base/notifications/migrations/0007_auto_20231026_0555.py create mode 100644 django_project_base/notifications/migrations/0008_deliveryreport_auxiliary_notification.py create mode 100644 example/demo_django_base/migrations/0010_alter_projectsettings_action_required_and_more.py diff --git a/django_project_base/constants.py b/django_project_base/constants.py index 6d5bef02..4cd8283b 100644 --- a/django_project_base/constants.py +++ b/django_project_base/constants.py @@ -10,3 +10,5 @@ INVITE_NOTIFICATION_TEXT = "invite-notification-link-text" NOTIFY_NEW_USER_SETTING_NAME = "notify-new-user-via-email-account-created" + +USE_EMAIL_IF_RECIPIENT_HAS_NO_PHONE_NUBER = "notify-user-via-email-if-no-phone-number" diff --git a/django_project_base/licensing/logic.py b/django_project_base/licensing/logic.py index ef4c3a7d..3b1c0039 100644 --- a/django_project_base/licensing/logic.py +++ b/django_project_base/licensing/logic.py @@ -97,7 +97,7 @@ def log( for used_channel in list(set(list(items.keys()))): used += chl_prices.get(used_channel, 0) * items.get(used_channel, 0) - if not kwargs.get("is_system_notification") and used >= 0: # janez medja + if not kwargs.get("is_system_notification") and used >= 0: raise PermissionDenied(gettext("Your license is consumed. Please contact support.")) if on_sucess: diff --git a/django_project_base/notifications/base/channels/channel.py b/django_project_base/notifications/base/channels/channel.py index 6dc232d8..dbf76384 100644 --- a/django_project_base/notifications/base/channels/channel.py +++ b/django_project_base/notifications/base/channels/channel.py @@ -1,3 +1,4 @@ +import datetime import logging import uuid from abc import ABC, abstractmethod @@ -9,7 +10,11 @@ from django_project_base.notifications.base.channels.integrations.provider_integration import ProviderIntegration from django_project_base.notifications.base.phone_number_parser import PhoneNumberParser -from django_project_base.notifications.models import DeliveryReport, DjangoProjectBaseNotification +from django_project_base.notifications.models import ( + DeliveryReport, + DjangoProjectBaseMessage, + DjangoProjectBaseNotification, +) from django_project_base.utils import get_pk_name @@ -88,14 +93,21 @@ def clean_recipients(self, recipients: List[Recipient]) -> List[Recipient]: return list(set(recipients)) def create_delivery_report( - self, notification: DjangoProjectBaseNotification, recipient: Recipient, pk: str + self, + notification: DjangoProjectBaseNotification, + recipient: Recipient, + pk: str, + channel: Optional[str] = None, + provider: Optional[str] = None, + auxiliary_notification: Optional[uuid.UUID] = None, ) -> DeliveryReport: return DeliveryReport.objects.create( notification=notification, user_id=recipient.identifier, - channel=f"{self.__module__}.{self.__class__.__name__}", - provider=f"{self.provider.__module__}.{self.provider.__class__.__name__}", + channel=f"{self.__module__}.{self.__class__.__name__}" if not channel else channel, + provider=f"{self.provider.__module__}.{self.provider.__class__.__name__}" if not provider else provider, pk=pk, + auxiliary_notification=auxiliary_notification, ) @abstractmethod @@ -151,9 +163,52 @@ def make_send(notification_obj, rec_obj, message_str, dlr_pk) -> Optional[Delive logger.exception(de) return dlr_obj + from django_project_base.notifications.base.channels.mail_channel import MailChannel + + mail_fallback = ( + MailChannel.name not in (notification.required_channels or "").split(",") + and notification.email_fallback + ) + + from django_project_base.notifications.email_notification import EMailNotification + for recipient in recipients: # noqa: E203 dlr__uuid = str(uuid.uuid4()) try: + if ( + self.provider.is_sms_provider + and not recipient.phone_number + and mail_fallback + and not notification.send_notification_sms + ): + try: + a_notification = EMailNotification( + message=DjangoProjectBaseMessage( + subject=notification.message.subject, + body=notification.message.body, + footer=notification.message.footer, + content_type=notification.message.content_type, + ), + raw_recipents=[ + recipient.identifier, + ], + project=notification.project_slug if notification.project_slug else None, + recipients=[ + recipient.identifier, + ], + a_sender=notification.sender, + a_extra_data=extra_data, + a_recipients_list=notification.recipients_list, + delay=int(datetime.datetime.now().timestamp()), + ).send() + self.create_delivery_report( + notification, recipient, dlr__uuid, auxiliary_notification=a_notification.pk + ) + continue + except Exception as e: + logger.exception(e) + continue + while dlr := not make_send( notification_obj=notification, message_str=message, rec_obj=recipient, dlr_pk=dlr__uuid ): diff --git a/django_project_base/notifications/base/notification.py b/django_project_base/notifications/base/notification.py index 842f25d2..bcfe022a 100644 --- a/django_project_base/notifications/base/notification.py +++ b/django_project_base/notifications/base/notification.py @@ -7,7 +7,11 @@ from django.conf import settings from django.contrib.auth import get_user_model -from django_project_base.constants import EMAIL_SENDER_ID_SETTING_NAME, SMS_SENDER_ID_SETTING_NAME +from django_project_base.constants import ( + EMAIL_SENDER_ID_SETTING_NAME, + SMS_SENDER_ID_SETTING_NAME, + USE_EMAIL_IF_RECIPIENT_HAS_NO_PHONE_NUBER, +) from django_project_base.notifications.base.channels.channel import Channel from django_project_base.notifications.base.duplicate_notification_mixin import DuplicateNotificationMixin from django_project_base.notifications.base.enums import ChannelIdentifier, NotificationLevel, NotificationType @@ -94,6 +98,14 @@ def resend(notification: DjangoProjectBaseNotification, user_pk: str): notification.recipients = ",".join(map(str, recipients)) if recipients else None notification.recipients_original_payload_search = None notification.sender = Notification._get_sender_config(notification.project_slug) + mail_fallback: bool = ( + swapper.load_model("django_project_base", "ProjectSettings") + .objects.get(name=USE_EMAIL_IF_RECIPIENT_HAS_NO_PHONE_NUBER, project__slug=notification.project_slug) + .python_value + if notification.project_slug + else False + ) + notification.email_fallback = mail_fallback notification.save(update_fields=["recipients", "recipients_original_payload_search"]) SendNotificationMixin().make_send(notification, {}) @@ -176,17 +188,19 @@ def send(self) -> DjangoProjectBaseNotification: if not self.persist: raise Exception("Delayed notification must be persisted") self._set_db() - rec_list = [] - for usr in self._recipients: - rec_list.append( - { - k: v - for k, v in get_user_model().objects.get(pk=usr).userprofile.__dict__.items() - if not k.startswith("_") - } - ) + + rec_list = self._extra_data.get("a_recipients_list") or [] + if len(rec_list) == 0: + for usr in self._recipients: + rec_list.append( + { + k: v + for k, v in get_user_model().objects.get(pk=usr).userprofile.__dict__.items() + if not k.startswith("_") + } + ) notification.recipients_list = rec_list - self.enqueue_notification(notification, self._extra_data) + self.enqueue_notification(notification, self._extra_data.get("a_extra_data") or self._extra_data) return notification notification = self.make_send(notification, self._extra_data) @@ -210,23 +224,36 @@ def _ensure_channels( ) -> DjangoProjectBaseNotification: from django_project_base.notifications.base.channels.mail_channel import MailChannel + extra_data = self._extra_data.get("a_extra_data") or self._extra_data + for channel_name in channels: # ensure dlr user and check providers - channel = ChannelIdentifier.channel(channel_name, extra_data=self._extra_data, project_slug=self._project) + channel = ChannelIdentifier.channel(channel_name, extra_data=extra_data, project_slug=self._project) - if not channel and self._extra_data.get("is_system_notification"): + if not channel and extra_data.get("is_system_notification"): continue assert channel if self.send_notification_sms and channel.name == MailChannel.name: notification.send_notification_sms_text = channel.provider.get_send_notification_sms_text( - notification=notification, host_url=self._extra_data.get("host_url", "") # noqa: E126 + notification=notification, host_url=extra_data.get("host_url", "") # noqa: E126 ) notification.user = self._user - notification.sender = Notification._get_sender_config(self._project) + notification.sender = self._extra_data.get("a_sender") or Notification._get_sender_config(self._project) + + mail_fallback = False + if not self._extra_data.get("a_sender"): + mail_fallback: bool = ( + swapper.load_model("django_project_base", "ProjectSettings") + .objects.get(name=USE_EMAIL_IF_RECIPIENT_HAS_NO_PHONE_NUBER, project__slug=notification.project_slug) + .python_value + if notification.project_slug + else False + ) + notification.email_fallback = mail_fallback if self._extra_data.get("is_system_notification"): notification.sender[MailChannel.name] = getattr(settings, "SYSTEM_EMAIL_SENDER_ID", "") diff --git a/django_project_base/notifications/migrations/0007_auto_20231026_0555.py b/django_project_base/notifications/migrations/0007_auto_20231026_0555.py new file mode 100644 index 00000000..ae6e297d --- /dev/null +++ b/django_project_base/notifications/migrations/0007_auto_20231026_0555.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.4 on 2023-10-26 05:55 +from gettext import gettext + +import swapper +from django.db import migrations + +from django_project_base.constants import USE_EMAIL_IF_RECIPIENT_HAS_NO_PHONE_NUBER + + +def forwards_func(apps, schema_editor): + project_sett = swapper.load_model("django_project_base", "ProjectSettings") + for project in swapper.load_model("django_project_base", "Project").objects.all(): + project_sett.objects.get_or_create( + project=project, + name=USE_EMAIL_IF_RECIPIENT_HAS_NO_PHONE_NUBER, + defaults=dict( + description=gettext("Send notification via EMail if user has no phone number"), + value=False, + value_type="bool", + ), + ) + + +def reverse_func(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("notifications", "0006_djangoprojectbasenotification_send_notification_sms"), + ] + + operations = [ + migrations.RunPython(forwards_func, reverse_func), + ] diff --git a/django_project_base/notifications/migrations/0008_deliveryreport_auxiliary_notification.py b/django_project_base/notifications/migrations/0008_deliveryreport_auxiliary_notification.py new file mode 100644 index 00000000..abdc5d1b --- /dev/null +++ b/django_project_base/notifications/migrations/0008_deliveryreport_auxiliary_notification.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.4 on 2023-10-26 06:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("notifications", "0007_auto_20231026_0555"), + ] + + operations = [ + migrations.AddField( + model_name="deliveryreport", + name="auxiliary_notification", + field=models.UUIDField(null=True, verbose_name="Auxiliary notification"), + ), + ] diff --git a/django_project_base/notifications/models.py b/django_project_base/notifications/models.py index 9717625e..fb3ff655 100644 --- a/django_project_base/notifications/models.py +++ b/django_project_base/notifications/models.py @@ -92,6 +92,7 @@ class DjangoProjectBaseNotification(AbstractDjangoProjectBaseNotification): _user = None _sender = {} _email_list = [] + _email_fallback = False def _get_recipients(self): return self._recipients_list @@ -117,6 +118,12 @@ def _get_email_list(self): def _set_email_list(self, val): self._email_list = val + def _get_email_fallback(self): + return self._email_fallback + + def _set_email_fallback(self, val): + self._email_fallback = val + recipients_list = property(_get_recipients, _set_recipents) user = property(_get_user, _set_user) @@ -125,6 +132,8 @@ def _set_email_list(self, val): email_list = property(_get_email_list, _set_email_list) + email_fallback = property(_get_email_fallback, _set_email_fallback) + class SearchItemObject: label = "" @@ -245,3 +254,4 @@ class Status(IntDescribedEnum): status = models.IntegerField( default=Status.PENDING_DELIVERY, choices=Status.get_choices_tuple(), db_index=True, null=False ) + auxiliary_notification = models.UUIDField(verbose_name=_("Auxiliary notification"), null=True, blank=False) diff --git a/django_project_base/notifications/tests/api/test_create_mail.py b/django_project_base/notifications/tests/api/test_create_mail.py index 429f81ff..4b1704c1 100644 --- a/django_project_base/notifications/tests/api/test_create_mail.py +++ b/django_project_base/notifications/tests/api/test_create_mail.py @@ -19,7 +19,7 @@ def test_send_mail(self): raw_recipents=[ self.test_user.pk, ], - project=swapper.load_model("django_project_base", "Project").objects.first(), + project=swapper.load_model("django_project_base", "Project").objects.first().slug, recipients=[ self.test_user.pk, ], @@ -32,7 +32,7 @@ def test_send_mail(self): raw_recipents=[ self.test_user.pk, ], - project=swapper.load_model("django_project_base", "Project").objects.first(), + project=swapper.load_model("django_project_base", "Project").objects.first().slug, recipients=[ self.test_user.pk, ], diff --git a/django_project_base/notifications/tests/notifications_transaction_test_case.py b/django_project_base/notifications/tests/notifications_transaction_test_case.py index 9ae24746..371a89bc 100644 --- a/django_project_base/notifications/tests/notifications_transaction_test_case.py +++ b/django_project_base/notifications/tests/notifications_transaction_test_case.py @@ -7,6 +7,7 @@ from rest_framework.authtoken.models import Token from rest_framework.test import APIClient +from django_project_base.constants import USE_EMAIL_IF_RECIPIENT_HAS_NO_PHONE_NUBER from django_project_base.notifications.base.channels.channel import Channel from django_project_base.notifications.base.channels.mail_channel import MailChannel from django_project_base.notifications.base.notification import Notification @@ -33,6 +34,16 @@ def setUp(self) -> None: name="test", slug="test", owner=self.test_user.userprofile ) self.api_client = APIClient() + for p in swapper.load_model("django_project_base", "Project").objects.all(): + swapper.load_model("django_project_base", "ProjectSettings").objects.get_or_create( + name=USE_EMAIL_IF_RECIPIENT_HAS_NO_PHONE_NUBER, + project=p, + defaults=dict( + value=False, + value_type="char", + description="Tests value", + ), + ) def _login_to_api_client_with_test_user(self): user_token, token_created = Token.objects.get_or_create(user=self.test_user) diff --git a/example/demo_django_base/migrations/0009_projectsettings_pending_value.py b/example/demo_django_base/migrations/0009_projectsettings_pending_value.py index 8f700ba0..f1703ced 100644 --- a/example/demo_django_base/migrations/0009_projectsettings_pending_value.py +++ b/example/demo_django_base/migrations/0009_projectsettings_pending_value.py @@ -1,5 +1,3 @@ -# Generated by Django 4.2.4 on 2023-10-10 06:50 - from django.db import migrations, models diff --git a/example/demo_django_base/migrations/0010_alter_projectsettings_action_required_and_more.py b/example/demo_django_base/migrations/0010_alter_projectsettings_action_required_and_more.py new file mode 100644 index 00000000..e3fbbd6a --- /dev/null +++ b/example/demo_django_base/migrations/0010_alter_projectsettings_action_required_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.4 on 2023-10-26 06:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("demo_django_base", "0009_projectsettings_pending_value"), + ] + + operations = [ + migrations.AlterField( + model_name="projectsettings", + name="action_required", + field=models.BooleanField(blank=True, default=False, null=True), + ), + migrations.AlterField( + model_name="projectsettings", + name="pending_value", + field=models.CharField(blank=True, max_length=320, null=True, verbose_name="Pending value"), + ), + ]