Skip to content

Commit

Permalink
add messaging apis
Browse files Browse the repository at this point in the history
  • Loading branch information
snopoke committed Oct 10, 2023
1 parent efbd954 commit 59c8a41
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 16 deletions.
2 changes: 1 addition & 1 deletion commcare_connect/connect_id_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .main import fetch_users # noqa: F401
from .main import fetch_users, send_message, send_message_bulk # noqa: F401
19 changes: 17 additions & 2 deletions commcare_connect/connect_id_client/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
62 changes: 62 additions & 0 deletions commcare_connect/connect_id_client/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import dataclasses
from enum import StrEnum


@dataclasses.dataclass
Expand All @@ -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}
88 changes: 86 additions & 2 deletions commcare_connect/connect_id_client/tests.py
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand All @@ -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)]
10 changes: 2 additions & 8 deletions requirements/base.txt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 59c8a41

Please sign in to comment.