From 225660b0d025565e6ca5a0f34bc1716e2871c44f Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Wed, 24 Mar 2021 16:59:16 +0200 Subject: [PATCH] Add support for Zenvia SMS --- .gitignore | 1 + code_check.py | 2 +- locale/en_US/LC_MESSAGES/django.po | 6 +- locale/es/LC_MESSAGES/django.po | 7 +- locale/fr/LC_MESSAGES/django.po | 7 +- locale/pt_BR/LC_MESSAGES/django.po | 7 +- locale/ru/LC_MESSAGES/django.po | 7 +- temba/channels/tests.py | 16 ++- temba/channels/types/zenvia_sms/__init__.py | 1 + temba/channels/types/zenvia_sms/tests.py | 120 ++++++++++++++++++ temba/channels/types/zenvia_sms/type.py | 104 +++++++++++++++ temba/channels/types/zenvia_whatsapp/tests.py | 1 + temba/channels/types/zenvia_whatsapp/views.py | 8 +- temba/settings_common.py | 3 +- temba/triggers/views.py | 4 +- 15 files changed, 277 insertions(+), 17 deletions(-) create mode 100644 temba/channels/types/zenvia_sms/__init__.py create mode 100644 temba/channels/types/zenvia_sms/tests.py create mode 100644 temba/channels/types/zenvia_sms/type.py diff --git a/.gitignore b/.gitignore index be3ac29c9ca..0d1832f9729 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ __pycache__/ .Python env/ venv/ +.venv/ build/ develop-eggs/ dist/ diff --git a/code_check.py b/code_check.py index b969cf4da14..5d86c0b97d3 100755 --- a/code_check.py +++ b/code_check.py @@ -43,7 +43,7 @@ def get_current_msgids(): saved_msgids = get_current_msgids() # re-extract locale files from source code - ignore_paths = ("env/*", "fabric/*", "media/*", "sitestatic/*", "static/*", "node_modules/*") + ignore_paths = ("env/*", ".venv/*", "fabric/*", "media/*", "sitestatic/*", "static/*", "node_modules/*") ignore_args = " ".join([f'--ignore="{p}"' for p in ignore_paths]) cmd(f"python manage.py makemessages -a -e haml,html,txt,py --no-location --no-wrap {ignore_args}") diff --git a/locale/en_US/LC_MESSAGES/django.po b/locale/en_US/LC_MESSAGES/django.po index c8d8550a50b..8d857e50f17 100644 --- a/locale/en_US/LC_MESSAGES/django.po +++ b/locale/en_US/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-04-06 21:32+0000\n" +"POT-Creation-Date: 2021-04-08 20:37+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -1843,6 +1843,10 @@ msgstr "" msgid "The account password provided by Zenvia" msgstr "" +#, python-format +msgid "If you have a %(link)s number, you can connect it to communicate with your contacts." +msgstr "" + #, python-format msgid "Unable to register webhook subscriptions: %(resp)s" msgstr "" diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po index 8c031a4fb77..87332fdc506 100644 --- a/locale/es/LC_MESSAGES/django.po +++ b/locale/es/LC_MESSAGES/django.po @@ -13,7 +13,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-04-06 21:32+0000\n" +"POT-Creation-Date: 2021-04-08 20:24+0000\n" "PO-Revision-Date: 2019-05-13 20:14+0000\n" "Last-Translator: Moy Moussan , 2021\n" "Language-Team: Spanish (https://www.transifex.com/rapidpro/teams/226/es/)\n" @@ -1859,6 +1859,11 @@ msgstr "El nombre de usuario de la cuenta proporcionado por Zenvia" msgid "The account password provided by Zenvia" msgstr "La contraseña de la cuenta proporcionada por Zenvia" +#, fuzzy, python-format +#| msgid "If you have a %(link)s number, you can connect it to communicate with your WhatsApp contacts." +msgid "If you have a %(link)s number, you can connect it to communicate with your contacts." +msgstr "Si tiene un número %(link)s, puede conectarlo para comunicarse con sus contactos de WhatsApp." + #, python-format msgid "Unable to register webhook subscriptions: %(resp)s" msgstr "" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 5cdebc2e55c..b543197cb90 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -12,7 +12,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-04-06 21:32+0000\n" +"POT-Creation-Date: 2021-04-08 20:37+0000\n" "PO-Revision-Date: 2019-05-13 20:14+0000\n" "Last-Translator: Jesus EKIE , 2020\n" "Language-Team: French (https://www.transifex.com/rapidpro/teams/226/fr/)\n" @@ -1858,6 +1858,11 @@ msgstr "Le nom d'utilisateur du compte fourni par Zenvia" msgid "The account password provided by Zenvia" msgstr "Le mot de passe du compte fourni par Zenvia" +#, fuzzy, python-format +#| msgid "If you have a %(link)s number, you can connect it to communicate with your WhatsApp contacts." +msgid "If you have a %(link)s number, you can connect it to communicate with your contacts." +msgstr "Si vous possédez un numéro %(link)s , vous pouvez le connecter pour communiquer avec vos contacts WhatsApp." + #, python-format msgid "Unable to register webhook subscriptions: %(resp)s" msgstr "" diff --git a/locale/pt_BR/LC_MESSAGES/django.po b/locale/pt_BR/LC_MESSAGES/django.po index 6a7da81e432..86fa264ac9b 100644 --- a/locale/pt_BR/LC_MESSAGES/django.po +++ b/locale/pt_BR/LC_MESSAGES/django.po @@ -17,7 +17,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-04-06 21:32+0000\n" +"POT-Creation-Date: 2021-04-08 20:37+0000\n" "PO-Revision-Date: 2019-05-13 20:14+0000\n" "Last-Translator: Ilhasoft , 2021\n" "Language-Team: Portuguese (Brazil) (https://www.transifex.com/rapidpro/teams/226/pt_BR/)\n" @@ -1865,6 +1865,11 @@ msgstr "O nome de usuário da conta fornecida pela Zenvia" msgid "The account password provided by Zenvia" msgstr "A senha da conta fornecida pela Zenvia" +#, fuzzy, python-format +#| msgid "If you have a %(link)s number, you can connect it to communicate with your WhatsApp contacts." +msgid "If you have a %(link)s number, you can connect it to communicate with your contacts." +msgstr "Se você possui um número %(link)s, você pode conectá-lo para se comunicar com os seus contatos do WhatsApp." + #, python-format msgid "Unable to register webhook subscriptions: %(resp)s" msgstr "" diff --git a/locale/ru/LC_MESSAGES/django.po b/locale/ru/LC_MESSAGES/django.po index 3f33cbeca39..3150ee5f6b1 100644 --- a/locale/ru/LC_MESSAGES/django.po +++ b/locale/ru/LC_MESSAGES/django.po @@ -12,7 +12,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-04-06 21:32+0000\n" +"POT-Creation-Date: 2021-04-08 20:37+0000\n" "PO-Revision-Date: 2019-05-13 20:14+0000\n" "Last-Translator: Hudson Brendon , 2020\n" "Language-Team: Russian (https://www.transifex.com/rapidpro/teams/226/ru/)\n" @@ -1872,6 +1872,11 @@ msgstr "Имя пользователя учетной записи, предо msgid "The account password provided by Zenvia" msgstr "Пароль учетной записи, предоставленный Zenvia" +#, fuzzy, python-format +#| msgid "If you have an enterprise WhatsApp account, you can connect it to communicate with your contacts" +msgid "If you have a %(link)s number, you can connect it to communicate with your contacts." +msgstr "Если у Вас есть корпоративная учетная запись WhatsApp, Вы можете подключить ее для общения с Вашими контактами" + #, python-format msgid "Unable to register webhook subscriptions: %(resp)s" msgstr "" diff --git a/temba/channels/tests.py b/temba/channels/tests.py index 15ecd328b61..2cd149fbb05 100644 --- a/temba/channels/tests.py +++ b/temba/channels/tests.py @@ -918,12 +918,13 @@ def test_claim_all(self): self.assertEqual(response.context["channel_types"]["PHONE"][0].code, "AC") self.assertEqual(response.context["channel_types"]["PHONE"][1].code, "T") self.assertEqual(response.context["channel_types"]["PHONE"][2].code, "TMS") - self.assertEqual(response.context["channel_types"]["PHONE"][-2].code, "WV") - self.assertEqual(response.context["channel_types"]["PHONE"][-1].code, "YO") + self.assertEqual(response.context["channel_types"]["PHONE"][-2].code, "YO") + self.assertEqual(response.context["channel_types"]["PHONE"][-1].code, "ZVS") self.assertEqual(response.context["channel_types"]["SOCIAL_MEDIA"][0].code, "D3") - self.assertEqual(response.context["channel_types"]["SOCIAL_MEDIA"][1].code, "TWA") - self.assertEqual(response.context["channel_types"]["SOCIAL_MEDIA"][2].code, "FBA") + self.assertEqual(response.context["channel_types"]["SOCIAL_MEDIA"][1].code, "ZVW") + self.assertEqual(response.context["channel_types"]["SOCIAL_MEDIA"][2].code, "TWA") + self.assertEqual(response.context["channel_types"]["SOCIAL_MEDIA"][3].code, "FBA") self.admin.groups.add(Group.objects.get(name="Beta")) @@ -936,12 +937,13 @@ def test_claim_all(self): self.assertEqual(response.context["channel_types"]["PHONE"][0].code, "AC") self.assertEqual(response.context["channel_types"]["PHONE"][1].code, "T") self.assertEqual(response.context["channel_types"]["PHONE"][2].code, "TMS") - self.assertEqual(response.context["channel_types"]["PHONE"][-2].code, "WV") - self.assertEqual(response.context["channel_types"]["PHONE"][-1].code, "YO") + self.assertEqual(response.context["channel_types"]["PHONE"][-2].code, "YO") + self.assertEqual(response.context["channel_types"]["PHONE"][-1].code, "ZVS") self.assertEqual(response.context["channel_types"]["SOCIAL_MEDIA"][0].code, "WA") self.assertEqual(response.context["channel_types"]["SOCIAL_MEDIA"][1].code, "D3") - self.assertEqual(response.context["channel_types"]["SOCIAL_MEDIA"][2].code, "TWA") + self.assertEqual(response.context["channel_types"]["SOCIAL_MEDIA"][2].code, "ZVW") + self.assertEqual(response.context["channel_types"]["SOCIAL_MEDIA"][3].code, "TWA") def test_register_unsupported_android(self): # remove our explicit country so it needs to be derived from channels diff --git a/temba/channels/types/zenvia_sms/__init__.py b/temba/channels/types/zenvia_sms/__init__.py new file mode 100644 index 00000000000..48de6db1eea --- /dev/null +++ b/temba/channels/types/zenvia_sms/__init__.py @@ -0,0 +1 @@ +from .type import ZenviaSMSType # noqa diff --git a/temba/channels/types/zenvia_sms/tests.py b/temba/channels/types/zenvia_sms/tests.py new file mode 100644 index 00000000000..00e0b3f60e4 --- /dev/null +++ b/temba/channels/types/zenvia_sms/tests.py @@ -0,0 +1,120 @@ +from unittest.mock import patch + +from django.forms import ValidationError +from django.urls import reverse + +from temba.tests import MockResponse, TembaTest + +from ...models import Channel +from .type import ZENVIA_MESSAGE_SUBSCRIPTION_ID, ZENVIA_STATUS_SUBSCRIPTION_ID, ZenviaSMSType + + +class ZenviaSMSTypeTest(TembaTest): + def test_claim(self): + Channel.objects.all().delete() + + self.login(self.admin) + + url = reverse("channels.types.zenvia_sms.claim") + + # should see the general channel claim page + response = self.client.get(reverse("channels.channel_claim")) + self.assertContains(response, url) + + # try to claim a channel + response = self.client.get(url) + post_data = response.context["form"].initial + + post_data["token"] = "12345" + post_data["country"] = "US" + post_data["number"] = "(206) 555-1212" + + response = self.client.post(url, post_data) + + channel = Channel.objects.get() + + self.assertEqual("US", channel.country) + self.assertTrue(channel.uuid) + self.assertEqual("+12065551212", channel.address) + self.assertEqual("12345", channel.config["api_key"]) + self.assertEqual("ZVS", channel.channel_type) + self.assertEqual("Zenvia SMS: +12065551212", channel.name) + + with patch("requests.post") as mock_patch: + mock_patch.side_effect = [MockResponse(400, '{ "error": true }')] + + try: + ZenviaSMSType().activate(channel) + self.fail("Should have thrown error activating channel") + except ValidationError: + pass + + self.assertFalse(channel.config.get(ZENVIA_MESSAGE_SUBSCRIPTION_ID)) + self.assertFalse(channel.config.get(ZENVIA_STATUS_SUBSCRIPTION_ID)) + + with patch("requests.post") as mock_post: + mock_post.side_effect = [ + MockResponse(200, '{"id": "message_123"}'), + MockResponse(400, '{"error": "failed"}'), + ] + try: + ZenviaSMSType().activate(channel) + except ValidationError: + pass + + self.assertEqual("12345", mock_post.call_args_list[0][1]["headers"]["X-API-TOKEN"]) + + self.assertEqual("message_123", channel.config.get(ZENVIA_MESSAGE_SUBSCRIPTION_ID)) + self.assertIsNone(channel.config.get(ZENVIA_STATUS_SUBSCRIPTION_ID)) + + with patch("requests.delete") as mock_delete: + mock_delete.return_value = MockResponse(204, "") + + # deactivate our channel + with self.settings(IS_PROD=True): + channel.release() + + self.assertEqual(1, mock_delete.call_count) + self.assertEqual( + "https://api.zenvia.com/v2/subscriptions/message_123", mock_delete.call_args_list[0][0][0] + ) + self.assertEqual("12345", mock_delete.call_args_list[0][1]["headers"]["X-API-TOKEN"]) + + # try to claim a channel + response = self.client.get(url) + post_data = response.context["form"].initial + + post_data["token"] = "12345" + post_data["country"] = "US" + post_data["number"] = "(206) 555-1212" + + response = self.client.post(url, post_data) + + channel = Channel.objects.filter(is_active=True).first() + + with patch("requests.post") as mock_post: + mock_post.side_effect = [ + MockResponse(200, '{"id": "message_123"}'), + MockResponse(200, '{"id": "status_123"}'), + ] + ZenviaSMSType().activate(channel) + + self.assertEqual("12345", mock_post.call_args_list[0][1]["headers"]["X-API-TOKEN"]) + + self.assertEqual("message_123", channel.config.get(ZENVIA_MESSAGE_SUBSCRIPTION_ID)) + self.assertEqual("status_123", channel.config.get(ZENVIA_STATUS_SUBSCRIPTION_ID)) + + with patch("requests.delete") as mock_delete: + mock_delete.return_value = MockResponse(400, "Error") + + # deactivate our channel + with self.settings(IS_PROD=True): + channel.release() + + self.assertEqual(2, mock_delete.call_count) + self.assertEqual( + "https://api.zenvia.com/v2/subscriptions/message_123", mock_delete.call_args_list[0][0][0] + ) + self.assertEqual("12345", mock_delete.call_args_list[0][1]["headers"]["X-API-TOKEN"]) + self.assertEqual("https://api.zenvia.com/v2/subscriptions/status_123", mock_delete.call_args_list[1][0][0]) + self.assertEqual("12345", mock_delete.call_args_list[1][1]["headers"]["X-API-TOKEN"]) diff --git a/temba/channels/types/zenvia_sms/type.py b/temba/channels/types/zenvia_sms/type.py new file mode 100644 index 00000000000..e9d99b5a511 --- /dev/null +++ b/temba/channels/types/zenvia_sms/type.py @@ -0,0 +1,104 @@ +import requests + +from django.forms import ValidationError +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ + +from temba.channels.types.zenvia_whatsapp.views import ClaimView +from temba.contacts.models import URN + +from ...models import Channel, ChannelType + +ZENVIA_MESSAGE_SUBSCRIPTION_ID = "zenvia_message_subscription_id" +ZENVIA_STATUS_SUBSCRIPTION_ID = "zenvia_status_subscription_id" + + +class ZenviaSMSType(ChannelType): + """ + An Zenvia SMS channel + """ + + code = "ZVS" + category = ChannelType.Category.PHONE + + courier_url = r"^zvs/(?P[a-z0-9\-]+)/(?Preceive|status)$" + + name = "Zenvia SMS" + + claim_blurb = _("If you have a %(link)s number, you can connect it to communicate with your contacts.") % { + "link": 'Zenvia SMS' + } + + claim_view = ClaimView + + schemes = [URN.TEL_SCHEME] + max_length = 1600 + + def update_webhook(self, channel, url, event_type): + headers = { + "X-API-TOKEN": channel.config[Channel.CONFIG_API_KEY], + "Content-Type": "application/json", + } + + conf_url = "https://api.zenvia.com/v2/subscriptions" + + # set our webhook + payload = { + "eventType": event_type, + "webhook": {"url": url, "headers": {}}, + "status": "ACTIVE", + "version": "v2", + "criteria": {"channel": "sms"}, + } + if event_type == "MESSAGE": + payload["criteria"]["direction"] = "IN" + + resp = requests.post(conf_url, json=payload, headers=headers) + + if resp.status_code != 200: + raise ValidationError( + _("Unable to register webhook subscriptions: %(resp)s"), params={"resp": resp.content} + ) + + return resp.json()["id"] + + def deactivate(self, channel): + headers = { + "X-API-TOKEN": channel.config[Channel.CONFIG_API_KEY], + "Content-Type": "application/json", + } + + subscriptionIds = [ + channel.config.get(ZENVIA_MESSAGE_SUBSCRIPTION_ID), + channel.config.get(ZENVIA_STATUS_SUBSCRIPTION_ID), + ] + + errored = False + + for subscriptionId in subscriptionIds: + if not subscriptionId: + continue + + conf_url = f"https://api.zenvia.com/v2/subscriptions/{subscriptionId}" + resp = requests.delete(conf_url, headers=headers) + + if resp.status_code != 204: + errored = True + + if errored: + raise ValidationError(_("Unable to remove webhook subscriptions: %(resp)s"), params={"resp": resp.content}) + + def activate(self, channel): + domain = channel.org.get_brand_domain() + + receive_url = "https://" + domain + reverse("courier.zvs", args=[channel.uuid, "receive"]) + messageSubscriptionId = self.update_webhook(channel, receive_url, "MESSAGE") + + channel.config[ZENVIA_MESSAGE_SUBSCRIPTION_ID] = messageSubscriptionId + + status_url = "https://" + domain + reverse("courier.zvs", args=[channel.uuid, "status"]) + statusSubscriptionId = self.update_webhook(channel, status_url, "MESSAGE_STATUS") + + channel.config[ZENVIA_STATUS_SUBSCRIPTION_ID] = statusSubscriptionId + + channel.save() diff --git a/temba/channels/types/zenvia_whatsapp/tests.py b/temba/channels/types/zenvia_whatsapp/tests.py index 9c813cf3f35..146a4bcbc8a 100644 --- a/temba/channels/types/zenvia_whatsapp/tests.py +++ b/temba/channels/types/zenvia_whatsapp/tests.py @@ -38,6 +38,7 @@ def test_claim(self): self.assertEqual("+12065551212", channel.address) self.assertEqual("12345", channel.config["api_key"]) self.assertEqual("ZVW", channel.channel_type) + self.assertEqual("Zenvia WhatsApp: +12065551212", channel.name) with patch("requests.post") as mock_patch: mock_patch.side_effect = [MockResponse(400, '{ "error": true }')] diff --git a/temba/channels/types/zenvia_whatsapp/views.py b/temba/channels/types/zenvia_whatsapp/views.py index 50d5e345a68..d03c3e6a4fd 100644 --- a/temba/channels/types/zenvia_whatsapp/views.py +++ b/temba/channels/types/zenvia_whatsapp/views.py @@ -53,12 +53,18 @@ def form_valid(self, form): config = {Channel.CONFIG_API_KEY: data["token"]} + channel_type_name = "" + if self.channel_type.code == "ZVW": + channel_type_name = "WhatsApp" + if self.channel_type.code == "ZVS": + channel_type_name = "SMS" + self.object = Channel.create( org, user, data["country"], self.channel_type, - name="Zenvia WhatsApp: %s" % data["number"], + name=f"Zenvia {channel_type_name}: {data['number']}", address=data["number"], config=config, ) diff --git a/temba/settings_common.py b/temba/settings_common.py index 114435ffe4d..e6eaea0cdd7 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -1097,6 +1097,7 @@ def traces_sampler(sampling_context): # pragma: no cover "temba.channels.types.whatsapp.WhatsAppType", "temba.channels.types.textit_whatsapp.TextItWhatsAppType", "temba.channels.types.dialog360.Dialog360Type", + "temba.channels.types.zenvia_whatsapp.ZenviaWhatsAppType", "temba.channels.types.twilio.TwilioType", "temba.channels.types.twilio_whatsapp.TwilioWhatsappType", "temba.channels.types.twilio_messaging_service.TwilioMessagingServiceType", @@ -1154,7 +1155,7 @@ def traces_sampler(sampling_context): # pragma: no cover "temba.channels.types.wechat.WeChatType", "temba.channels.types.yo.YoType", "temba.channels.types.zenvia.ZenviaType", - "temba.channels.types.zenvia_whatsapp.ZenviaWhatsAppType", + "temba.channels.types.zenvia_sms.ZenviaSMSType", "temba.channels.types.android.AndroidType", "temba.channels.types.discord.DiscordType", "temba.channels.types.rocketchat.RocketChatType", diff --git a/temba/triggers/views.py b/temba/triggers/views.py index 295cef57de2..d12dd6e4ffe 100644 --- a/temba/triggers/views.py +++ b/temba/triggers/views.py @@ -5,7 +5,7 @@ from django.db.models import Min from django.http import HttpResponse, HttpResponseRedirect from django.urls import reverse -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ngettext_lazy, ugettext_lazy as _ from temba.channels.models import Channel from temba.contacts.models import ContactGroup, ContactURN @@ -210,7 +210,7 @@ def clean(self, value): response = forms.CharField( widget=CompletionTextarea(attrs={"placeholder": _("Hi @contact.name!")}), required=False, - label=_("Response"), + label=ngettext_lazy("Response", "Responses", 1), help_text=_("The message to send in response after they join the group (optional)"), )