From 00433229c976ef3641e0e04b19d1673833f859c1 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 13 Nov 2024 22:24:00 -0500 Subject: [PATCH 01/23] add models and rename function --- corehq/apps/sms/api.py | 42 +++++++--- ...er_messagingevent_content_type_and_more.py | 52 ++++++++++++ ...e_message_id_connectmessage_received_on.py | 23 ++++++ corehq/apps/sms/models.py | 80 ++++++++++++++++++- ...ctiduserlink_messaging_consent_and_more.py | 49 ++++++++++++ corehq/apps/users/models.py | 12 ++- ...nt_connectmessagesurveycontent_and_more.py | 55 +++++++++++++ .../messaging/scheduling/models/abstract.py | 21 +++-- corehq/messaging/scheduling/models/content.py | 54 +++++++++++++ migrations.lock | 4 + 10 files changed, 373 insertions(+), 19 deletions(-) create mode 100644 corehq/apps/sms/migrations/0060_alter_messagingevent_content_type_and_more.py create mode 100644 corehq/apps/sms/migrations/0061_connectmessage_message_id_connectmessage_received_on.py create mode 100644 corehq/apps/users/migrations/0075_connectiduserlink_messaging_consent_and_more.py create mode 100644 corehq/messaging/scheduling/migrations/0029_connectmessagecontent_connectmessagesurveycontent_and_more.py diff --git a/corehq/apps/sms/api.py b/corehq/apps/sms/api.py index 3bbae9c65008..64672589bf78 100644 --- a/corehq/apps/sms/api.py +++ b/corehq/apps/sms/api.py @@ -28,6 +28,7 @@ ) from corehq.apps.sms.mixin import BadSMSConfigException from corehq.apps.sms.models import ( + ConnectMessage, INCOMING, OUTGOING, SMS, @@ -131,6 +132,13 @@ def get_sms_class(): return QueuedSMS if settings.SMS_QUEUE_ENABLED else SMS +def get_message_class(phone_number): + if phone_number.is_sms: + return get_sms_class() + else: + return ConnectMessage + + def send_sms(domain, contact, phone_number, text, metadata=None, logged_subevent=None): """ Sends an outbound SMS. Returns false if it fails. @@ -174,7 +182,7 @@ def send_sms(domain, contact, phone_number, text, metadata=None, logged_subevent return queue_outgoing_sms(msg) -def send_sms_to_verified_number(verified_number, text, metadata=None, logged_subevent=None, events=None): +def send_message_to_verified_number(verified_number, text, metadata=None, logged_subevent=None, events=None): """ Sends an sms using the given verified phone number entry. @@ -192,7 +200,7 @@ def send_sms_to_verified_number(verified_number, text, metadata=None, logged_sub return False raise - msg = get_sms_class()( + msg = get_message_class(verified_number)( couch_recipient_doc_type=verified_number.owner_doc_type, couch_recipient=verified_number.owner_id, phone_number="+" + str(verified_number.phone_number), @@ -215,7 +223,10 @@ def send_sms_to_verified_number(verified_number, text, metadata=None, logged_sub msg.custom_metadata[field] = value msg.save() - return queue_outgoing_sms(msg) + if verified_number.is_sms: + return queue_outgoing_sms(msg) + else: + return send_connect_message(msg, backend) def send_sms_with_backend(domain, phone_number, text, backend_id, metadata=None): @@ -386,6 +397,16 @@ def should_log_exception_for_backend(backend, exception): return True +def send_connect_message(message, backend): + try: + backend.send(message) + return True + except Exception: + log_sms_exception(message) + return False + + + def register_sms_user( username, cleaned_phone_number, domain, send_welcome_sms=False, admin_alert_emails=None ): @@ -685,9 +706,9 @@ def get_inbound_phone_entry(phone_number, backend_id=None): ) -def process_incoming(msg): +def process_incoming(msg, phone=None): try: - _process_incoming(msg) + _process_incoming(msg, phone) status = 'ok' except Exception: status = 'error' @@ -715,9 +736,12 @@ def _domain_accepts_inbound(msg): return msg.domain and domain_has_privilege(msg.domain, privileges.INBOUND_SMS) -def _process_incoming(msg): +def _process_incoming(msg, phone=None): sms_load_counter("inbound", msg.domain)() - verified_number, has_domain_two_way_scope = get_inbound_phone_entry_from_sms(msg) + if phone is None: + verified_number, has_domain_two_way_scope = get_inbound_phone_entry_from_sms(msg) + else: + verified_number = phone is_two_way = verified_number is not None and verified_number.is_two_way if verified_number: @@ -746,7 +770,7 @@ def _process_incoming(msg): metadata = MessageMetadata(ignore_opt_out=True) text = get_message(MSG_OPTED_OUT, verified_number, context=(opt_in_keywords[0],)) if verified_number: - send_sms_to_verified_number(verified_number, text, metadata=metadata) + send_message_to_verified_number(verified_number, text, metadata=metadata) elif msg.backend_id: send_sms_with_backend(msg.domain, msg.phone_number, text, msg.backend_id, metadata=metadata) else: @@ -756,7 +780,7 @@ def _process_incoming(msg): if PhoneBlacklist.opt_in_sms(msg.phone_number, domain=domain): text = get_message(MSG_OPTED_IN, verified_number, context=(opt_out_keywords[0],)) if verified_number: - send_sms_to_verified_number(verified_number, text) + send_message_to_verified_number(verified_number, text) elif msg.backend_id: send_sms_with_backend(msg.domain, msg.phone_number, text, msg.backend_id) else: diff --git a/corehq/apps/sms/migrations/0060_alter_messagingevent_content_type_and_more.py b/corehq/apps/sms/migrations/0060_alter_messagingevent_content_type_and_more.py new file mode 100644 index 000000000000..70fc944233ab --- /dev/null +++ b/corehq/apps/sms/migrations/0060_alter_messagingevent_content_type_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.15 on 2024-10-11 13:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sms', '0059_remove_unicel_backend'), + ] + + operations = [ + migrations.AlterField( + model_name='messagingevent', + name='content_type', + field=models.CharField(choices=[('NOP', 'None'), ('SMS', 'SMS Message'), ('CBK', 'SMS Expecting Callback'), ('SVY', 'SMS Survey'), ('IVR', 'IVR Survey'), ('VER', 'Phone Verification'), ('ADH', 'Manually Sent Message'), ('API', 'Message Sent Via API'), ('CHT', 'Message Sent Via Chat'), ('EML', 'Email'), ('FCM', 'FCM Push Notification'), ('CON', 'Connect Message')], max_length=3), + ), + migrations.AlterField( + model_name='messagingsubevent', + name='content_type', + field=models.CharField(choices=[('NOP', 'None'), ('SMS', 'SMS Message'), ('CBK', 'SMS Expecting Callback'), ('SVY', 'SMS Survey'), ('IVR', 'IVR Survey'), ('VER', 'Phone Verification'), ('ADH', 'Manually Sent Message'), ('API', 'Message Sent Via API'), ('CHT', 'Message Sent Via Chat'), ('EML', 'Email'), ('FCM', 'FCM Push Notification'), ('CON', 'Connect Message')], max_length=3), + ), + migrations.CreateModel( + name='ConnectMessage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('domain', models.CharField(db_index=True, max_length=126, null=True)), + ('date', models.DateTimeField(db_index=True, null=True)), + ('couch_recipient_doc_type', models.CharField(db_index=True, max_length=126, null=True)), + ('couch_recipient', models.CharField(db_index=True, max_length=126, null=True)), + ('phone_number', models.CharField(db_index=True, max_length=126, null=True)), + ('direction', models.CharField(max_length=1, null=True)), + ('error', models.BooleanField(default=False, null=True)), + ('system_error_message', models.TextField(null=True)), + ('system_phone_number', models.CharField(max_length=126, null=True)), + ('backend_api', models.CharField(max_length=126, null=True)), + ('backend_id', models.CharField(max_length=126, null=True)), + ('billed', models.BooleanField(default=False, null=True)), + ('workflow', models.CharField(max_length=126, null=True)), + ('xforms_session_couch_id', models.CharField(db_index=True, max_length=126, null=True)), + ('reminder_id', models.CharField(max_length=126, null=True)), + ('location_id', models.CharField(max_length=126, null=True)), + ('date_modified', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('text', models.CharField(max_length=300)), + ('messaging_subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='sms.messagingsubevent')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/corehq/apps/sms/migrations/0061_connectmessage_message_id_connectmessage_received_on.py b/corehq/apps/sms/migrations/0061_connectmessage_message_id_connectmessage_received_on.py new file mode 100644 index 000000000000..48c8f4f705ad --- /dev/null +++ b/corehq/apps/sms/migrations/0061_connectmessage_message_id_connectmessage_received_on.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2024-10-25 11:26 + +from django.db import migrations, models +import uuid + +class Migration(migrations.Migration): + + dependencies = [ + ("sms", "0060_alter_messagingevent_content_type_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="connectmessage", + name="message_id", + field=models.UUIDField(default=uuid.uuid4), + ), + migrations.AddField( + model_name="connectmessage", + name="received_on", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/corehq/apps/sms/models.py b/corehq/apps/sms/models.py index cbdf64ac6a3e..c5e34a46bac2 100644 --- a/corehq/apps/sms/models.py +++ b/corehq/apps/sms/models.py @@ -1,7 +1,9 @@ #!/usr/bin/env python import hashlib +from abc import ABC, abstractmethod from collections import namedtuple from datetime import datetime +from uuid import uuid4 from django.contrib.postgres.fields import ArrayField from django.db import IntegrityError, connection, models, transaction @@ -20,8 +22,9 @@ PhoneNumberInUseException, apply_leniency, ) -from corehq.apps.users.models import CouchUser +from corehq.apps.users.models import ConnectIDUserLink, CouchUser from corehq.form_processor.models import CommCareCase +from corehq.messaging.smsbackends.connectid.backend import ConnectBackend from corehq.util.mixin import UUIDGeneratorMixin from corehq.util.quickcache import quickcache @@ -593,6 +596,62 @@ def opt_out_sms(cls, phone_number, domain=None): return True +class AbstractNumber(ABC): + owner_doc_type = None + owner_id = None + is_two_way = None + phone_number = None + domain = None + + @property + @abstractmethod + def backend(self): + pass + + @property + @abstractmethod + def is_sms(self): + pass + + +class ConnectMessagingNumber(AbstractNumber): + owner_doc_type = "CommCareUser" + is_two_way = True + + def __init__(self, user): + self.user = user + + @property + def phone_number(self): + return self.user_link.channel_id + + @property + def user_link(self): + django_user = self.user.get_django_user() + return ConnectIDUserLink.objects.get(commcare_user=django_user) + + @property + def backend(self): + return ConnectBackend() + + @property + def owner_id(self): + return self.user._id + + @property + def owner(self): + return self.user + + @property + def domain(self): + self.user_link.domain + + @property + def is_sms(self): + return False + + + class PhoneNumber(UUIDGeneratorMixin, models.Model): UUIDS_TO_GENERATE = ['couch_id'] @@ -637,6 +696,10 @@ def __repr__(self): owner=self.owner_id ) + @property + def is_sms(self): + return True + @property def backend(self): from corehq.apps.sms.util import clean_phone_number @@ -980,6 +1043,7 @@ class MessagingEvent(models.Model, MessagingStatusMixin): CONTENT_CHAT_SMS = 'CHT' CONTENT_EMAIL = 'EML' CONTENT_FCM_Notification = 'FCM' + CONTENT_CONNECT = 'CON' CONTENT_CHOICES = ( (CONTENT_NONE, gettext_noop('None')), @@ -993,6 +1057,7 @@ class MessagingEvent(models.Model, MessagingStatusMixin): (CONTENT_CHAT_SMS, gettext_noop('Message Sent Via Chat')), (CONTENT_EMAIL, gettext_noop('Email')), (CONTENT_FCM_Notification, gettext_noop('FCM Push Notification')), + (CONTENT_CONNECT, gettext_noop('Connect Message')), ) CONTENT_TYPE_SLUGS = { @@ -1006,7 +1071,8 @@ class MessagingEvent(models.Model, MessagingStatusMixin): CONTENT_API_SMS: "api-sms", CONTENT_CHAT_SMS: "chat-sms", CONTENT_EMAIL: "email", - CONTENT_FCM_Notification: 'fcm-notification', + CONTENT_FCM_Notification: "fcm-notification", + CONTENT_CONNECT: "connect", } RECIPIENT_CASE = 'CAS' @@ -1319,6 +1385,7 @@ def get_content_info_from_content_object(cls, domain, content): EmailContent, CustomContent, FCMNotificationContent, + ConnectMessageContent ) if isinstance(content, (SMSContent, CustomContent)): @@ -1331,6 +1398,8 @@ def get_content_info_from_content_object(cls, domain, content): return cls.CONTENT_EMAIL, None, None, None elif isinstance(content, FCMNotificationContent): return cls.CONTENT_FCM_Notification, None, None, None + elif isinstance(content, ConnectMessageContent): + return cls.CONTENT_CONNECT, None, None, None else: return cls.CONTENT_NONE, None, None, None @@ -2680,3 +2749,10 @@ class Email(models.Model): subject = models.TextField(null=True) body = models.TextField(null=True) html_body = models.TextField(null=True) + + +class ConnectMessage(Log): + date_modified = models.DateTimeField(null=True, db_index=True, auto_now=True) + text = models.CharField(max_length=300) + received_on = models.DateTimeField(null=True, blank=True) + message_id = models.UUIDField(default=uuid4) diff --git a/corehq/apps/users/migrations/0075_connectiduserlink_messaging_consent_and_more.py b/corehq/apps/users/migrations/0075_connectiduserlink_messaging_consent_and_more.py new file mode 100644 index 000000000000..bc67ea9e458c --- /dev/null +++ b/corehq/apps/users/migrations/0075_connectiduserlink_messaging_consent_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 4.2.15 on 2024-10-18 06:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0074_alter_sqluserdata_profile"), + ] + + operations = [ + migrations.AddField( + model_name="connectiduserlink", + name="channel_id", + field=models.CharField(blank=True, null=True), + ), + migrations.AddField( + model_name="connectiduserlink", + name="messaging_consent", + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name="ConnectIDMessagingKey", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("domain", models.TextField()), + ("key", models.CharField(blank=True, max_length=44, null=True)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("active", models.BooleanField(default=True)), + ( + "connectid_user_link", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="users.connectiduserlink", + ), + ), + ], + ), + ] diff --git a/corehq/apps/users/models.py b/corehq/apps/users/models.py index b5b20203be49..a9c5b6ffea79 100644 --- a/corehq/apps/users/models.py +++ b/corehq/apps/users/models.py @@ -1550,7 +1550,7 @@ def save(self, fire_signals=True, update_django_user=True, fail_hard=False, **pa # test no username conflict by_username = self.get_db().view('users/by_username', key=self.username, reduce=False).first() if by_username and by_username['id'] != self._id: - raise self.Inconsistent("CouchUser with username %s already exists" % self.username) + raise self.Inconsistent("User with username %s already exists" % self.username) if update_django_user and self._rev and not self.to_be_deleted(): django_user = self.sync_to_django_user() @@ -3294,6 +3294,16 @@ class ConnectIDUserLink(models.Model): connectid_username = models.TextField() commcare_user = models.ForeignKey(User, related_name='connectid_user', on_delete=models.CASCADE) domain = models.TextField() + messaging_consent = models.BooleanField(default=False) + channel_id = models.CharField(null=True, blank=True) class Meta: unique_together = ('domain', 'commcare_user') + + +class ConnectIDMessagingKey(models.Model): + domain = models.TextField() + connectid_user_link = models.ForeignKey(ConnectIDUserLink, on_delete=models.CASCADE) + key = models.CharField(max_length=44, null=True, blank=True) + created_on = models.DateTimeField(auto_now_add=True) + active = models.BooleanField(default=True) diff --git a/corehq/messaging/scheduling/migrations/0029_connectmessagecontent_connectmessagesurveycontent_and_more.py b/corehq/messaging/scheduling/migrations/0029_connectmessagecontent_connectmessagesurveycontent_and_more.py new file mode 100644 index 000000000000..4596e7ef86f4 --- /dev/null +++ b/corehq/messaging/scheduling/migrations/0029_connectmessagecontent_connectmessagesurveycontent_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.15 on 2024-10-11 13:12 + +from django.db import migrations, models +import django.db.models.deletion +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('scheduling', '0028_alertschedule_use_user_case_for_filter_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ConnectMessageContent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', jsonfield.fields.JSONField(default=dict)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ConnectMessageSurveyContent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', jsonfield.fields.JSONField(default=dict)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='alertevent', + name='connect_message_content', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='scheduling.connectmessagecontent'), + ), + migrations.AddField( + model_name='casepropertytimedevent', + name='connect_message_content', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='scheduling.connectmessagecontent'), + ), + migrations.AddField( + model_name='randomtimedevent', + name='connect_message_content', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='scheduling.connectmessagecontent'), + ), + migrations.AddField( + model_name='timedevent', + name='connect_message_content', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='scheduling.connectmessagecontent'), + ), + ] diff --git a/corehq/messaging/scheduling/models/abstract.py b/corehq/messaging/scheduling/models/abstract.py index d3b8fe174cf6..98b52d15f5fe 100644 --- a/corehq/messaging/scheduling/models/abstract.py +++ b/corehq/messaging/scheduling/models/abstract.py @@ -7,7 +7,7 @@ from corehq import toggles from corehq.apps.data_interfaces.utils import property_references_parent from corehq.apps.reminders.util import get_one_way_number_for_recipient, get_two_way_number_for_recipient -from corehq.apps.sms.api import MessageMetadata, send_sms, send_sms_to_verified_number +from corehq.apps.sms.api import MessageMetadata, send_sms, send_message_to_verified_number from corehq.apps.sms.forms import ( LANGUAGE_FALLBACK_NONE, LANGUAGE_FALLBACK_SCHEDULE, @@ -163,14 +163,14 @@ def set_extra_scheduling_options(self, options): @property @memoized def memoized_language_set(self): - from corehq.messaging.scheduling.models import SMSContent, EmailContent, SMSCallbackContent + from corehq.messaging.scheduling.models import ConnectMessageContent, SMSContent, EmailContent, SMSCallbackContent result = set() for event in self.memoized_events: content = event.memoized_content - if isinstance(content, (SMSContent, SMSCallbackContent)): + if isinstance(content, (SMSContent, SMSCallbackContent, ConnectMessageContent)): result |= set(content.message) - elif isinstance(content, EmailContent): + elif isinstance(content, (EmailContent)): result |= set(content.subject) result |= set(content.message) @@ -246,6 +246,8 @@ class ContentForeignKeyMixin(models.Model): sms_callback_content = models.ForeignKey('scheduling.SMSCallbackContent', null=True, on_delete=models.CASCADE) fcm_notification_content = models.ForeignKey('scheduling.FCMNotificationContent', null=True, on_delete=models.CASCADE) + connect_message_content = models.ForeignKey('scheduling.ConnectMessageContent', null=True, + on_delete=models.CASCADE) class Meta(object): abstract = True @@ -266,6 +268,8 @@ def content(self): return self.sms_callback_content elif self.fcm_notification_content: return self.fcm_notification_content + elif self.connect_message_content: + return self.connect_message_content raise NoAvailableContent() @@ -280,8 +284,8 @@ def memoized_content(self): @content.setter def content(self, value): - from corehq.messaging.scheduling.models import (SMSContent, EmailContent, - SMSSurveyContent, IVRSurveyContent, CustomContent, SMSCallbackContent, FCMNotificationContent) + from corehq.messaging.scheduling.models import (SMSContent, EmailContent, SMSSurveyContent, + IVRSurveyContent, CustomContent, SMSCallbackContent, FCMNotificationContent, ConnectMessageContent) self.sms_content = None self.email_content = None @@ -289,6 +293,7 @@ def content(self, value): self.ivr_survey_content = None self.custom_content = None self.sms_callback_content = None + self.connect_message_content = None if isinstance(value, SMSContent): self.sms_content = value @@ -304,6 +309,8 @@ def content(self, value): self.sms_callback_content = value elif isinstance(value, FCMNotificationContent): self.fcm_notification_content = value + elif isinstance(value, ConnectMessageContent): + self.connect_message_content = value else: raise UnknownContentType() @@ -511,7 +518,7 @@ def send_sms_message(self, domain, recipient, phone_entry_or_number, message, lo metadata = self.get_sms_message_metadata(logged_subevent) if isinstance(phone_entry_or_number, PhoneNumber): - send_sms_to_verified_number(phone_entry_or_number, message, metadata=metadata, + send_message_to_verified_number(phone_entry_or_number, message, metadata=metadata, logged_subevent=logged_subevent) else: send_sms(domain, recipient, phone_entry_or_number, message, metadata=metadata, diff --git a/corehq/messaging/scheduling/models/content.py b/corehq/messaging/scheduling/models/content.py index 4ddcc5c5aa42..8640d58b1ed2 100644 --- a/corehq/messaging/scheduling/models/content.py +++ b/corehq/messaging/scheduling/models/content.py @@ -27,7 +27,9 @@ from corehq.apps.formplayer_api.smsforms.api import TouchformsError from corehq.apps.hqwebapp.tasks import send_html_email_async, send_mail_async from corehq.apps.reminders.models import EmailUsage +from corehq.apps.sms.api import send_message_to_verified_number from corehq.apps.sms.models import ( + ConnectMessagingNumber, Email, MessagingEvent, PhoneBlacklist, @@ -767,3 +769,55 @@ def get_url(self): return absolute_reverse("download_messaging_image", args=[self.domain, self.blob_id]) DoesNotExist = BlobMeta.DoesNotExist + + +class ConnectMessageContent(Content): + message = old_jsonfield.JSONField(default=dict) + + def create_copy(self): + return ConnectMessageContent( + message=deepcopy(self.message), + ) + + def send(self, recipient, logged_event, phone_entry=None): + domain = logged_event.domain + domain_obj = Domain.get_by_name(domain) + + logged_subevent = logged_event.create_subevent_from_contact_and_content( + recipient, + self, + case_id=None, + ) + message = self.get_translation_from_message_dict( + domain_obj, + self.message, + recipient.get_language_code() + ) + connect_number = ConnectMessagingNumber(recipient) + + send_message_to_verified_number(connect_number, message, logged_subevent=logged_subevent) + +class ConnectMessageSurveyContent(Content): + message = old_jsonfield.JSONField(default=dict) + + def create_copy(self): + return ConnectMessageSurveyContent( + message=deepcopy(self.message), + ) + + def send(self, recipient, logged_event, phone_entry=None): + domain = logged_event.domain + domain_obj = Domain.get_by_name(domain) + + logged_subevent = logged_event.create_subevent_from_contact_and_content( + recipient, + self, + case_id=None, + ) + message = self.get_translation_from_message_dict( + domain_obj, + self.message, + recipient.get_language_code() + ) + + send_connect_message(message) diff --git a/migrations.lock b/migrations.lock index 515762313673..6ec5b2c7d238 100644 --- a/migrations.lock +++ b/migrations.lock @@ -897,6 +897,7 @@ scheduling 0026_add_model_fcm_notification_content 0027_emailcontent_html_message 0028_alertschedule_use_user_case_for_filter_and_more + 0029_connectmessagecontent_connectmessagesurveycontent_and_more scheduling_partitioned 0001_initial 0002_case_schedule_instances @@ -971,6 +972,8 @@ sms 0057_fcm_content_type_messaging_events 0058_email_html_body 0059_remove_unicel_backend + 0060_alter_messagingevent_content_type_and_more + 0061_connectmessage_message_id_connectmessage_received_on smsbillables 0001_initial 0002_bootstrap @@ -1268,6 +1271,7 @@ users 0072_remove_invitation_supply_point 0073_rm_location_from_user_data 0074_alter_sqluserdata_profile + 0075_connectiduserlink_messaging_consent_and_more util 0001_initial 0002_complaintbouncemeta_permanentbouncemeta_transientbounceemail From 7bc3854498dd998e8f9d0c8c00bb6792d31c73ce Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 13 Nov 2024 22:24:48 -0500 Subject: [PATCH 02/23] add connect backend --- .../smsbackends/connectid/backend.py | 47 +++++++++ .../messaging/smsbackends/connectid/urls.py | 22 +++++ .../messaging/smsbackends/connectid/views.py | 99 +++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 corehq/messaging/smsbackends/connectid/backend.py create mode 100644 corehq/messaging/smsbackends/connectid/urls.py create mode 100644 corehq/messaging/smsbackends/connectid/views.py diff --git a/corehq/messaging/smsbackends/connectid/backend.py b/corehq/messaging/smsbackends/connectid/backend.py new file mode 100644 index 000000000000..21fd88e8a5c7 --- /dev/null +++ b/corehq/messaging/smsbackends/connectid/backend.py @@ -0,0 +1,47 @@ +import base64 +import requests +from Crypto.Cipher import AES +from uuid import uuid4 + +from django.conf import settings + +from corehq.apps.users.models import ConnectIDUserLink, CouchUser + +class ConnectBackend: + couch_id = "connectid" + + def send(self, message): + user = CouchUser.get_by_user_id(message.couch_recipient).get_django_user() + user_link = ConnectIDUserLink.objects.get(commcare_user=user) + key = base64.b64decode(user_link.connectidmessagingkey_set.first().key) + cipher = AES.new(key, AES.MODE_GCM) + data, tag = cipher.encrypt_and_digest(message.text.encode("utf-8")) + content = { + "tag": base64.b64encode(tag).decode("utf-8"), + "nonce": base64.b64encode(cipher.nonce).decode("utf-8"), + "ciphertext": base64.b64encode(data).decode("utf-8"), + } + response = requests.post( + settings.CONNECTID_MESSAGE_URL, + json={ + "channel": str(user_link.channel_id), + "content": content, + "message_id": str(message.message_id), + }, + auth=(settings.CONNECTID_CLIENT_ID, settings.CONNECTID_SECRET_KEY) + ) + return response.status_code == requests.codes.OK + + + def create_channel(self, user): + user_link = ConnectIDUserLink.objects.get(commcare_user=user) + response = requests.post( + settings.CONNECTID_CHANNEL_URL, + data={ + "connectid": user_link.connectid_username, + "channel_source": user_link.domain, + }, + auth=(settings.CONNECTID_CLIENT_ID, settings.CONNECTID_SECRET_KEY) + ) + user_link.channel_id = response.json()["channel_id"] + user_link.save() diff --git a/corehq/messaging/smsbackends/connectid/urls.py b/corehq/messaging/smsbackends/connectid/urls.py new file mode 100644 index 000000000000..1ad862df59fb --- /dev/null +++ b/corehq/messaging/smsbackends/connectid/urls.py @@ -0,0 +1,22 @@ +from django.urls import re_path as url +from corehq.messaging.smsbackends.connectid.views import ( + connectid_messaging_key, + receive_message, + update_connectid_messaging_consent, + messaging_callback_url +) + +urlpatterns = [ + url(r'^message$', receive_message, name="receive_connect_message"), + url( + r'^messaging_key/$', + connectid_messaging_key, + name='connectid_messaging_key', + ), + url( + r'^update_messaging_consent/$', + update_connectid_messaging_consent, + name='update_connectid_messaging_consent', + ), + url(r'^callback$', messaging_callback_url, name="connect_message_callback"), +] diff --git a/corehq/messaging/smsbackends/connectid/views.py b/corehq/messaging/smsbackends/connectid/views.py new file mode 100644 index 000000000000..5edff0392332 --- /dev/null +++ b/corehq/messaging/smsbackends/connectid/views.py @@ -0,0 +1,99 @@ +from Crypto.Cipher import AES +import base64 +import json + +from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +from corehq.apps.domain.auth import connectid_token_auth +from corehq.apps.users.models import ConnectIDMessagingKey, ConnectIDUserLink +from corehq.apps.sms.models import ConnectMessagingNumber, ConnectMessage, INCOMING +from corehq.apps.sms.api import process_incoming +from corehq.util.hmac_request import validate_request_hmac +from corehq.apps.mobile_auth.utils import generate_aes_key + +@csrf_exempt +@require_POST +@validate_request_hmac("CONNECTID_SECRET_KEY") +def receive_message(request, *args, **kwargs): + data = json.loads(request.body.decode("utf-8")) + channel_id = data["channel"] + user_link = ConnectIDUserLink.objects.get(channel_id=channel_id) + phone_obj = ConnectMessagingNumber(user_link) + for message in data["messages"]: + content = data["content"] + key = base64.b64decode(user_link.conectidmessagingkey_set.first()) + cipher = AES.new(key, AES.MODE_GCM, nonce=content["nonce"]) + text = cipher.decrypt_and_verify(content["ciphertext"], content["tag"]).decode("utf-8") + timestamp = data["timestamp"] + message_id = data["message_id"] + msg = ConnectMessage( + direction=INCOMING, + date=timestamp, + text=text, + domain_scope=user_link.domain, + backend_id="connectid", + message_id=message_id + ) + process_incoming(msg, phone_obj) + return HttpResponse(status=200) + + +@csrf_exempt +@require_POST +@connectid_token_auth +def connectid_messaging_key(request, *args, **kwargs): + channel_id = request.POST.get("channel_id") + if channel_id is None: + return HttpResponseBadRequest("Channel ID is required.") + link = get_object_or_404(ConnectIDUserLink, channel_id=channel_id) + key = generate_aes_key().decode("utf-8") + messaging_key, _ = ConnectIDMessagingKey.objects.get_or_create( + connectid_user_link=link, domain=link.domain, active=True, defaults={"key": key} + ) + return JsonResponse({"key": messaging_key.key}) + + +@csrf_exempt +@require_POST +@validate_request_hmac("CONNECTID_SECRET_KEY") +def update_connectid_messaging_consent(request, *args, **kwargs): + data = json.loads(request.body) + channel_id = data.get("channel_id") + consent = data.get("consent", False) + if channel_id is None: + return HttpResponseBadRequest("Channel ID is required.") + link = get_object_or_404(ConnectIDUserLink, channel_id=channel_id) + link.messaging_consent = consent + link.save() + return HttpResponse(status=200) + + +@csrf_exempt +@require_POST +@validate_request_hmac("CONNECTID_SECRET_KEY") +def messaging_callback_url(request, *args, **kwargs): + data = json.loads(request.body.decode("utf-8")) + channel_id = data.get("channel_id") + if channel_id is None: + return HttpResponseBadRequest("Channel ID is required.") + user_link = get_object_or_404(ConnectIDUserLink, channel_id=channel_id) + messages = data.get("messages", []) + messages_to_update = [] + message_data = {message.get("message_id"): message.get("received_on") for message in messages} + message_ids = list(message_data.keys()) + message_objs = ConnectMessage.objects.filter( + message_id__in=message_ids, + domain=user_link.domain, + backend_id="connectid" + ) + for message_obj in message_objs: + received_on = message_data.get(message_obj.message_id) + if received_on is None: + continue + message_obj.received_on = received_on + messages_to_update.append(message_obj) + ConnectMessage.objects.bulk_update(messages_to_update, ("received_on",)) + return HttpResponse(status=200) From c609f0d358551ad70f73aab973ca14f7be43e95d Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 13 Nov 2024 22:27:25 -0500 Subject: [PATCH 03/23] add connectid settings and urls --- settings.py | 5 +++++ urls.py | 1 + 2 files changed, 6 insertions(+) diff --git a/settings.py b/settings.py index f82ee2a63412..b158007faf89 100755 --- a/settings.py +++ b/settings.py @@ -348,6 +348,7 @@ 'corehq.messaging.smsbackends.start_enterprise', 'corehq.messaging.smsbackends.ivory_coast_mtn', 'corehq.messaging.smsbackends.airtel_tcl', + 'corehq.messaging.smsbackends.connectid', 'corehq.apps.reports.app_config.ReportsModule', 'corehq.apps.reports_core', 'corehq.apps.saved_reports', @@ -1146,6 +1147,10 @@ def _pkce_required(client_id): FCM_CREDS = None CONNECTID_USERINFO_URL = 'http://localhost:8080/o/userinfo' +CONNECTID_CLIENT_ID = '' +CONNECTID_SECRET_KEY = '' +CONNECTID_CHANNEL_URL = 'http://localhost:8080/messaging/create_channel/' +CONNECTID_MESSAGE_URL = 'http://localhost:8080/messaging/send_fcm/' MAX_MOBILE_UCR_LIMIT = 300 # used in corehq.apps.cloudcare.util.should_restrict_web_apps_usage MAX_MOBILE_UCR_SIZE = 100000 # max number of rows allowed when syncing a mobile UCR diff --git a/urls.py b/urls.py index 2caa928bce36..d41d303858fb 100644 --- a/urls.py +++ b/urls.py @@ -135,6 +135,7 @@ url(r'^yo/', include('corehq.messaging.smsbackends.yo.urls')), url(r'^gvi/', include('corehq.messaging.smsbackends.grapevine.urls')), url(r'^sislog/', include('corehq.messaging.smsbackends.sislog.urls')), + url(r'^connectid/', include('corehq.messaging.smsbackends.connectid.urls')), url(r'^langcodes/', include('langcodes.urls')), url(r'^builds/', include('corehq.apps.builds.urls')), url(r'^downloads/temp/', include('soil.urls')), From e4b6deba164da728fec47b11d638d24467b89a03 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 13 Nov 2024 22:27:53 -0500 Subject: [PATCH 04/23] add connect messages to scheduling forms --- corehq/messaging/scheduling/forms.py | 34 +++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/corehq/messaging/scheduling/forms.py b/corehq/messaging/scheduling/forms.py index 7f04445358d9..094b1eb104ef 100644 --- a/corehq/messaging/scheduling/forms.py +++ b/corehq/messaging/scheduling/forms.py @@ -85,6 +85,7 @@ AlertEvent, AlertSchedule, CasePropertyTimedEvent, + ConnectMessageContent, CustomContent, EmailContent, FCMNotificationContent, @@ -104,6 +105,7 @@ ScheduleInstance, ) from corehq.toggles import ( + COMMCARE_CONNECT, EXTENSION_CASES_SYNC_ENABLED, FCM_NOTIFICATION, RICH_TEXT_EMAILS, @@ -288,7 +290,8 @@ def clean_message(self): return self._validate_fcm_message_length(cleaned_value, self.FCM_MESSAGE_MAX_LENGTH) if self.schedule_form.cleaned_data.get('content') not in (ScheduleForm.CONTENT_SMS, - ScheduleForm.CONTENT_EMAIL): + ScheduleForm.CONTENT_EMAIL, + ScheduleForm.CONTENT_CONNECT_MESSAGE): return None return self._clean_message_field('message') @@ -457,6 +460,10 @@ def distill_content(self): action=self.cleaned_data['fcm_action'], message_type=self.cleaned_data['fcm_message_type'], ) + elif self.schedule_form.cleaned_data['content'] == ScheduleForm.CONTENT_CONNECT_MESSAGE: + return ConnectMessageContent( + message=self.cleaned_data['message'], + ) else: raise ValueError("Unexpected value for content: '%s'" % self.schedule_form.cleaned_data['content']) @@ -513,9 +520,9 @@ def get_layout_fields(self): data_bind='with: message', ), data_bind=( - "visible: $root.content() === '%s' || $root.content() === '%s' " + "visible: $root.content() === '%s' || $root.content() === '%s' || $root.content() === '%s' " "|| ($root.content() === '%s' && fcm_message_type() === '%s')" % - (ScheduleForm.CONTENT_SMS, ScheduleForm.CONTENT_SMS_CALLBACK, + (ScheduleForm.CONTENT_SMS, ScheduleForm.CONTENT_SMS_CALLBACK, ScheduleForm.CONTENT_CONNECT_MESSAGE, ScheduleForm.CONTENT_FCM_NOTIFICATION, FCMNotificationContent.MESSAGE_TYPE_NOTIFICATION) ), ) @@ -534,10 +541,11 @@ def get_layout_fields(self): ), data_bind=( "visible: $root.content() === '%s' || $root.content() === '%s' " - "|| $root.content() === '%s' " + "|| $root.content() === '%s' || $root.content() === '%s' " "|| ($root.content() === '%s' && fcm_message_type() === '%s')" % - (ScheduleForm.CONTENT_SMS, ScheduleForm.CONTENT_EMAIL, ScheduleForm.CONTENT_SMS_CALLBACK, - ScheduleForm.CONTENT_FCM_NOTIFICATION, FCMNotificationContent.MESSAGE_TYPE_NOTIFICATION) + (ScheduleForm.CONTENT_SMS, ScheduleForm.CONTENT_EMAIL, + ScheduleForm.CONTENT_SMS_CALLBACK, ScheduleForm.CONTENT_CONNECT_MESSAGE, + ScheduleForm.CONTENT_FCM_NOTIFICATION, FCMNotificationContent.MESSAGE_TYPE_NOTIFICATION,) ), ), ] @@ -683,6 +691,8 @@ def compute_initial(domain, content): result['message'] = content.message result['fcm_action'] = content.action result['fcm_message_type'] = content.message_type + elif isinstance(content, ConnectMessageContent): + result['message'] = content.message else: raise TypeError("Unexpected content type: %s" % type(content)) @@ -1157,6 +1167,7 @@ class ScheduleForm(Form): CONTENT_SMS_CALLBACK = 'sms_callback' CONTENT_CUSTOM_SMS = 'custom_sms' CONTENT_FCM_NOTIFICATION = 'fcm_notification' + CONTENT_CONNECT_MESSAGE = 'connect_message' YES = 'Y' NO = 'N' @@ -1565,6 +1576,8 @@ def add_initial_for_content(self, initial): initial['content'] = self.CONTENT_SMS_CALLBACK elif isinstance(content, FCMNotificationContent): initial['content'] = self.CONTENT_FCM_NOTIFICATION + elif isinstance(content, ConnectMessageContent): + initial['conent'] = self.CONTENT_CONNECT_MESSAGE else: raise TypeError("Unexpected content type: %s" % type(content)) @@ -1757,6 +1770,10 @@ def create_form_helper(): def form_choices(self): return [(form['code'], form['name']) for form in get_form_list(self.domain)] + @property + def can_use_connect(self): + return COMMCARE_CONNECT.enabled(self.domain) + def add_additional_content_types(self): if ( self.can_use_sms_surveys @@ -1777,6 +1794,11 @@ def add_additional_content_types(self): (self.CONTENT_SMS_CALLBACK, _("SMS Expecting Callback")), ] + if self.can_use_connect: + self.fields['content'].choices += [ + (self.CONTENT_CONNECT_MESSAGE, _("Connect Message")), + ] + def enable_json_user_data_filter(self, initial): if self.is_system_admin or initial.get('use_user_data_filter') == self.JSON: self.fields['use_user_data_filter'].choices += [ From 929204dee1fc91454b0fdef505e96c0b45b282cd Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 13 Nov 2024 22:28:34 -0500 Subject: [PATCH 05/23] add necessary auth changes --- corehq/apps/domain/auth.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/corehq/apps/domain/auth.py b/corehq/apps/domain/auth.py index 0c2e4f734d62..cc83e0c7ad3f 100644 --- a/corehq/apps/domain/auth.py +++ b/corehq/apps/domain/auth.py @@ -9,7 +9,7 @@ from django.contrib.auth import authenticate, get_user_model from django.contrib.auth.models import User from django.db.models import Q -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.views.decorators.debug import sensitive_variables from no_exceptions.exceptions import Http400 @@ -169,6 +169,8 @@ def wrapper(request, *args, **kwargs): return real_decorator + + def formplayer_auth(view): return validate_request_hmac('FORMPLAYER_INTERNAL_AUTH_KEY')(view) @@ -405,3 +407,19 @@ def user_can_access_domain_specific_pages(request): return False return couch_user.is_member_of(project) or (couch_user.is_superuser and not project.restrict_superusers) + + +def connectid_token_auth(view_func): + @wraps(view_func) + def _inner(request, *args, **kwargs): + auth_header = request.META.get("HTTP_AUTHORIZATION") + if not auth_header: + return HttpResponseForbidden() + _, token = auth_header.split(" ") + if not token: + return HttpResponseBadRequest("ConnectID Token Required") + username = get_connectid_userinfo(token) + if username is None: + return HttpResponseForbidden() + return view_func(request, *args, **kwargs) + return _inner From 79154f0de620a84e624fd6b9c2ce1cf170bf45a6 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 13 Nov 2024 22:29:18 -0500 Subject: [PATCH 06/23] update domain deletion code for new models --- corehq/apps/domain/deletion.py | 1 + corehq/apps/dump_reload/sql/dump.py | 1 + 2 files changed, 2 insertions(+) diff --git a/corehq/apps/domain/deletion.py b/corehq/apps/domain/deletion.py index d5ee8891faac..24476f880c1f 100644 --- a/corehq/apps/domain/deletion.py +++ b/corehq/apps/domain/deletion.py @@ -456,6 +456,7 @@ def _delete_demo_user_restores(domain_name): ModelDeletion('userreports', 'InvalidUCRData', 'domain'), ModelDeletion('userreports', 'UCRExpression', 'domain'), ModelDeletion('users', 'ConnectIDUserLink', 'domain'), + ModelDeletion('users', 'ConnectIDMessagingKey', 'domain'), ModelDeletion('users', 'DomainRequest', 'domain'), ModelDeletion('users', 'DeactivateMobileWorkerTrigger', 'domain'), ModelDeletion('users', 'Invitation', 'domain'), diff --git a/corehq/apps/dump_reload/sql/dump.py b/corehq/apps/dump_reload/sql/dump.py index bd09a7635e07..35d3914dd3f5 100644 --- a/corehq/apps/dump_reload/sql/dump.py +++ b/corehq/apps/dump_reload/sql/dump.py @@ -166,6 +166,7 @@ FilteredModelIteratorBuilder('linked_domain.DomainLinkHistory', SimpleFilter('link__linked_domain')), FilteredModelIteratorBuilder('user_importer.UserUploadRecord', SimpleFilter('domain')), FilteredModelIteratorBuilder('users.ConnectIDUserLink', SimpleFilter('domain')), + FilteredModelIteratorBuilder('users.ConnectIDMessagingKey', SimpleFilter('domain')), FilteredModelIteratorBuilder('users.DeactivateMobileWorkerTrigger', SimpleFilter('domain')), FilteredModelIteratorBuilder('users.DomainRequest', SimpleFilter('domain')), FilteredModelIteratorBuilder('users.Invitation', SimpleFilter('domain')), From 460c7941641abadd6d7f4bb873a692cbf800057c Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 13 Nov 2024 22:29:41 -0500 Subject: [PATCH 07/23] use updated method name --- corehq/apps/commtrack/sms.py | 6 +++--- corehq/apps/sms/filters.py | 1 + corehq/apps/sms/handlers/fallback.py | 4 ++-- corehq/apps/sms/handlers/form_session.py | 12 ++++++------ corehq/apps/sms/handlers/keyword.py | 14 +++++++------- corehq/apps/sms/tests/opt_tests.py | 14 +++++++------- corehq/apps/sms/tests/test_backends.py | 14 +++++++------- corehq/apps/sms/tests/test_form_session_handler.py | 2 +- corehq/apps/sms/verify.py | 4 ++-- corehq/apps/sms/views.py | 4 ++-- corehq/apps/smsforms/tasks.py | 6 +++--- 11 files changed, 41 insertions(+), 40 deletions(-) diff --git a/corehq/apps/commtrack/sms.py b/corehq/apps/commtrack/sms.py index ee08aa11140a..136dcacf8302 100644 --- a/corehq/apps/commtrack/sms.py +++ b/corehq/apps/commtrack/sms.py @@ -21,7 +21,7 @@ from corehq.apps.domain.models import Domain from corehq.apps.products.models import SQLProduct from corehq.apps.receiverwrapper.util import submit_form_locally -from corehq.apps.sms.api import MessageMetadata, send_sms_to_verified_number +from corehq.apps.sms.api import MessageMetadata, send_message_to_verified_number from corehq.apps.users.models import CouchUser from corehq.form_processor.interfaces.supply import SupplyInterface from corehq.form_processor.parsers.ledgers.helpers import ( @@ -50,7 +50,7 @@ def handle(verified_contact, text, msg): except Exception as e: if settings.UNIT_TESTING or settings.DEBUG: raise - send_sms_to_verified_number(verified_contact, 'problem with stock report: %s' % str(e)) + send_message_to_verified_number(verified_contact, 'problem with stock report: %s' % str(e)) return True process(domain_obj.name, data) @@ -372,4 +372,4 @@ def summarize_action(action, txs): ' '.join(sorted(summarize_action(a, txs) for a, txs in tx_by_action.items())) ) - send_sms_to_verified_number(v, msg, metadata=metadata) + send_message_to_verified_number(v, msg, metadata=metadata) diff --git a/corehq/apps/sms/filters.py b/corehq/apps/sms/filters.py index c681f8babd44..b83e134e7797 100644 --- a/corehq/apps/sms/filters.py +++ b/corehq/apps/sms/filters.py @@ -80,6 +80,7 @@ def options(self): (MessagingEvent.CONTENT_SMS_CALLBACK, gettext_noop('SMS Callback')), (MessagingEvent.CONTENT_SMS, gettext_noop('Other SMS')), (MessagingEvent.CONTENT_EMAIL, gettext_noop('Email')), + (MessagingEvent.CONTENT_CONNECT, gettext_noop('Connect Message')), ] diff --git a/corehq/apps/sms/handlers/fallback.py b/corehq/apps/sms/handlers/fallback.py index fc6413c0f624..01ad318199ce 100644 --- a/corehq/apps/sms/handlers/fallback.py +++ b/corehq/apps/sms/handlers/fallback.py @@ -2,7 +2,7 @@ from corehq.apps.sms.api import ( MessageMetadata, add_msg_tags, - send_sms_to_verified_number, + send_message_to_verified_number, ) from corehq.apps.sms.models import WORKFLOW_DEFAULT, MessagingEvent @@ -26,7 +26,7 @@ def fallback_handler(verified_number, text, msg): outbound_meta = MessageMetadata(workflow=WORKFLOW_DEFAULT, location_id=msg.location_id, messaging_subevent_id=outbound_subevent.pk) - send_sms_to_verified_number(verified_number, domain_obj.default_sms_response, + send_message_to_verified_number(verified_number, domain_obj.default_sms_response, metadata=outbound_meta) outbound_subevent.completed() diff --git a/corehq/apps/sms/handlers/form_session.py b/corehq/apps/sms/handlers/form_session.py index 931f859ba51f..fd664ec5a878 100644 --- a/corehq/apps/sms/handlers/form_session.py +++ b/corehq/apps/sms/handlers/form_session.py @@ -9,7 +9,7 @@ MessageMetadata, add_msg_tags, log_sms_exception, - send_sms_to_verified_number, + send_message_to_verified_number, ) from corehq.apps.sms.messages import ( MSG_CHOICE_OUT_OF_RANGE, @@ -59,7 +59,7 @@ def form_session_handler(verified_number, text, msg): "message_id": msg.couch_id }) session.mark_completed(False) # this will also release the channel - send_sms_to_verified_number( + send_message_to_verified_number( verified_number, get_message(MSG_GENERIC_ERROR, verified_number) ) return True @@ -77,7 +77,7 @@ def form_session_handler(verified_number, text, msg): verified_number.domain, verified_number.owner_id ) if multiple: - send_sms_to_verified_number(verified_number, get_message(MSG_GENERIC_ERROR, verified_number)) + send_message_to_verified_number(verified_number, get_message(MSG_GENERIC_ERROR, verified_number)) return True if session: @@ -102,7 +102,7 @@ def form_session_handler(verified_number, text, msg): except Exception: # Catch any touchforms errors log_sms_exception(msg) - send_sms_to_verified_number(verified_number, get_message(MSG_TOUCHFORMS_DOWN, verified_number)) + send_message_to_verified_number(verified_number, get_message(MSG_TOUCHFORMS_DOWN, verified_number)) return True else: return False @@ -152,12 +152,12 @@ def answer_next_question(verified_number, text, msg, session, subevent_id): events = get_events_from_responses(responses) if len(text_responses) > 0: response_text = format_message_list(text_responses) - send_sms_to_verified_number(verified_number, response_text, + send_message_to_verified_number(verified_number, response_text, metadata=outbound_metadata, events=events) else: mark_as_invalid_response(msg) response_text = "%s %s" % (error_msg, event.text_prompt) - send_sms_to_verified_number(verified_number, response_text, + send_message_to_verified_number(verified_number, response_text, metadata=outbound_metadata, events=[event]) diff --git a/corehq/apps/sms/handlers/keyword.py b/corehq/apps/sms/handlers/keyword.py index 595135ea3944..5e3c41f45614 100644 --- a/corehq/apps/sms/handlers/keyword.py +++ b/corehq/apps/sms/handlers/keyword.py @@ -13,7 +13,7 @@ from corehq.apps.sms.api import ( MessageMetadata, add_msg_tags, - send_sms_to_verified_number, + send_message_to_verified_number, ) from corehq.apps.sms.handlers.form_session import validate_answer from corehq.apps.sms.messages import ( @@ -107,10 +107,10 @@ def global_keyword_start(verified_number, text, msg, text_words, open_sessions): process_survey_keyword_actions(verified_number, k, text[6:].strip(), msg) else: message = get_message(MSG_KEYWORD_NOT_FOUND, verified_number, (keyword,)) - send_sms_to_verified_number(verified_number, message, metadata=outbound_metadata) + send_message_to_verified_number(verified_number, message, metadata=outbound_metadata) else: message = get_message(MSG_START_KEYWORD_USAGE, verified_number, (text_words[0],)) - send_sms_to_verified_number(verified_number, message, metadata=outbound_metadata) + send_message_to_verified_number(verified_number, message, metadata=outbound_metadata) return True @@ -130,14 +130,14 @@ def global_keyword_current(verified_number, text, msg, text_words, open_sessions resp = FormplayerInterface(session.session_id, verified_number.domain).current_question() - send_sms_to_verified_number(verified_number, resp.event.text_prompt, + send_message_to_verified_number(verified_number, resp.event.text_prompt, metadata=outbound_metadata, events=[resp.event]) return True def global_keyword_unknown(verified_number, text, msg, text_words, open_sessions): message = get_message(MSG_UNKNOWN_GLOBAL_KEYWORD, verified_number, (text_words[0],)) - send_sms_to_verified_number(verified_number, message) + send_message_to_verified_number(verified_number, message) return True @@ -469,7 +469,7 @@ def clean_up_and_send_response(msg, contact, session, error_occurred, error_msg, contact.doc_type, contact.get_id) metadata.messaging_subevent_id = response_subevent.pk - send_sms_to_verified_number(verified_number, error_msg, metadata=metadata) + send_message_to_verified_number(verified_number, error_msg, metadata=metadata) if response_subevent: response_subevent.completed() @@ -560,7 +560,7 @@ def send_keyword_response(vn, message_id, logged_event): messaging_subevent_id=subevent.pk, ) message = get_message(message_id, vn) - send_sms_to_verified_number(vn, message, metadata=metadata) + send_message_to_verified_number(vn, message, metadata=metadata) subevent.completed() diff --git a/corehq/apps/sms/tests/opt_tests.py b/corehq/apps/sms/tests/opt_tests.py index f1c6855a9afa..fd4482348ff3 100644 --- a/corehq/apps/sms/tests/opt_tests.py +++ b/corehq/apps/sms/tests/opt_tests.py @@ -6,7 +6,7 @@ from corehq.apps.domain.models import Domain from corehq.messaging.pillow import get_case_messaging_sync_pillow from corehq.messaging.smsbackends.test.models import SQLTestSMSBackend -from corehq.apps.sms.api import incoming, send_sms_to_verified_number +from corehq.apps.sms.api import incoming, send_message_to_verified_number from corehq.apps.sms.messages import MSG_OPTED_IN, MSG_OPTED_OUT, get_message from corehq.apps.sms.models import SMS, PhoneBlacklist, PhoneNumber, SQLMobileBackendMapping, SQLMobileBackend from corehq.apps.sms.tests.util import ( @@ -108,7 +108,7 @@ def test_sending_to_opted_out_number(self): v = PhoneNumber.get_two_way_number('99912345678') self.assertIsNotNone(v) - send_sms_to_verified_number(v, 'hello') + send_message_to_verified_number(v, 'hello') sms = self.get_last_sms('+99912345678') self.assertEqual(sms.direction, 'O') self.assertEqual(sms.text, 'hello') @@ -118,7 +118,7 @@ def test_sending_to_opted_out_number(self): phone_number = PhoneBlacklist.objects.get(phone_number='99912345678') self.assertFalse(phone_number.send_sms) - send_sms_to_verified_number(v, 'hello') + send_message_to_verified_number(v, 'hello') sms = self.get_last_sms('+99912345678') self.assertEqual(sms.direction, 'O') self.assertEqual(sms.text, 'hello') @@ -130,7 +130,7 @@ def test_sending_to_opted_out_number(self): phone_number = PhoneBlacklist.objects.get(phone_number='99912345678') self.assertTrue(phone_number.send_sms) - send_sms_to_verified_number(v, 'hello') + send_message_to_verified_number(v, 'hello') sms = self.get_last_sms('+99912345678') self.assertEqual(sms.direction, 'O') self.assertEqual(sms.text, 'hello') @@ -144,7 +144,7 @@ def test_custom_opt_keywords(self): v = PhoneNumber.get_two_way_number('19912345678') self.assertIsNotNone(v) - send_sms_to_verified_number(v, 'hello') + send_message_to_verified_number(v, 'hello') sms = self.get_last_sms('+19912345678') self.assertEqual(sms.direction, 'O') self.assertEqual(sms.text, 'hello') @@ -154,7 +154,7 @@ def test_custom_opt_keywords(self): phone_number = PhoneBlacklist.objects.get(phone_number='19912345678') self.assertFalse(phone_number.send_sms) - send_sms_to_verified_number(v, 'hello') + send_message_to_verified_number(v, 'hello') sms = self.get_last_sms('+19912345678') self.assertEqual(sms.direction, 'O') self.assertEqual(sms.text, 'hello') @@ -166,7 +166,7 @@ def test_custom_opt_keywords(self): phone_number = PhoneBlacklist.objects.get(phone_number='19912345678') self.assertTrue(phone_number.send_sms) - send_sms_to_verified_number(v, 'hello') + send_message_to_verified_number(v, 'hello') sms = self.get_last_sms('+19912345678') self.assertEqual(sms.direction, 'O') self.assertEqual(sms.text, 'hello') diff --git a/corehq/apps/sms/tests/test_backends.py b/corehq/apps/sms/tests/test_backends.py index 8665c5b42f67..94a5a696ed49 100644 --- a/corehq/apps/sms/tests/test_backends.py +++ b/corehq/apps/sms/tests/test_backends.py @@ -16,7 +16,7 @@ from corehq.apps.hqcase.utils import update_case from corehq.apps.sms.api import ( send_sms, - send_sms_to_verified_number, + send_message_to_verified_number, send_sms_with_backend, send_sms_with_backend_name, ) @@ -812,7 +812,7 @@ def __test_verified_number_with_map(self, contact): 'corehq.messaging.smsbackends.test.models.SQLTestSMSBackend.send', autospec=True ) as mock_send: - self.assertTrue(send_sms_to_verified_number(verified_number, 'Test for BACKEND2')) + self.assertTrue(send_message_to_verified_number(verified_number, 'Test for BACKEND2')) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args[0][0].pk, self.backend2.pk) @@ -823,7 +823,7 @@ def __test_verified_number_with_map(self, contact): 'corehq.messaging.smsbackends.test.models.SQLTestSMSBackend.send', autospec=True ) as mock_send: - self.assertTrue(send_sms_to_verified_number(verified_number, 'Test for BACKEND5')) + self.assertTrue(send_message_to_verified_number(verified_number, 'Test for BACKEND5')) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args[0][0].pk, self.backend5.pk) @@ -841,7 +841,7 @@ def __test_contact_level_backend(self, contact): 'corehq.messaging.smsbackends.test.models.SQLTestSMSBackend.send', autospec=True ) as mock_send: - self.assertTrue(send_sms_to_verified_number(verified_number, 'Test for domain BACKEND')) + self.assertTrue(send_message_to_verified_number(verified_number, 'Test for domain BACKEND')) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args[0][0].pk, self.backend8.pk) @@ -853,7 +853,7 @@ def __test_contact_level_backend(self, contact): 'corehq.messaging.smsbackends.test.models.SQLTestSMSBackend.send', autospec=True ) as mock_send: - self.assertTrue(send_sms_to_verified_number(verified_number, 'Test for shared domain BACKEND')) + self.assertTrue(send_message_to_verified_number(verified_number, 'Test for shared domain BACKEND')) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args[0][0].pk, self.backend9.pk) @@ -865,7 +865,7 @@ def __test_contact_level_backend(self, contact): 'corehq.messaging.smsbackends.test.models.SQLTestSMSBackend.send', autospec=True ) as mock_send: - self.assertTrue(send_sms_to_verified_number(verified_number, 'Test for global BACKEND')) + self.assertTrue(send_message_to_verified_number(verified_number, 'Test for global BACKEND')) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args[0][0].pk, self.backend10.pk) @@ -874,7 +874,7 @@ def __test_contact_level_backend(self, contact): self.backend10.save() with self.assertRaises(BadSMSConfigException): - send_sms_to_verified_number(verified_number, 'Test for unknown BACKEND') + send_message_to_verified_number(verified_number, 'Test for unknown BACKEND') def __test_send_sms_with_backend(self): with patch( diff --git a/corehq/apps/sms/tests/test_form_session_handler.py b/corehq/apps/sms/tests/test_form_session_handler.py index 565f698b3878..7b6ac72dce5b 100644 --- a/corehq/apps/sms/tests/test_form_session_handler.py +++ b/corehq/apps/sms/tests/test_form_session_handler.py @@ -222,7 +222,7 @@ def test_incoming_sms_linked_form_session__session_contact_matches_incoming(self self.assertEqual(session_info.session_id, session.session_id) @patch('corehq.apps.sms.handlers.form_session.answer_next_question', MagicMock(return_value=None)) - @patch('corehq.apps.sms.handlers.form_session.send_sms_to_verified_number', MagicMock(return_value=None)) + @patch('corehq.apps.sms.handlers.form_session.send_message_to_verified_number', MagicMock(return_value=None)) def test_incoming_sms_linked_form_session__session_contact_different_from_incoming(self): session = self._make_session(self.number2) self._claim_channel(session) diff --git a/corehq/apps/sms/verify.py b/corehq/apps/sms/verify.py index 03138035c3bf..dbce5cb74936 100644 --- a/corehq/apps/sms/verify.py +++ b/corehq/apps/sms/verify.py @@ -4,7 +4,7 @@ from corehq.apps.sms.api import ( MessageMetadata, send_sms, - send_sms_to_verified_number, + send_message_to_verified_number, ) from corehq.apps.sms.mixin import PhoneNumberInUseException, apply_leniency from corehq.apps.sms.models import ( @@ -135,7 +135,7 @@ def process_verification(verified_number, msg, verification_keywords=None, creat messages.MSG_VERIFICATION_SUCCESSFUL, verified_number=verified_number ) - send_sms_to_verified_number(verified_number, message, + send_message_to_verified_number(verified_number, message, metadata=MessageMetadata(messaging_subevent_id=subevent.pk)) subevent.completed() return True diff --git a/corehq/apps/sms/views.py b/corehq/apps/sms/views.py index f516c7d9f4c8..6af63867e372 100644 --- a/corehq/apps/sms/views.py +++ b/corehq/apps/sms/views.py @@ -77,7 +77,7 @@ get_inbound_phone_entry, incoming, send_sms, - send_sms_to_verified_number, + send_message_to_verified_number, send_sms_with_backend_name, ) from corehq.apps.sms.forms import ( @@ -543,7 +543,7 @@ def api_send_sms(request, domain): if backend_id is not None: success = send_sms_with_backend_name(domain, phone_number, text, backend_id, metadata) elif vn is not None: - success = send_sms_to_verified_number(vn, text, metadata) + success = send_message_to_verified_number(vn, text, metadata) else: success = send_sms(domain, None, phone_number, text, metadata) diff --git a/corehq/apps/smsforms/tasks.py b/corehq/apps/smsforms/tasks.py index c700b1766740..9be78d01daab 100644 --- a/corehq/apps/smsforms/tasks.py +++ b/corehq/apps/smsforms/tasks.py @@ -11,7 +11,7 @@ from corehq.apps.sms.api import ( MessageMetadata, send_sms, - send_sms_to_verified_number, + send_message_to_verified_number, ) from corehq.apps.sms.models import PhoneNumber from corehq.apps.sms.util import format_message_list @@ -77,7 +77,7 @@ def send_first_message(domain, recipient, phone_entry_or_number, session, respon ) if isinstance(phone_entry_or_number, PhoneNumber): - send_sms_to_verified_number( + send_message_to_verified_number( phone_entry_or_number, message, metadata, @@ -134,7 +134,7 @@ def handle_due_survey_action(domain, contact_id, session_id): messaging_subevent_id=subevent.pk if subevent else None ) resp = FormplayerInterface(session.session_id, domain).current_question() - send_sms_to_verified_number( + send_message_to_verified_number( p, resp.event.text_prompt, metadata, From 092dbe0d1a55653f678b62b66a85c3ecf3675f1c Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Thu, 14 Nov 2024 22:24:36 -0500 Subject: [PATCH 08/23] implement survey model --- corehq/apps/smsforms/tasks.py | 4 +- ...ctmessagesurveycontent_message_and_more.py | 50 ++++++ .../messaging/scheduling/models/abstract.py | 93 +++++++++++ corehq/messaging/scheduling/models/content.py | 152 ++++++------------ migrations.lock | 1 + 5 files changed, 193 insertions(+), 107 deletions(-) create mode 100644 corehq/messaging/scheduling/migrations/0030_remove_connectmessagesurveycontent_message_and_more.py diff --git a/corehq/apps/smsforms/tasks.py b/corehq/apps/smsforms/tasks.py index 9be78d01daab..4eeecd48892a 100644 --- a/corehq/apps/smsforms/tasks.py +++ b/corehq/apps/smsforms/tasks.py @@ -13,7 +13,7 @@ send_sms, send_message_to_verified_number, ) -from corehq.apps.sms.models import PhoneNumber +from corehq.apps.sms.models import ConnectMessagingNumber, PhoneNumber from corehq.apps.sms.util import format_message_list from corehq.apps.smsforms.app import ( _responses_to_text, @@ -76,7 +76,7 @@ def send_first_message(domain, recipient, phone_entry_or_number, session, respon messaging_subevent_id=logged_subevent.pk ) - if isinstance(phone_entry_or_number, PhoneNumber): + if isinstance(phone_entry_or_number, PhoneNumber) or isinstance(phone_entry_or_number, ConnectMessagingNumber): send_message_to_verified_number( phone_entry_or_number, message, diff --git a/corehq/messaging/scheduling/migrations/0030_remove_connectmessagesurveycontent_message_and_more.py b/corehq/messaging/scheduling/migrations/0030_remove_connectmessagesurveycontent_message_and_more.py new file mode 100644 index 000000000000..f7f1043e49f6 --- /dev/null +++ b/corehq/messaging/scheduling/migrations/0030_remove_connectmessagesurveycontent_message_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.15 on 2024-11-15 03:20 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('scheduling', '0029_connectmessagecontent_connectmessagesurveycontent_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='connectmessagesurveycontent', + name='message', + ), + migrations.AddField( + model_name='connectmessagesurveycontent', + name='app_id', + field=models.CharField(max_length=126, null=True), + ), + migrations.AddField( + model_name='connectmessagesurveycontent', + name='expire_after', + field=models.IntegerField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='connectmessagesurveycontent', + name='form_unique_id', + field=models.CharField(default=1, max_length=126), + preserve_default=False, + ), + migrations.AddField( + model_name='connectmessagesurveycontent', + name='include_case_updates_in_partial_submissions', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='connectmessagesurveycontent', + name='reminder_intervals', + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name='connectmessagesurveycontent', + name='submit_partially_completed_forms', + field=models.BooleanField(default=False), + ), + ] diff --git a/corehq/messaging/scheduling/models/abstract.py b/corehq/messaging/scheduling/models/abstract.py index 98b52d15f5fe..1fe8fcefb914 100644 --- a/corehq/messaging/scheduling/models/abstract.py +++ b/corehq/messaging/scheduling/models/abstract.py @@ -525,6 +525,99 @@ def send_sms_message(self, domain, recipient, phone_entry_or_number, message, lo logged_subevent=logged_subevent) +class SurveyContent(Content): + app_id = models.CharField(max_length=126, null=True) + form_unique_id = models.CharField(max_length=126) + + # See corehq.apps.smsforms.models.SQLXFormsSession for an + # explanation of these properties + expire_after = models.IntegerField() + reminder_intervals = models.JSONField(default=list) + submit_partially_completed_forms = models.BooleanField(default=False) + include_case_updates_in_partial_submissions = models.BooleanField(default=False) + + class Meta: + abstract = True + + def create_copy(self): + """ + See Content.create_copy() for docstring + """ + return SMSSurveyContent( + app_id=None, + form_unique_id=None, + expire_after=self.expire_after, + reminder_intervals=deepcopy(self.reminder_intervals), + submit_partially_completed_forms=self.submit_partially_completed_forms, + include_case_updates_in_partial_submissions=self.include_case_updates_in_partial_submissions, + ) + + @memoized + def get_memoized_app_module_form(self, domain): + try: + if toggles.SMS_USE_LATEST_DEV_APP.enabled(domain, toggles.NAMESPACE_DOMAIN): + app = get_app(domain, self.app_id) + else: + app = get_latest_released_app(domain, self.app_id) + form = app.get_form(self.form_unique_id) + module = form.get_module() + except (Http404, FormNotFoundException): + return None, None, None, None + + return app, module, form, form_requires_input(form) + def start_smsforms_session(self, domain, recipient, case_id, phone_entry_or_number, logged_subevent, workflow, + app, form): + # Close all currently open sessions + SQLXFormsSession.close_all_open_sms_sessions(domain, recipient.get_id) + + # Start the new session + try: + session, responses = start_session( + SQLXFormsSession.create_session_object( + domain, + recipient, + (phone_entry_or_number.phone_number + if isinstance(phone_entry_or_number, PhoneNumber) + else phone_entry_or_number), + app, + form, + expire_after=self.expire_after, + reminder_intervals=self.reminder_intervals, + submit_partially_completed_forms=self.submit_partially_completed_forms, + include_case_updates_in_partial_submissions=self.include_case_updates_in_partial_submissions + ), + domain, + recipient, + app, + form, + case_id, + yield_responses=True + ) + except TouchformsError as e: + logged_subevent.error( + MessagingEvent.ERROR_TOUCHFORMS_ERROR, + additional_error_text=get_formplayer_exception(domain, e) + ) + + if touchforms_error_is_config_error(domain, e): + # Don't reraise the exception because this means there are configuration + # issues with the form that need to be fixed. The error is logged in the + # above lines. + return None, None + + # Reraise the exception so that the framework retries it again later + raise + except Exception: + logged_subevent.error(MessagingEvent.ERROR_TOUCHFORMS_ERROR) + # Reraise the exception so that the framework retries it again later + raise + + session.workflow = workflow + session.save() + + return session, responses + + class Broadcast(models.Model): domain = models.CharField(max_length=126, db_index=True) name = models.CharField(max_length=1000) diff --git a/corehq/messaging/scheduling/models/content.py b/corehq/messaging/scheduling/models/content.py index 8640d58b1ed2..b81840c15cd4 100644 --- a/corehq/messaging/scheduling/models/content.py +++ b/corehq/messaging/scheduling/models/content.py @@ -55,7 +55,7 @@ from corehq.messaging.fcm.exceptions import FCMTokenValidationException from corehq.messaging.fcm.utils import FCMUtil from corehq.messaging.scheduling.exceptions import EmailValidationException -from corehq.messaging.scheduling.models.abstract import Content +from corehq.messaging.scheduling.models.abstract import Content, SurveyContent from corehq.sql_db.util import get_db_aliases_for_partitioned_query from corehq.util.metrics import metrics_counter from corehq.util.models import NullJsonField @@ -246,43 +246,9 @@ def get_recipient_email(self, recipient): return email_address -class SMSSurveyContent(Content): - app_id = models.CharField(max_length=126, null=True) - form_unique_id = models.CharField(max_length=126) - # See corehq.apps.smsforms.models.SQLXFormsSession for an - # explanation of these properties - expire_after = models.IntegerField() - reminder_intervals = models.JSONField(default=list) - submit_partially_completed_forms = models.BooleanField(default=False) - include_case_updates_in_partial_submissions = models.BooleanField(default=False) - def create_copy(self): - """ - See Content.create_copy() for docstring - """ - return SMSSurveyContent( - app_id=None, - form_unique_id=None, - expire_after=self.expire_after, - reminder_intervals=deepcopy(self.reminder_intervals), - submit_partially_completed_forms=self.submit_partially_completed_forms, - include_case_updates_in_partial_submissions=self.include_case_updates_in_partial_submissions, - ) - - @memoized - def get_memoized_app_module_form(self, domain): - try: - if toggles.SMS_USE_LATEST_DEV_APP.enabled(domain, toggles.NAMESPACE_DOMAIN): - app = get_app(domain, self.app_id) - else: - app = get_latest_released_app(domain, self.app_id) - form = app.get_form(self.form_unique_id) - module = form.get_module() - except (Http404, FormNotFoundException): - return None, None, None, None - - return app, module, form, form_requires_input(form) +class SMSSurveyContent(SurveyContent): def phone_has_opted_out(self, phone_entry_or_number): if isinstance(phone_entry_or_number, PhoneNumber): @@ -369,58 +335,6 @@ def send(self, recipient, logged_event, phone_entry=None): self.get_workflow(logged_event) ) - def start_smsforms_session(self, domain, recipient, case_id, phone_entry_or_number, logged_subevent, workflow, - app, form): - # Close all currently open sessions - SQLXFormsSession.close_all_open_sms_sessions(domain, recipient.get_id) - - # Start the new session - try: - session, responses = start_session( - SQLXFormsSession.create_session_object( - domain, - recipient, - (phone_entry_or_number.phone_number - if isinstance(phone_entry_or_number, PhoneNumber) - else phone_entry_or_number), - app, - form, - expire_after=self.expire_after, - reminder_intervals=self.reminder_intervals, - submit_partially_completed_forms=self.submit_partially_completed_forms, - include_case_updates_in_partial_submissions=self.include_case_updates_in_partial_submissions - ), - domain, - recipient, - app, - form, - case_id, - yield_responses=True - ) - except TouchformsError as e: - logged_subevent.error( - MessagingEvent.ERROR_TOUCHFORMS_ERROR, - additional_error_text=get_formplayer_exception(domain, e) - ) - - if touchforms_error_is_config_error(domain, e): - # Don't reraise the exception because this means there are configuration - # issues with the form that need to be fixed. The error is logged in the - # above lines. - return None, None - - # Reraise the exception so that the framework retries it again later - raise - except Exception: - logged_subevent.error(MessagingEvent.ERROR_TOUCHFORMS_ERROR) - # Reraise the exception so that the framework retries it again later - raise - - session.workflow = workflow - session.save() - - return session, responses - class IVRSurveyContent(Content): """ @@ -797,27 +711,55 @@ def send(self, recipient, logged_event, phone_entry=None): send_message_to_verified_number(connect_number, message, logged_subevent=logged_subevent) -class ConnectMessageSurveyContent(Content): - message = old_jsonfield.JSONField(default=dict) - - def create_copy(self): - return ConnectMessageSurveyContent( - message=deepcopy(self.message), - ) - +class ConnectMessageSurveyContent(SurveyContent): def send(self, recipient, logged_event, phone_entry=None): - domain = logged_event.domain - domain_obj = Domain.get_by_name(domain) + app, module, form, requires_input = self.get_memoized_app_module_form(logged_event.domain) + if any([o is None for o in (app, module, form)]): + logged_event.error(MessagingEvent.ERROR_CANNOT_FIND_FORM) + return logged_subevent = logged_event.create_subevent_from_contact_and_content( recipient, self, - case_id=None, - ) - message = self.get_translation_from_message_dict( - domain_obj, - self.message, - recipient.get_language_code() + case_id=self.case.case_id if self.case else None, ) - send_connect_message(message) + connect_number = ConnectMessagingNumber(recipient) + + with self.get_critical_section(recipient): + # Get the case to submit the form against, if any + case_id = None + if self.case: + case_id = self.case.case_id + + if form.requires_case() and not case_id: + logged_subevent.error(MessagingEvent.ERROR_NO_CASE_GIVEN) + return + + session, responses = self.start_smsforms_session( + logged_event.domain, + recipient, + case_id, + connect_number.phone_number, + logged_subevent, + self.get_workflow(logged_event), + app, + form + ) + + if session: + logged_subevent.xforms_session = session + logged_subevent.save() + # send_first_message is a celery task + # but we first call it synchronously to save resources in the 99% case + # send_first_message will retry itself as a delayed celery task + # if there are conflicting sessions preventing it from sending immediately + send_first_message( + logged_event.domain, + recipient, + connect_number, + session, + responses, + logged_subevent, + self.get_workflow(logged_event) + ) diff --git a/migrations.lock b/migrations.lock index 6ec5b2c7d238..9af1c0d1f189 100644 --- a/migrations.lock +++ b/migrations.lock @@ -898,6 +898,7 @@ scheduling 0027_emailcontent_html_message 0028_alertschedule_use_user_case_for_filter_and_more 0029_connectmessagecontent_connectmessagesurveycontent_and_more + 0030_remove_connectmessagesurveycontent_message_and_more scheduling_partitioned 0001_initial 0002_case_schedule_instances From f29ebfc0691ce0ef228ece334f0b47b430d826c3 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Fri, 15 Nov 2024 16:03:27 -0500 Subject: [PATCH 09/23] fix default values --- ...030_remove_connectmessagesurveycontent_message_and_more.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/corehq/messaging/scheduling/migrations/0030_remove_connectmessagesurveycontent_message_and_more.py b/corehq/messaging/scheduling/migrations/0030_remove_connectmessagesurveycontent_message_and_more.py index f7f1043e49f6..bca721f4ebbb 100644 --- a/corehq/messaging/scheduling/migrations/0030_remove_connectmessagesurveycontent_message_and_more.py +++ b/corehq/messaging/scheduling/migrations/0030_remove_connectmessagesurveycontent_message_and_more.py @@ -23,13 +23,13 @@ class Migration(migrations.Migration): migrations.AddField( model_name='connectmessagesurveycontent', name='expire_after', - field=models.IntegerField(default=django.utils.timezone.now), + field=models.IntegerField(default=1), preserve_default=False, ), migrations.AddField( model_name='connectmessagesurveycontent', name='form_unique_id', - field=models.CharField(default=1, max_length=126), + field=models.CharField(default="1", max_length=126), preserve_default=False, ), migrations.AddField( From 91ce6e6d05edc80e02764a5a7ab45db158189ae9 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Fri, 22 Nov 2024 15:44:34 -0500 Subject: [PATCH 10/23] move content fields to top level of payload --- corehq/messaging/smsbackends/connectid/views.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/corehq/messaging/smsbackends/connectid/views.py b/corehq/messaging/smsbackends/connectid/views.py index 5edff0392332..d23c1eb075cf 100644 --- a/corehq/messaging/smsbackends/connectid/views.py +++ b/corehq/messaging/smsbackends/connectid/views.py @@ -23,10 +23,9 @@ def receive_message(request, *args, **kwargs): user_link = ConnectIDUserLink.objects.get(channel_id=channel_id) phone_obj = ConnectMessagingNumber(user_link) for message in data["messages"]: - content = data["content"] key = base64.b64decode(user_link.conectidmessagingkey_set.first()) - cipher = AES.new(key, AES.MODE_GCM, nonce=content["nonce"]) - text = cipher.decrypt_and_verify(content["ciphertext"], content["tag"]).decode("utf-8") + cipher = AES.new(key, AES.MODE_GCM, nonce=data["nonce"]) + text = cipher.decrypt_and_verify(data["ciphertext"], data["tag"]).decode("utf-8") timestamp = data["timestamp"] message_id = data["message_id"] msg = ConnectMessage( From 4b992c77e2776c4f8528eb5c21c52b5b5dc87b49 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Tue, 26 Nov 2024 13:46:29 -0500 Subject: [PATCH 11/23] use string for the key --- corehq/messaging/smsbackends/connectid/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corehq/messaging/smsbackends/connectid/views.py b/corehq/messaging/smsbackends/connectid/views.py index d23c1eb075cf..ac3d86b35373 100644 --- a/corehq/messaging/smsbackends/connectid/views.py +++ b/corehq/messaging/smsbackends/connectid/views.py @@ -89,7 +89,7 @@ def messaging_callback_url(request, *args, **kwargs): backend_id="connectid" ) for message_obj in message_objs: - received_on = message_data.get(message_obj.message_id) + received_on = message_data.get(str(message_obj.message_id)) if received_on is None: continue message_obj.received_on = received_on From 36a800fb85ca9f1622b5e753648f58046bda6ab3 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Tue, 26 Nov 2024 14:57:30 -0500 Subject: [PATCH 12/23] return the value --- corehq/apps/sms/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corehq/apps/sms/models.py b/corehq/apps/sms/models.py index c5e34a46bac2..dcdb5d30e46b 100644 --- a/corehq/apps/sms/models.py +++ b/corehq/apps/sms/models.py @@ -644,7 +644,7 @@ def owner(self): @property def domain(self): - self.user_link.domain + return self.user_link.domain @property def is_sms(self): From 472609ad8f8b417067746b772581cd0b438934d5 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Tue, 26 Nov 2024 16:48:56 -0500 Subject: [PATCH 13/23] typo --- corehq/messaging/smsbackends/connectid/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corehq/messaging/smsbackends/connectid/views.py b/corehq/messaging/smsbackends/connectid/views.py index ac3d86b35373..223309ba3a64 100644 --- a/corehq/messaging/smsbackends/connectid/views.py +++ b/corehq/messaging/smsbackends/connectid/views.py @@ -23,7 +23,7 @@ def receive_message(request, *args, **kwargs): user_link = ConnectIDUserLink.objects.get(channel_id=channel_id) phone_obj = ConnectMessagingNumber(user_link) for message in data["messages"]: - key = base64.b64decode(user_link.conectidmessagingkey_set.first()) + key = base64.b64decode(user_link.connectidmessagingkey_set.first()) cipher = AES.new(key, AES.MODE_GCM, nonce=data["nonce"]) text = cipher.decrypt_and_verify(data["ciphertext"], data["tag"]).decode("utf-8") timestamp = data["timestamp"] From 1efd62e827facfda6785a96272a4ceb8e64f7bea Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 27 Nov 2024 13:04:10 -0500 Subject: [PATCH 14/23] incoming messaging processing updates --- corehq/apps/sms/api.py | 2 ++ corehq/apps/sms/models.py | 5 +++++ .../smsbackends/connectid/backend.py | 2 ++ .../messaging/smsbackends/connectid/views.py | 20 ++++++++++++------- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/corehq/apps/sms/api.py b/corehq/apps/sms/api.py index 64672589bf78..c90a3fb35fa7 100644 --- a/corehq/apps/sms/api.py +++ b/corehq/apps/sms/api.py @@ -742,6 +742,7 @@ def _process_incoming(msg, phone=None): verified_number, has_domain_two_way_scope = get_inbound_phone_entry_from_sms(msg) else: verified_number = phone + has_domain_two_way_scope = phone.is_two_way is_two_way = verified_number is not None and verified_number.is_two_way if verified_number: @@ -810,6 +811,7 @@ def _process_incoming(msg, phone=None): not settings.SMS_QUEUE_ENABLED and msg.domain and domain_has_privilege(msg.domain, privileges.INBOUND_SMS) + and not isinstance(msg, ConnectMessage) ): create_billable_for_sms(msg) diff --git a/corehq/apps/sms/models.py b/corehq/apps/sms/models.py index dcdb5d30e46b..7321e985f159 100644 --- a/corehq/apps/sms/models.py +++ b/corehq/apps/sms/models.py @@ -617,6 +617,7 @@ def is_sms(self): class ConnectMessagingNumber(AbstractNumber): owner_doc_type = "CommCareUser" is_two_way = True + pending_verification = False def __init__(self, user): self.user = user @@ -2756,3 +2757,7 @@ class ConnectMessage(Log): text = models.CharField(max_length=300) received_on = models.DateTimeField(null=True, blank=True) message_id = models.UUIDField(default=uuid4) + + @property + def outbound_backend(self): + return ConnectBackend() diff --git a/corehq/messaging/smsbackends/connectid/backend.py b/corehq/messaging/smsbackends/connectid/backend.py index 21fd88e8a5c7..c8615218366e 100644 --- a/corehq/messaging/smsbackends/connectid/backend.py +++ b/corehq/messaging/smsbackends/connectid/backend.py @@ -9,6 +9,8 @@ class ConnectBackend: couch_id = "connectid" + opt_out_keywords = [] + opt_in_keywords = [] def send(self, message): user = CouchUser.get_by_user_id(message.couch_recipient).get_django_user() diff --git a/corehq/messaging/smsbackends/connectid/views.py b/corehq/messaging/smsbackends/connectid/views.py index 223309ba3a64..5e4461618ccf 100644 --- a/corehq/messaging/smsbackends/connectid/views.py +++ b/corehq/messaging/smsbackends/connectid/views.py @@ -8,7 +8,7 @@ from django.views.decorators.http import require_POST from corehq.apps.domain.auth import connectid_token_auth -from corehq.apps.users.models import ConnectIDMessagingKey, ConnectIDUserLink +from corehq.apps.users.models import ConnectIDMessagingKey, ConnectIDUserLink, CouchUser from corehq.apps.sms.models import ConnectMessagingNumber, ConnectMessage, INCOMING from corehq.apps.sms.api import process_incoming from corehq.util.hmac_request import validate_request_hmac @@ -19,13 +19,18 @@ @validate_request_hmac("CONNECTID_SECRET_KEY") def receive_message(request, *args, **kwargs): data = json.loads(request.body.decode("utf-8")) - channel_id = data["channel"] + channel_id = data["channel_id"] user_link = ConnectIDUserLink.objects.get(channel_id=channel_id) - phone_obj = ConnectMessagingNumber(user_link) + username = user_link.commcare_user.username + couch_user = CouchUser.get_by_username(username) + phone_obj = ConnectMessagingNumber(couch_user) for message in data["messages"]: - key = base64.b64decode(user_link.connectidmessagingkey_set.first()) - cipher = AES.new(key, AES.MODE_GCM, nonce=data["nonce"]) - text = cipher.decrypt_and_verify(data["ciphertext"], data["tag"]).decode("utf-8") + key = base64.b64decode(user_link.connectidmessagingkey_set.first().key) + ciphertext = base64.b64decode(data["ciphertext"]) + tag = base64.b64decode(data["tag"]) + nonce = base64.b64decode(data["nonce"]) + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + text = cipher.decrypt_and_verify(ciphertext, tag).decode("utf-8") timestamp = data["timestamp"] message_id = data["message_id"] msg = ConnectMessage( @@ -34,8 +39,9 @@ def receive_message(request, *args, **kwargs): text=text, domain_scope=user_link.domain, backend_id="connectid", - message_id=message_id + message_id=message_id, ) + msg.save() process_incoming(msg, phone_obj) return HttpResponse(status=200) From 80e034559c2e93ab283b87bb1522c6ac9010fef5 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 27 Nov 2024 13:20:36 -0500 Subject: [PATCH 15/23] handle connect content --- corehq/apps/reports/standard/sms.py | 8 ++++++-- corehq/apps/sms/handlers/fallback.py | 7 ++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/corehq/apps/reports/standard/sms.py b/corehq/apps/reports/standard/sms.py index 0e3eacad5af5..f279a5d30912 100644 --- a/corehq/apps/reports/standard/sms.py +++ b/corehq/apps/reports/standard/sms.py @@ -869,8 +869,12 @@ def template_context(self): def headers(self): EMAIL_ADDRRESS = _('Email Address') PHONE_NUMBER = _('Phone Number') - if self.messaging_event and self.messaging_event.content_type == MessagingEvent.CONTENT_EMAIL: - contact_column = EMAIL_ADDRRESS + CONNECT_ID = _('ConnectID') + if self.messaging_event: + if self.messaging_event.content_type == MessagingEvent.CONTENT_EMAIL: + contact_column = EMAIL_ADDRRESS + elif self.messaging_event.content_type == MessagingEvent.CONTENT_CONNECT: + contact_column = CONNECT_ID else: contact_column = PHONE_NUMBER return DataTablesHeader( diff --git a/corehq/apps/sms/handlers/fallback.py b/corehq/apps/sms/handlers/fallback.py index 01ad318199ce..6201692f3015 100644 --- a/corehq/apps/sms/handlers/fallback.py +++ b/corehq/apps/sms/handlers/fallback.py @@ -9,9 +9,14 @@ def fallback_handler(verified_number, text, msg): domain_obj = Domain.get_by_name(verified_number.domain, strict=True) + if verified_number isinstance(ConnectMessagingNumber): + content_type = MessagingEvent.CONTENT_CONNECT + else: + content_type = MessagingEvent.CONTENT_SMS + logged_event = MessagingEvent.create_event_for_adhoc_sms( - verified_number.domain, recipient=verified_number.owner, content_type=MessagingEvent.CONTENT_SMS, + verified_number.domain, recipient=verified_number.owner, content_type=content_type, source=MessagingEvent.SOURCE_UNRECOGNIZED) inbound_subevent = logged_event.create_subevent_for_single_sms( From f327d5592bc3fd2cf1014e6a456af3c12972b8b1 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 27 Nov 2024 20:35:39 -0500 Subject: [PATCH 16/23] add create channel views --- corehq/apps/sms/models.py | 5 +++- .../sms/connect_messaging_users.html | 11 ++++++++ corehq/apps/sms/urls.py | 4 +++ corehq/apps/sms/views.py | 26 +++++++++++++++++++ corehq/tabs/tabclasses.py | 11 ++++++++ 5 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 corehq/apps/sms/templates/sms/connect_messaging_users.html diff --git a/corehq/apps/sms/models.py b/corehq/apps/sms/models.py index 7321e985f159..e9905866d3d6 100644 --- a/corehq/apps/sms/models.py +++ b/corehq/apps/sms/models.py @@ -1386,7 +1386,8 @@ def get_content_info_from_content_object(cls, domain, content): EmailContent, CustomContent, FCMNotificationContent, - ConnectMessageContent + ConnectMessageContent, + ConnectMessageSurveyContent ) if isinstance(content, (SMSContent, CustomContent)): @@ -1401,6 +1402,8 @@ def get_content_info_from_content_object(cls, domain, content): return cls.CONTENT_FCM_Notification, None, None, None elif isinstance(content, ConnectMessageContent): return cls.CONTENT_CONNECT, None, None, None + elif isinstance(content, ConnectMessageSurveyContent): + return cls.CONTENT_CONNECT, None, None, None else: return cls.CONTENT_NONE, None, None, None diff --git a/corehq/apps/sms/templates/sms/connect_messaging_users.html b/corehq/apps/sms/templates/sms/connect_messaging_users.html new file mode 100644 index 000000000000..d8e3aadcf1fc --- /dev/null +++ b/corehq/apps/sms/templates/sms/connect_messaging_users.html @@ -0,0 +1,11 @@ +{% extends 'hqwebapp/bootstrap3/base_section.html' %} +{% load i18n %} +{% load hq_shared_tags %} + +{% block page_content %} + +{% endblock %} diff --git a/corehq/apps/sms/urls.py b/corehq/apps/sms/urls.py index 9aa8ab065d1b..f2c4ec0fba61 100644 --- a/corehq/apps/sms/urls.py +++ b/corehq/apps/sms/urls.py @@ -7,6 +7,7 @@ ChatMessageHistory, ChatOverSMSView, ComposeMessageView, + ConnectMessagingUserView, DomainSmsGatewayListView, EditDomainGatewayView, EditGlobalGatewayView, @@ -20,6 +21,7 @@ api_send_sms, chat, chat_contact_list, + create_channels, default, download_sms_translations, edit_sms_languages, @@ -59,6 +61,8 @@ url(r'^translations/upload/$', upload_sms_translations, name='upload_sms_translations'), url(r'^telerivet/', include(telerivet_urls)), url(r'^whatsapp_templates/$', WhatsAppTemplatesView.as_view(), name=WhatsAppTemplatesView.urlname), + url(r'^connect_messaging_user/$', ConnectMessagingUserView.as_view(), name=ConnectMessagingUserView), + url(r'^create_channels/$', create_channels, name='create_channels'), ] diff --git a/corehq/apps/sms/views.py b/corehq/apps/sms/views.py index 6af63867e372..1d5d2689ff24 100644 --- a/corehq/apps/sms/views.py +++ b/corehq/apps/sms/views.py @@ -2059,3 +2059,29 @@ def page_context(self): + _(" failed to fetch templates. Please make sure the gateway is configured properly.") ) return context + + +class ConnectMessagingUserView(BaseMessagingSectionView): + urlname = 'connect_messaging_user' + template_name = 'sms/connect_messaging_user.html' + page_title = _("Connect Messaging Users") + + @method_decorator(domain_admin_required) + def dispatch(self, *args, **kwargs): + return super(ConnectMessagingUserView, self).dispatch(*args, **kwargs) + + @property + def page_context(self): + page_context = super(ConnectMessagingUserView, self).page_context + page_context.update({ + "create_channel_url": reverse(("create_channels", args=[self.domain])) + }) + + +@domain_admin_required +def create_channels(self, request, *args, **kwargs): + user_links = ConnectIDUserLink.objects.filter(domain=request.domain) + backend = ConnectBackend() + for link in user_links: + backend.create_channel(link) + return HttpResponseRedirect(reverse(ConnectMessagingUserView.urlname, args=[domain])) diff --git a/corehq/tabs/tabclasses.py b/corehq/tabs/tabclasses.py index c8b563492a40..f60ce43ec073 100644 --- a/corehq/tabs/tabclasses.py +++ b/corehq/tabs/tabclasses.py @@ -1447,6 +1447,17 @@ def whatsapp_urls(self): }) return whatsapp_urls + @property + def connect_urls(self): + from corehq.apps.sms.views import ConnectMessagingUserView + + connect_urls = [] + if toggles.COMMCARE_CONNECT.enabled(self.domain): + connect_urls.append({ + 'title': _('User Consent'), + 'url': reverse(ConnectMesssagingUserView.urlname, args=[self.domain]), + }) + @property def dropdown_items(self): result = [] From 6d9e562b236bd9ee078e26015e9173dbd8537885 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 27 Nov 2024 20:35:51 -0500 Subject: [PATCH 17/23] add connect survey content --- corehq/messaging/scheduling/forms.py | 52 ++++++++++++++++--- .../smsbackends/connectid/backend.py | 3 +- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/corehq/messaging/scheduling/forms.py b/corehq/messaging/scheduling/forms.py index 094b1eb104ef..8ea0320d1cb0 100644 --- a/corehq/messaging/scheduling/forms.py +++ b/corehq/messaging/scheduling/forms.py @@ -86,6 +86,7 @@ AlertSchedule, CasePropertyTimedEvent, ConnectMessageContent, + ConnectMessageSurveyContent, CustomContent, EmailContent, FCMNotificationContent, @@ -341,7 +342,7 @@ def clean_fcm_action(self): return value def clean_app_and_form_unique_id(self): - if self.schedule_form.cleaned_data.get('content') != ScheduleForm.CONTENT_SMS_SURVEY: + if self.schedule_form.cleaned_data.get('content') not in (ScheduleForm.CONTENT_SMS_SURVEY, ScheduleForm.CONTENT_CONNECT_SURVEY): return None value = self.cleaned_data.get('app_and_form_unique_id') @@ -352,7 +353,7 @@ def clean_app_and_form_unique_id(self): return value def clean_survey_expiration_in_hours(self): - if self.schedule_form.cleaned_data.get('content') != ScheduleForm.CONTENT_SMS_SURVEY: + if self.schedule_form.cleaned_data.get('content') not in (ScheduleForm.CONTENT_SMS_SURVEY, ScheduleForm.CONTENT_CONNECT_SURVEY): return None value = self.cleaned_data.get('survey_expiration_in_hours') @@ -362,7 +363,7 @@ def clean_survey_expiration_in_hours(self): return value def clean_survey_reminder_intervals(self): - if self.schedule_form.cleaned_data.get('content') != ScheduleForm.CONTENT_SMS_SURVEY: + if self.schedule_form.cleaned_data.get('content') not in (ScheduleForm.CONTENT_SMS_SURVEY, ScheduleForm.CONTENT_CONNECT_SURVEY): return None if self.cleaned_data.get('survey_reminder_intervals_enabled') != 'Y': @@ -464,6 +465,19 @@ def distill_content(self): return ConnectMessageContent( message=self.cleaned_data['message'], ) + elif self.schedule_form.cleaned_data['content'] == ScheduleForm.CONTENT_CONNECT_SURVEY: + combined_id = self.cleaned_data['app_and_form_unique_id'] + app_id, form_unique_id = split_combined_id(combined_id) + return ConnectMessageSurveyContent( + app_id=app_id, + form_unique_id=form_unique_id, + expire_after=self.cleaned_data['survey_expiration_in_hours'] * 60, + reminder_intervals=self.cleaned_data['survey_reminder_intervals'], + submit_partially_completed_forms=self.schedule_form.cleaned_data[ + 'submit_partially_completed_forms'], + include_case_updates_in_partial_submissions=self.schedule_form.cleaned_data[ + 'include_case_updates_in_partial_submissions'] + ) else: raise ValueError("Unexpected value for content: '%s'" % self.schedule_form.cleaned_data['content']) @@ -586,8 +600,7 @@ def get_layout_fields(self): css_class="hqwebapp-select2", ), data_bind=( - "visible: $root.content() === '%s' || $root.content() === '%s'" % - (ScheduleForm.CONTENT_SMS_SURVEY, ScheduleForm.CONTENT_IVR_SURVEY) + "visible: $root.content() === '{ScheduleForm.CONTENT_SMS_SURVEY}' || $root.content() === '{ScheduleForm.CONTENT_CONNECT_SURVEY} || $root.content() === '{ScheduleForm.CONTENT_IVR_SURVEY}" ), ), crispy.Div( @@ -627,7 +640,7 @@ def get_layout_fields(self): ), data_bind="visible: survey_reminder_intervals_enabled() === 'Y'", ), - data_bind="visible: $root.content() === '%s'" % ScheduleForm.CONTENT_SMS_SURVEY, + data_bind=f"visible: $root.content() === '{ScheduleForm.CONTENT_SMS_SURVEY}' || $root.content() === '{ScheduleForm.CONTENT_CONNECT_SURVEY}", ), crispy.Div( crispy.Field('ivr_intervals'), @@ -693,6 +706,23 @@ def compute_initial(domain, content): result['fcm_message_type'] = content.message_type elif isinstance(content, ConnectMessageContent): result['message'] = content.message + elif isinstance(content, ConnectMessageSurveyContent): + result['app_and_form_unique_id'] = get_combined_id( + content.app_id, + content.form_unique_id + ) + result['survey_expiration_in_hours'] = content.expire_after // 60 + if (content.expire_after % 60) != 0: + # The old framework let you enter minutes. If it's not an even number of hours, round up. + result['survey_expiration_in_hours'] += 1 + + if content.reminder_intervals: + result['survey_reminder_intervals_enabled'] = 'Y' + result['survey_reminder_intervals'] = \ + ', '.join(str(i) for i in content.reminder_intervals) + else: + result['survey_reminder_intervals_enabled'] = 'N' + else: raise TypeError("Unexpected content type: %s" % type(content)) @@ -1168,6 +1198,7 @@ class ScheduleForm(Form): CONTENT_CUSTOM_SMS = 'custom_sms' CONTENT_FCM_NOTIFICATION = 'fcm_notification' CONTENT_CONNECT_MESSAGE = 'connect_message' + CONTENT_CONNECT_SURVEY = 'connect_survey' YES = 'Y' NO = 'N' @@ -1578,6 +1609,11 @@ def add_initial_for_content(self, initial): initial['content'] = self.CONTENT_FCM_NOTIFICATION elif isinstance(content, ConnectMessageContent): initial['conent'] = self.CONTENT_CONNECT_MESSAGE + elif isinstance(content, ConnectMessageSurveyContent): + initial['content'] = self.CONTENT_CONNECT_SURVEY + initial['submit_partially_completed_forms'] = content.submit_partially_completed_forms + initial['include_case_updates_in_partial_submissions'] = \ + content.include_case_updates_in_partial_submissions else: raise TypeError("Unexpected content type: %s" % type(content)) @@ -1797,6 +1833,7 @@ def add_additional_content_types(self): if self.can_use_connect: self.fields['content'].choices += [ (self.CONTENT_CONNECT_MESSAGE, _("Connect Message")), + (self.CONTENT_CONNECT_SURVEY, _("Connect Survey")) ] def enable_json_user_data_filter(self, initial): @@ -1844,8 +1881,7 @@ def get_after_content_layout_fields(self): _("Advanced Survey Options"), *self.get_advanced_survey_layout_fields(), data_bind=( - "visible: content() === '%s' || content() === '%s'" % - (self.CONTENT_SMS_SURVEY, self.CONTENT_IVR_SURVEY) + f"visible: content() === '{self.CONTENT_SMS_SURVEY}' || content() === '{self.CONTENT_IVR_SURVEY}' || content() === '{self.CONTENT_CONNECT_SURVEY}" ) ), crispy.Fieldset( diff --git a/corehq/messaging/smsbackends/connectid/backend.py b/corehq/messaging/smsbackends/connectid/backend.py index c8615218366e..b862e0d80b47 100644 --- a/corehq/messaging/smsbackends/connectid/backend.py +++ b/corehq/messaging/smsbackends/connectid/backend.py @@ -35,8 +35,7 @@ def send(self, message): return response.status_code == requests.codes.OK - def create_channel(self, user): - user_link = ConnectIDUserLink.objects.get(commcare_user=user) + def create_channel(self, user_link): response = requests.post( settings.CONNECTID_CHANNEL_URL, data={ From 84211249986378d1ab191530ae25d4cd11bb962f Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 27 Nov 2024 20:49:43 -0500 Subject: [PATCH 18/23] update models for connect message survey --- ...er_messagingevent_content_type_and_more.py | 23 +++++++++++++ corehq/apps/sms/models.py | 7 +++- corehq/apps/sms/urls.py | 2 +- corehq/apps/sms/views.py | 2 +- ...rtevent_connect_survey_content_and_more.py | 34 +++++++++++++++++++ .../messaging/scheduling/models/abstract.py | 10 +++++- 6 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 corehq/apps/sms/migrations/0062_alter_messagingevent_content_type_and_more.py create mode 100644 corehq/messaging/scheduling/migrations/0031_alertevent_connect_survey_content_and_more.py diff --git a/corehq/apps/sms/migrations/0062_alter_messagingevent_content_type_and_more.py b/corehq/apps/sms/migrations/0062_alter_messagingevent_content_type_and_more.py new file mode 100644 index 000000000000..0a604086f6ec --- /dev/null +++ b/corehq/apps/sms/migrations/0062_alter_messagingevent_content_type_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.15 on 2024-11-28 01:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sms', '0061_connectmessage_message_id_connectmessage_received_on'), + ] + + operations = [ + migrations.AlterField( + model_name='messagingevent', + name='content_type', + field=models.CharField(choices=[('NOP', 'None'), ('SMS', 'SMS Message'), ('CBK', 'SMS Expecting Callback'), ('SVY', 'SMS Survey'), ('IVR', 'IVR Survey'), ('VER', 'Phone Verification'), ('ADH', 'Manually Sent Message'), ('API', 'Message Sent Via API'), ('CHT', 'Message Sent Via Chat'), ('EML', 'Email'), ('FCM', 'FCM Push Notification'), ('CON', 'Connect Message'), ('CSY', 'Connect Message Survey')], max_length=3), + ), + migrations.AlterField( + model_name='messagingsubevent', + name='content_type', + field=models.CharField(choices=[('NOP', 'None'), ('SMS', 'SMS Message'), ('CBK', 'SMS Expecting Callback'), ('SVY', 'SMS Survey'), ('IVR', 'IVR Survey'), ('VER', 'Phone Verification'), ('ADH', 'Manually Sent Message'), ('API', 'Message Sent Via API'), ('CHT', 'Message Sent Via Chat'), ('EML', 'Email'), ('FCM', 'FCM Push Notification'), ('CON', 'Connect Message'), ('CSY', 'Connect Message Survey')], max_length=3), + ), + ] diff --git a/corehq/apps/sms/models.py b/corehq/apps/sms/models.py index e9905866d3d6..c464543de008 100644 --- a/corehq/apps/sms/models.py +++ b/corehq/apps/sms/models.py @@ -1045,6 +1045,7 @@ class MessagingEvent(models.Model, MessagingStatusMixin): CONTENT_EMAIL = 'EML' CONTENT_FCM_Notification = 'FCM' CONTENT_CONNECT = 'CON' + CONTENT_CONNECT_SURVEY = 'CSY' CONTENT_CHOICES = ( (CONTENT_NONE, gettext_noop('None')), @@ -1059,6 +1060,7 @@ class MessagingEvent(models.Model, MessagingStatusMixin): (CONTENT_EMAIL, gettext_noop('Email')), (CONTENT_FCM_Notification, gettext_noop('FCM Push Notification')), (CONTENT_CONNECT, gettext_noop('Connect Message')), + (CONTENT_CONNECT_SURVEY, gettext_noop('Connect Message Survey')), ) CONTENT_TYPE_SLUGS = { @@ -1074,6 +1076,7 @@ class MessagingEvent(models.Model, MessagingStatusMixin): CONTENT_EMAIL: "email", CONTENT_FCM_Notification: "fcm-notification", CONTENT_CONNECT: "connect", + CONTENT_CONNECT_SURVEY: "connect-survey", } RECIPIENT_CASE = 'CAS' @@ -1403,7 +1406,9 @@ def get_content_info_from_content_object(cls, domain, content): elif isinstance(content, ConnectMessageContent): return cls.CONTENT_CONNECT, None, None, None elif isinstance(content, ConnectMessageSurveyContent): - return cls.CONTENT_CONNECT, None, None, None + app, module, form, requires_input = content.get_memoized_app_module_form(domain) + form_name = form.full_path_name if form else None + return cls.CONTENT_CONNECT_SURVEY, content.app_id, content.form_unique_id, form_name else: return cls.CONTENT_NONE, None, None, None diff --git a/corehq/apps/sms/urls.py b/corehq/apps/sms/urls.py index f2c4ec0fba61..acc71b62b84b 100644 --- a/corehq/apps/sms/urls.py +++ b/corehq/apps/sms/urls.py @@ -61,7 +61,7 @@ url(r'^translations/upload/$', upload_sms_translations, name='upload_sms_translations'), url(r'^telerivet/', include(telerivet_urls)), url(r'^whatsapp_templates/$', WhatsAppTemplatesView.as_view(), name=WhatsAppTemplatesView.urlname), - url(r'^connect_messaging_user/$', ConnectMessagingUserView.as_view(), name=ConnectMessagingUserView), + url(r'^connect_messaging_user/$', ConnectMessagingUserView.as_view(), name=ConnectMessagingUserView.urlname), url(r'^create_channels/$', create_channels, name='create_channels'), ] diff --git a/corehq/apps/sms/views.py b/corehq/apps/sms/views.py index 1d5d2689ff24..be5321437a26 100644 --- a/corehq/apps/sms/views.py +++ b/corehq/apps/sms/views.py @@ -2074,7 +2074,7 @@ def dispatch(self, *args, **kwargs): def page_context(self): page_context = super(ConnectMessagingUserView, self).page_context page_context.update({ - "create_channel_url": reverse(("create_channels", args=[self.domain])) + "create_channel_url": reverse("create_channels", args=[self.domain]) }) diff --git a/corehq/messaging/scheduling/migrations/0031_alertevent_connect_survey_content_and_more.py b/corehq/messaging/scheduling/migrations/0031_alertevent_connect_survey_content_and_more.py new file mode 100644 index 000000000000..da0670ddb89f --- /dev/null +++ b/corehq/messaging/scheduling/migrations/0031_alertevent_connect_survey_content_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.15 on 2024-11-28 01:43 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('scheduling', '0030_remove_connectmessagesurveycontent_message_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='alertevent', + name='connect_survey_content', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='scheduling.connectmessagesurveycontent'), + ), + migrations.AddField( + model_name='casepropertytimedevent', + name='connect_survey_content', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='scheduling.connectmessagesurveycontent'), + ), + migrations.AddField( + model_name='randomtimedevent', + name='connect_survey_content', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='scheduling.connectmessagesurveycontent'), + ), + migrations.AddField( + model_name='timedevent', + name='connect_survey_content', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='scheduling.connectmessagesurveycontent'), + ), + ] diff --git a/corehq/messaging/scheduling/models/abstract.py b/corehq/messaging/scheduling/models/abstract.py index 1fe8fcefb914..4bd639a7ba48 100644 --- a/corehq/messaging/scheduling/models/abstract.py +++ b/corehq/messaging/scheduling/models/abstract.py @@ -248,6 +248,8 @@ class ContentForeignKeyMixin(models.Model): on_delete=models.CASCADE) connect_message_content = models.ForeignKey('scheduling.ConnectMessageContent', null=True, on_delete=models.CASCADE) + connect_survey_content = models.ForeignKey('scheduling.ConnectMessageSurveyContent', null=True, + on_delete=models.CASCADE) class Meta(object): abstract = True @@ -270,6 +272,8 @@ def content(self): return self.fcm_notification_content elif self.connect_message_content: return self.connect_message_content + elif self.connect_survey_content: + return self.connect_survey_content raise NoAvailableContent() @@ -285,7 +289,8 @@ def memoized_content(self): @content.setter def content(self, value): from corehq.messaging.scheduling.models import (SMSContent, EmailContent, SMSSurveyContent, - IVRSurveyContent, CustomContent, SMSCallbackContent, FCMNotificationContent, ConnectMessageContent) + IVRSurveyContent, CustomContent, SMSCallbackContent, FCMNotificationContent, + ConnectMessageContent, ConnectMessageSurveyContent) self.sms_content = None self.email_content = None @@ -294,6 +299,7 @@ def content(self, value): self.custom_content = None self.sms_callback_content = None self.connect_message_content = None + self.connect_survey_content = None if isinstance(value, SMSContent): self.sms_content = value @@ -311,6 +317,8 @@ def content(self, value): self.fcm_notification_content = value elif isinstance(value, ConnectMessageContent): self.connect_message_content = value + elif isinstance(value, ConnectMessageSurveyContent): + self.connect_survey_content = value else: raise UnknownContentType() From e7255577eeea802731d120c94f90771ecad5c69a Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 27 Nov 2024 21:20:57 -0500 Subject: [PATCH 19/23] return context --- corehq/apps/sms/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/corehq/apps/sms/views.py b/corehq/apps/sms/views.py index be5321437a26..9ad5091efbc8 100644 --- a/corehq/apps/sms/views.py +++ b/corehq/apps/sms/views.py @@ -2076,6 +2076,7 @@ def page_context(self): page_context.update({ "create_channel_url": reverse("create_channels", args=[self.domain]) }) + return page_context @domain_admin_required From a9b2f0855efc995bdc28b33249f0793ddd5c1f02 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 27 Nov 2024 21:52:37 -0500 Subject: [PATCH 20/23] correct template name --- .../{connect_messaging_users.html => connect_messaging_user.html} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename corehq/apps/sms/templates/sms/{connect_messaging_users.html => connect_messaging_user.html} (100%) diff --git a/corehq/apps/sms/templates/sms/connect_messaging_users.html b/corehq/apps/sms/templates/sms/connect_messaging_user.html similarity index 100% rename from corehq/apps/sms/templates/sms/connect_messaging_users.html rename to corehq/apps/sms/templates/sms/connect_messaging_user.html From d5935d2963aacaa0ca915d88deb1685b7a09a05e Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Wed, 27 Nov 2024 22:28:10 -0500 Subject: [PATCH 21/23] use correct context name --- corehq/apps/sms/templates/sms/connect_messaging_user.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corehq/apps/sms/templates/sms/connect_messaging_user.html b/corehq/apps/sms/templates/sms/connect_messaging_user.html index d8e3aadcf1fc..df4c83573d07 100644 --- a/corehq/apps/sms/templates/sms/connect_messaging_user.html +++ b/corehq/apps/sms/templates/sms/connect_messaging_user.html @@ -5,7 +5,7 @@ {% block page_content %} {% endblock %} From 7a59b8e02989f42671974195d85e93e639564bb4 Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Thu, 28 Nov 2024 13:15:47 -0500 Subject: [PATCH 22/23] add imports --- corehq/apps/sms/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/corehq/apps/sms/views.py b/corehq/apps/sms/views.py index 9ad5091efbc8..c96cfb58fb1c 100644 --- a/corehq/apps/sms/views.py +++ b/corehq/apps/sms/views.py @@ -127,11 +127,12 @@ ) from corehq.apps.users import models as user_models from corehq.apps.users.decorators import require_permission -from corehq.apps.users.models import CommCareUser, CouchUser, HqPermissions +from corehq.apps.users.models import CommCareUser, ConnectIDUserLink, CouchUser, HqPermissions from corehq.apps.users.views.mobile.users import EditCommCareUserView from corehq.form_processor.models import CommCareCase from corehq.form_processor.utils import is_commcarecase from corehq.messaging.scheduling.async_handlers import SMSSettingsAsyncHandler +from corehq.messaging.smsbackends.connectid.backend import ConnectBackend from corehq.messaging.smsbackends.telerivet.models import SQLTelerivetBackend from corehq.util.dates import iso_string_to_datetime from corehq.util.quickcache import quickcache From ef87ae1a690195d8e33bc1dfefce1c97a1a012cb Mon Sep 17 00:00:00 2001 From: Cal Ellowitz Date: Thu, 28 Nov 2024 13:56:40 -0500 Subject: [PATCH 23/23] update view args --- corehq/apps/sms/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/corehq/apps/sms/views.py b/corehq/apps/sms/views.py index c96cfb58fb1c..4997e2d78fe0 100644 --- a/corehq/apps/sms/views.py +++ b/corehq/apps/sms/views.py @@ -2081,8 +2081,8 @@ def page_context(self): @domain_admin_required -def create_channels(self, request, *args, **kwargs): - user_links = ConnectIDUserLink.objects.filter(domain=request.domain) +def create_channels(request, domain, *args, **kwargs): + user_links = ConnectIDUserLink.objects.filter(domain=domain) backend = ConnectBackend() for link in user_links: backend.create_channel(link)