From 59c8a413142c342637ac08bba3921586fe1dce52 Mon Sep 17 00:00:00 2001 From: Simon Kelly Date: Tue, 10 Oct 2023 17:09:23 +0200 Subject: [PATCH] add messaging apis --- .../connect_id_client/__init__.py | 2 +- commcare_connect/connect_id_client/main.py | 19 +++- commcare_connect/connect_id_client/models.py | 62 +++++++++++++ commcare_connect/connect_id_client/tests.py | 88 ++++++++++++++++++- requirements/base.txt | 10 +-- requirements/dev.txt | 2 +- tasks.py | 7 +- 7 files changed, 174 insertions(+), 16 deletions(-) diff --git a/commcare_connect/connect_id_client/__init__.py b/commcare_connect/connect_id_client/__init__.py index eef3dd24..fc3376b9 100644 --- a/commcare_connect/connect_id_client/__init__.py +++ b/commcare_connect/connect_id_client/__init__.py @@ -1 +1 @@ -from .main import fetch_users # noqa: F401 +from .main import fetch_users, send_message, send_message_bulk # noqa: F401 diff --git a/commcare_connect/connect_id_client/main.py b/commcare_connect/connect_id_client/main.py index 0d12851c..5e36d0bf 100644 --- a/commcare_connect/connect_id_client/main.py +++ b/commcare_connect/connect_id_client/main.py @@ -2,18 +2,33 @@ from django.conf import settings from httpx import BasicAuth, Response -from commcare_connect.connect_id_client.models import ConnectIdUser +from commcare_connect.connect_id_client.models import ConnectIdUser, Message, MessagingBulkResponse, MessagingResponse GET = "GET" POST = "POST" -def fetch_users(phone_number_list) -> list[ConnectIdUser]: +def fetch_users(phone_number_list: list[str]) -> list[ConnectIdUser]: response = _make_request(GET, "/users/fetch_users", params={"phone_numbers": phone_number_list}) data = response.json() return [ConnectIdUser(**user_dict) for user_dict in data["found_users"]] +def send_message(message: Message): + """Send a push notification to a user.""" + response = _make_request(POST, "/messaging/send/", json=message.asdict()) + data = response.json() + return MessagingResponse.build(**data) + + +def send_message_bulk(messages: list[Message]) -> MessagingBulkResponse: + """Send a push notification to multiple users.""" + json = {"messages": [message.asdict() for message in messages]} + response = _make_request(POST, "/messaging/send_bulk/", json=json) + data = response.json() + return MessagingBulkResponse.build(**data) + + def _make_request(method, path, params=None, json=None) -> Response: if json and not method == "POST": raise ValueError("json can only be used with POST requests") diff --git a/commcare_connect/connect_id_client/models.py b/commcare_connect/connect_id_client/models.py index 3e76f3d6..915bb282 100644 --- a/commcare_connect/connect_id_client/models.py +++ b/commcare_connect/connect_id_client/models.py @@ -1,4 +1,5 @@ import dataclasses +from enum import StrEnum @dataclasses.dataclass @@ -9,3 +10,64 @@ class ConnectIdUser: def __str__(self) -> str: return f"{self.name} ({self.username})" + + +class MessageStatus(StrEnum): + success = "success" + error = "error" + deactivated = "deactivated" + + +@dataclasses.dataclass +class UserMessageStatus: + username: str + status: MessageStatus + + @classmethod + def build(cls, username: str, status: str): + return cls(username, MessageStatus(status)) + + +@dataclasses.dataclass +class MessagingResponse: + all_success: bool + responses: list[UserMessageStatus] + + @classmethod + def build(cls, all_success: bool, responses: list[dict]): + return cls(all_success, [UserMessageStatus.build(**response) for response in responses]) + + def get_failures(self) -> list[UserMessageStatus]: + return [response for response in self.responses if response.status != MessageStatus.success] + + +@dataclasses.dataclass +class MessagingBulkResponse: + all_success: bool + messages: list[MessagingResponse] + + @classmethod + def build(cls, all_success: bool, messages: list[dict]): + return cls(all_success, [MessagingResponse.build(**message) for message in messages]) + + def get_failures(self) -> list[list[UserMessageStatus]]: + """Return a list of lists of UserMessageStatus objects, where each + inner list is the list of failures for a single message. + + Usage: + for message, failures in zip(messages, response.get_failures()): + if failures: + # do something with the failures + """ + return [message.get_failures() for message in self.messages] + + +@dataclasses.dataclass +class Message: + usernames: list[str] + title: str = None + body: str = None + data: dict = None + + def asdict(self): + return {k: v for k, v in dataclasses.asdict(self).items() if v is not None} diff --git a/commcare_connect/connect_id_client/tests.py b/commcare_connect/connect_id_client/tests.py index 9761a4ef..13b0d26b 100644 --- a/commcare_connect/connect_id_client/tests.py +++ b/commcare_connect/connect_id_client/tests.py @@ -1,8 +1,12 @@ -from .main import fetch_users +from httpx import URL + +from .main import fetch_users, send_message, send_message_bulk +from .models import Message, MessageStatus def test_fetch_users(httpx_mock): httpx_mock.add_response( + method="GET", json={ "found_users": [ { @@ -16,10 +20,90 @@ def test_fetch_users(httpx_mock): "phone_number": "phone_number2", }, ] - } + }, ) users = fetch_users(["phone_number1", "phone_number2"]) assert len(users) == 2 assert users[0].name == "name1" assert users[1].name == "name2" + + request = httpx_mock.get_request() + assert URL(request.url).params.get_list("phone_numbers") == ["phone_number1", "phone_number2"] + + +def test_send_message(httpx_mock): + httpx_mock.add_response( + method="POST", + match_json={"usernames": ["user_name1"], "body": "test message"}, + json={"all_success": True, "responses": [{"username": "user_name1", "status": "success"}]}, + ) + print(Message(usernames=["user_name1"], body="test message").asdict()) + result = send_message(Message(usernames=["user_name1"], body="test message")) + assert result.all_success is True + assert len(result.responses) == 1 + assert result.responses[0].username == "user_name1" + assert result.responses[0].status == MessageStatus.success + + +def test_send_message_bulk(httpx_mock): + httpx_mock.add_response( + match_json={ + "messages": [ + { + "usernames": ["user_name1", "user_name2"], + "body": "test message1", + }, + { + "usernames": ["user_name3", "user_name4"], + "body": "test message2", + }, + ] + }, + json={ + "all_success": False, + "messages": [ + { + "all_success": True, + "responses": [ + {"status": "success", "username": "user_name1"}, + {"status": "success", "username": "user_name2"}, + ], + }, + { + "all_success": False, + "responses": [ + {"status": "error", "username": "user_name3"}, + {"status": "deactivated", "username": "user_name4"}, + ], + }, + ], + }, + ) + + result = send_message_bulk( + [ + Message(usernames=["user_name1", "user_name2"], body="test message1"), + Message(usernames=["user_name3", "user_name4"], body="test message2"), + ] + ) + assert result.all_success is False + assert len(result.messages) == 2 + + assert result.messages[0].all_success is True + assert [(resp.username, resp.status) for resp in result.messages[0].responses] == [ + ("user_name1", MessageStatus.success), + ("user_name2", MessageStatus.success), + ] + assert result.messages[0].responses[0].username == "user_name1" + assert result.messages[0].responses[0].status == MessageStatus.success + assert result.messages[0].responses[1].username == "user_name2" + assert result.messages[0].responses[1].status == MessageStatus.success + + assert result.messages[1].all_success is False + assert [(resp.username, resp.status) for resp in result.messages[1].responses] == [ + ("user_name3", MessageStatus.error), + ("user_name4", MessageStatus.deactivated), + ] + + assert result.get_failures() == [[], list(result.messages[1].responses)] diff --git a/requirements/base.txt b/requirements/base.txt index 677f6ac7..00cf97a8 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # inv requirements @@ -23,9 +23,7 @@ argon2-cffi-bindings==21.2.0 asgiref==3.7.2 # via django async-timeout==4.0.3 - # via - # aiohttp - # redis + # via aiohttp attrs==23.1.0 # via # aiohttp @@ -121,8 +119,6 @@ drf-spectacular==0.26.4 # via -r requirements/base.in et-xmlfile==1.1.0 # via openpyxl -exceptiongroup==1.1.3 - # via anyio flatten-dict==0.4.2 # via -r requirements/base.in frozenlist==1.4.0 @@ -241,8 +237,6 @@ text-unidecode==1.3 # via python-slugify twilio==8.8.0 # via -r requirements/base.in -typing-extensions==4.7.1 - # via asgiref tzdata==2023.3 # via # celery diff --git a/requirements/dev.txt b/requirements/dev.txt index 6930d0e0..54c5146b 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -179,7 +179,7 @@ pytest==7.4.0 # pytest-httpx pytest-django==4.5.2 # via -r requirements/dev.in -pytest-httpx==0.23.1 +pytest-httpx==0.24.0 # via -r requirements/dev.in python-dateutil==2.8.2 # via diff --git a/tasks.py b/tasks.py index fbd12ac4..c5c1d299 100644 --- a/tasks.py +++ b/tasks.py @@ -32,11 +32,14 @@ def down(c: Context): @task -def requirements(c: Context, upgrade=False): - """Re-compile the pip requirements files""" +def requirements(c: Context, upgrade=False, upgrade_package=None): + if upgrade and upgrade_package: + raise Exit("Cannot specify both upgrade and upgrade-package", -1) args = " -U" if upgrade else "" cmd_base = "pip-compile -q --resolver=backtracking" env = {"CUSTOM_COMPILE_COMMAND": "inv requirements"} + if upgrade_package: + cmd_base += f" --upgrade-package {upgrade_package}" c.run(f"{cmd_base} requirements/base.in{args}", env=env) c.run(f"{cmd_base} requirements/dev.in{args}", env=env) # can't use backtracking resolver for now: https://github.com/pypa/pip/issues/8713