Send push notifications on new chat messages #9

Merged
Ghasemi merged 1 commit from feature/notification into master 2026-08-01 09:26:46 -04:00
24 changed files with 2023 additions and 3 deletions

View file

@ -12,12 +12,16 @@ OAUTH2_PROVIDER_BASE_PUBLIC_URL=https://accounts.example.com/oauth2
OAUTH2_PROVIDER_BASE_PRIVATE_URL=https://accounts.example.com/oauth2 OAUTH2_PROVIDER_BASE_PRIVATE_URL=https://accounts.example.com/oauth2
OAUTH2_PROVIDER_CLIENT_ID= OAUTH2_PROVIDER_CLIENT_ID=
OAUTH2_PROVIDER_CLIENT_SECRET= OAUTH2_PROVIDER_CLIENT_SECRET=
# Must include accounts.profile:retrieve for the accounts client (see ACCOUNTS_BASE_PUBLIC_URL) to read user profiles # Must include accounts.profile:retrieve for the accounts client (see ACCOUNTS_BASE_PUBLIC_URL)
# and notifications.application.push:send for the notifications client (see NOTIFICATIONS_BASE_PUBLIC_URL)
OAUTH2_PROVIDER_SCOPES= OAUTH2_PROVIDER_SCOPES=
# Gooyal Accounts service — used to fetch public user profiles (name, avatar) for chat responses # Gooyal Accounts service — used to fetch public user profiles (name, avatar) for chat responses
ACCOUNTS_BASE_PUBLIC_URL=https://accounts.example.com ACCOUNTS_BASE_PUBLIC_URL=https://accounts.example.com
# Gooyal Notifications service — used to push a notification to the other participant(s) when a message is sent
NOTIFICATIONS_BASE_PUBLIC_URL=https://notifications.example.com
# Redis (used for cache, channel layer, and long-poll) # Redis (used for cache, channel layer, and long-poll)
REDIS_BASE_URL=redis://127.0.0.1:6379/1 REDIS_BASE_URL=redis://127.0.0.1:6379/1

View file

@ -1,7 +1,42 @@
import logging
from apps.chat.events.event import MessageSentEvent from apps.chat.events.event import MessageSentEvent
from apps.chat.models import ConversationParticipant
from apps.chat.services.account import AccountService
from utils.clients import notifications_client
logger = logging.getLogger(__name__)
MEDIA_MESSAGE_PREVIEWS = {
"image": "Sent a photo",
"video": "Sent a video",
"voice": "Sent a voice message",
}
class PushPublisher: class PushPublisher:
# Future: FCM / APNS integration def __init__(self, account_service: AccountService | None = None, push_user=None):
self._account = account_service or AccountService()
self._push_user = push_user or notifications_client.push_user
def publish(self, event: MessageSentEvent) -> None: def publish(self, event: MessageSentEvent) -> None:
pass recipient_uuids = list(
ConversationParticipant.objects.filter(conversation_id=event.chat_uuid)
.exclude(user_uuid=event.sender_uuid)
.values_list("user_uuid", flat=True)
)
if not recipient_uuids:
return
sender_info = self._account.get_public_info([event.sender_uuid])
title = sender_info.get(str(event.sender_uuid), {}).get("name") or "New message"
body = event.payload.get("text") or MEDIA_MESSAGE_PREVIEWS.get(
event.message_type, "Sent a message"
)
extras = {"conversation_uuid": str(event.chat_uuid), "post_id": event.post_id}
for recipient_uuid in recipient_uuids:
try:
self._push_user(recipient_uuid, title=title, message=body, extras=extras)
except Exception:
logger.exception("Failed to push notification to user %s", recipient_uuid)

View file

@ -0,0 +1,119 @@
import uuid
from unittest.mock import Mock
import pytest
from apps.chat.events.event import MessageSentEvent
from apps.chat.events.publishers.push import PushPublisher
from apps.chat.models import Conversation, ConversationParticipant
def _make_publisher(*, public_info=None, push_user=None):
account_service = Mock()
account_service.get_public_info.return_value = public_info or {}
return (
PushPublisher(account_service=account_service, push_user=push_user or Mock()),
account_service,
)
@pytest.mark.django_db
def test_push_notifies_other_participants_with_sender_name():
conv = Conversation.objects.create(mattermost_channel_id="ch-push")
sender_uuid = uuid.uuid4()
recipient_uuid = uuid.uuid4()
ConversationParticipant.objects.create(conversation=conv, user_uuid=sender_uuid)
ConversationParticipant.objects.create(conversation=conv, user_uuid=recipient_uuid)
push_user = Mock()
publisher, account_service = _make_publisher(
public_info={str(sender_uuid): {"name": "Alice"}}, push_user=push_user
)
event = MessageSentEvent(
chat_uuid=conv.uuid,
post_id="post-1",
sender_uuid=sender_uuid,
message_type="text",
payload={"text": "Hello there", "object_key": None, "url": None},
)
publisher.publish(event)
account_service.get_public_info.assert_called_once_with([sender_uuid])
push_user.assert_called_once_with(
recipient_uuid,
title="Alice",
message="Hello there",
extras={"conversation_uuid": str(conv.uuid), "post_id": "post-1"},
)
@pytest.mark.django_db
def test_push_skips_sender_and_uses_media_preview_when_no_text():
conv = Conversation.objects.create(mattermost_channel_id="ch-push-media")
sender_uuid = uuid.uuid4()
recipient_uuid = uuid.uuid4()
ConversationParticipant.objects.create(conversation=conv, user_uuid=sender_uuid)
ConversationParticipant.objects.create(conversation=conv, user_uuid=recipient_uuid)
push_user = Mock()
publisher, _ = _make_publisher(push_user=push_user)
event = MessageSentEvent(
chat_uuid=conv.uuid,
post_id="post-2",
sender_uuid=sender_uuid,
message_type="image",
payload={"text": None, "object_key": "images/x.jpg", "url": "http://x"},
)
publisher.publish(event)
push_user.assert_called_once()
called_uuid, kwargs = push_user.call_args[0][0], push_user.call_args[1]
assert called_uuid == recipient_uuid
assert kwargs["title"] == "New message"
assert kwargs["message"] == "Sent a photo"
@pytest.mark.django_db
def test_push_noop_when_no_other_participants():
conv = Conversation.objects.create(mattermost_channel_id="ch-push-solo")
sender_uuid = uuid.uuid4()
ConversationParticipant.objects.create(conversation=conv, user_uuid=sender_uuid)
push_user = Mock()
publisher, account_service = _make_publisher(push_user=push_user)
event = MessageSentEvent(
chat_uuid=conv.uuid,
post_id="post-3",
sender_uuid=sender_uuid,
message_type="text",
payload={"text": "Hi", "object_key": None, "url": None},
)
publisher.publish(event)
push_user.assert_not_called()
account_service.get_public_info.assert_not_called()
@pytest.mark.django_db
def test_push_swallows_per_recipient_failures():
conv = Conversation.objects.create(mattermost_channel_id="ch-push-fail")
sender_uuid = uuid.uuid4()
recipient_uuid = uuid.uuid4()
ConversationParticipant.objects.create(conversation=conv, user_uuid=sender_uuid)
ConversationParticipant.objects.create(conversation=conv, user_uuid=recipient_uuid)
push_user = Mock(side_effect=Exception("notifications service down"))
publisher, _ = _make_publisher(push_user=push_user)
event = MessageSentEvent(
chat_uuid=conv.uuid,
post_id="post-4",
sender_uuid=sender_uuid,
message_type="text",
payload={"text": "Hi", "object_key": None, "url": None},
)
publisher.publish(event) # must not raise

View file

@ -10,6 +10,7 @@ OAUTH2_PROVIDER_CLIENT_ID=QkeJuvsPvwV2wjO2InQ7AIKfhCr0HqBWNtit2EHj
OAUTH2_PROVIDER_CLIENT_SECRET=S9AYajQRffgI7SzsxUmpzY7IMkVqlux8sW0A1scoBKgNfUFjXk3uwIMGbkBF9erMYONNVbSWemgODo6ArCDlg4UXYhaSZ8zolLhagfJ5Fk92RmrMLyY2rKv4cb209tPw OAUTH2_PROVIDER_CLIENT_SECRET=S9AYajQRffgI7SzsxUmpzY7IMkVqlux8sW0A1scoBKgNfUFjXk3uwIMGbkBF9erMYONNVbSWemgODo6ArCDlg4UXYhaSZ8zolLhagfJ5Fk92RmrMLyY2rKv4cb209tPw
OAUTH2_PROVIDER_SCOPES=accounts.profile:retrieve wallet.application.deposit:verify wallet.application.deposit:submit OAUTH2_PROVIDER_SCOPES=accounts.profile:retrieve wallet.application.deposit:verify wallet.application.deposit:submit
ACCOUNTS_BASE_PUBLIC_URL=https://accounts-staging.gooyal.ir ACCOUNTS_BASE_PUBLIC_URL=https://accounts-staging.gooyal.ir
NOTIFICATIONS_BASE_PUBLIC_URL=https://notifications-staging.gooyal.ir
REDIS_BASE_URL=redis://127.0.0.1:6379/1 REDIS_BASE_URL=redis://127.0.0.1:6379/1
MATTERMOST_TOKEN=3fktq5yzw7yp7m4ty7d3nx8gph MATTERMOST_TOKEN=3fktq5yzw7yp7m4ty7d3nx8gph

View file

@ -0,0 +1,8 @@
"""A client library for accessing Notifications"""
from .client import AuthenticatedClient, Client
__all__ = (
"AuthenticatedClient",
"Client",
)

View file

@ -0,0 +1 @@
"""Contains methods for accessing the API"""

View file

@ -0,0 +1 @@
"""Contains endpoint functions for accessing the API"""

View file

@ -0,0 +1,164 @@
from http import HTTPStatus
from typing import Any, Optional, Union
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.email import Email
from ...types import Response
def _get_kwargs(
user_uuid: str,
*,
body: Email,
) -> dict[str, Any]:
headers: dict[str, Any] = {}
_kwargs: dict[str, Any] = {
"method": "post",
"url": f"/email/application/{user_uuid}/application/",
}
_kwargs["json"] = body.to_dict()
headers["Content-Type"] = "application/json"
_kwargs["headers"] = headers
return _kwargs
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Email]:
if response.status_code == 201:
response_201 = Email.from_dict(response.json())
return response_201
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Email]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
body: Email,
) -> Response[Email]:
"""
Args:
user_uuid (str):
body (Email):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Email]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
body=body,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
user_uuid: str,
*,
client: AuthenticatedClient,
body: Email,
) -> Optional[Email]:
"""
Args:
user_uuid (str):
body (Email):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Email
"""
return sync_detailed(
user_uuid=user_uuid,
client=client,
body=body,
).parsed
async def asyncio_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
body: Email,
) -> Response[Email]:
"""
Args:
user_uuid (str):
body (Email):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Email]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
body=body,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
user_uuid: str,
*,
client: AuthenticatedClient,
body: Email,
) -> Optional[Email]:
"""
Args:
user_uuid (str):
body (Email):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Email
"""
return (
await asyncio_detailed(
user_uuid=user_uuid,
client=client,
body=body,
)
).parsed

View file

@ -0,0 +1,183 @@
from http import HTTPStatus
from typing import Any, Optional, Union
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.paginated_email_list import PaginatedEmailList
from ...types import UNSET, Response, Unset
def _get_kwargs(
user_uuid: str,
*,
limit: Union[Unset, int] = UNSET,
offset: Union[Unset, int] = UNSET,
) -> dict[str, Any]:
params: dict[str, Any] = {}
params["limit"] = limit
params["offset"] = offset
params = {k: v for k, v in params.items() if v is not UNSET and v is not None}
_kwargs: dict[str, Any] = {
"method": "get",
"url": f"/email/application/{user_uuid}/application/",
"params": params,
}
return _kwargs
def _parse_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Optional[PaginatedEmailList]:
if response.status_code == 200:
response_200 = PaginatedEmailList.from_dict(response.json())
return response_200
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Response[PaginatedEmailList]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: Union[Unset, int] = UNSET,
offset: Union[Unset, int] = UNSET,
) -> Response[PaginatedEmailList]:
"""
Args:
user_uuid (str):
limit (Union[Unset, int]):
offset (Union[Unset, int]):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PaginatedEmailList]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
limit=limit,
offset=offset,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: Union[Unset, int] = UNSET,
offset: Union[Unset, int] = UNSET,
) -> Optional[PaginatedEmailList]:
"""
Args:
user_uuid (str):
limit (Union[Unset, int]):
offset (Union[Unset, int]):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PaginatedEmailList
"""
return sync_detailed(
user_uuid=user_uuid,
client=client,
limit=limit,
offset=offset,
).parsed
async def asyncio_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: Union[Unset, int] = UNSET,
offset: Union[Unset, int] = UNSET,
) -> Response[PaginatedEmailList]:
"""
Args:
user_uuid (str):
limit (Union[Unset, int]):
offset (Union[Unset, int]):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PaginatedEmailList]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
limit=limit,
offset=offset,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: Union[Unset, int] = UNSET,
offset: Union[Unset, int] = UNSET,
) -> Optional[PaginatedEmailList]:
"""
Args:
user_uuid (str):
limit (Union[Unset, int]):
offset (Union[Unset, int]):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PaginatedEmailList
"""
return (
await asyncio_detailed(
user_uuid=user_uuid,
client=client,
limit=limit,
offset=offset,
)
).parsed

View file

@ -0,0 +1 @@
"""Contains endpoint functions for accessing the API"""

View file

@ -0,0 +1,164 @@
from http import HTTPStatus
from typing import Any, Optional, Union
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.push_message import PushMessage
from ...types import Response
def _get_kwargs(
user_uuid: str,
*,
body: PushMessage,
) -> dict[str, Any]:
headers: dict[str, Any] = {}
_kwargs: dict[str, Any] = {
"method": "post",
"url": f"/push/application/{user_uuid}/application/",
}
_kwargs["json"] = body.to_dict()
headers["Content-Type"] = "application/json"
_kwargs["headers"] = headers
return _kwargs
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[PushMessage]:
if response.status_code == 201:
response_201 = PushMessage.from_dict(response.json())
return response_201
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[PushMessage]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
body: PushMessage,
) -> Response[PushMessage]:
"""
Args:
user_uuid (str):
body (PushMessage):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PushMessage]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
body=body,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
user_uuid: str,
*,
client: AuthenticatedClient,
body: PushMessage,
) -> Optional[PushMessage]:
"""
Args:
user_uuid (str):
body (PushMessage):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PushMessage
"""
return sync_detailed(
user_uuid=user_uuid,
client=client,
body=body,
).parsed
async def asyncio_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
body: PushMessage,
) -> Response[PushMessage]:
"""
Args:
user_uuid (str):
body (PushMessage):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PushMessage]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
body=body,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
user_uuid: str,
*,
client: AuthenticatedClient,
body: PushMessage,
) -> Optional[PushMessage]:
"""
Args:
user_uuid (str):
body (PushMessage):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PushMessage
"""
return (
await asyncio_detailed(
user_uuid=user_uuid,
client=client,
body=body,
)
).parsed

View file

@ -0,0 +1,183 @@
from http import HTTPStatus
from typing import Any, Optional, Union
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.paginated_push_message_list import PaginatedPushMessageList
from ...types import UNSET, Response, Unset
def _get_kwargs(
user_uuid: str,
*,
limit: Union[Unset, int] = UNSET,
offset: Union[Unset, int] = UNSET,
) -> dict[str, Any]:
params: dict[str, Any] = {}
params["limit"] = limit
params["offset"] = offset
params = {k: v for k, v in params.items() if v is not UNSET and v is not None}
_kwargs: dict[str, Any] = {
"method": "get",
"url": f"/push/application/{user_uuid}/application/",
"params": params,
}
return _kwargs
def _parse_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Optional[PaginatedPushMessageList]:
if response.status_code == 200:
response_200 = PaginatedPushMessageList.from_dict(response.json())
return response_200
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Response[PaginatedPushMessageList]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: Union[Unset, int] = UNSET,
offset: Union[Unset, int] = UNSET,
) -> Response[PaginatedPushMessageList]:
"""
Args:
user_uuid (str):
limit (Union[Unset, int]):
offset (Union[Unset, int]):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PaginatedPushMessageList]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
limit=limit,
offset=offset,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: Union[Unset, int] = UNSET,
offset: Union[Unset, int] = UNSET,
) -> Optional[PaginatedPushMessageList]:
"""
Args:
user_uuid (str):
limit (Union[Unset, int]):
offset (Union[Unset, int]):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PaginatedPushMessageList
"""
return sync_detailed(
user_uuid=user_uuid,
client=client,
limit=limit,
offset=offset,
).parsed
async def asyncio_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: Union[Unset, int] = UNSET,
offset: Union[Unset, int] = UNSET,
) -> Response[PaginatedPushMessageList]:
"""
Args:
user_uuid (str):
limit (Union[Unset, int]):
offset (Union[Unset, int]):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PaginatedPushMessageList]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
limit=limit,
offset=offset,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: Union[Unset, int] = UNSET,
offset: Union[Unset, int] = UNSET,
) -> Optional[PaginatedPushMessageList]:
"""
Args:
user_uuid (str):
limit (Union[Unset, int]):
offset (Union[Unset, int]):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PaginatedPushMessageList
"""
return (
await asyncio_detailed(
user_uuid=user_uuid,
client=client,
limit=limit,
offset=offset,
)
).parsed

View file

@ -0,0 +1,119 @@
from http import HTTPStatus
from typing import Any, Optional, Union
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.push_user import PushUser
from ...types import Response
def _get_kwargs() -> dict[str, Any]:
_kwargs: dict[str, Any] = {
"method": "get",
"url": "/push/user/push_user/",
}
return _kwargs
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[PushUser]:
if response.status_code == 200:
response_200 = PushUser.from_dict(response.json())
return response_200
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[PushUser]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
*,
client: AuthenticatedClient,
) -> Response[PushUser]:
"""
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PushUser]
"""
kwargs = _get_kwargs()
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
*,
client: AuthenticatedClient,
) -> Optional[PushUser]:
"""
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PushUser
"""
return sync_detailed(
client=client,
).parsed
async def asyncio_detailed(
*,
client: AuthenticatedClient,
) -> Response[PushUser]:
"""
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PushUser]
"""
kwargs = _get_kwargs()
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
*,
client: AuthenticatedClient,
) -> Optional[PushUser]:
"""
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PushUser
"""
return (
await asyncio_detailed(
client=client,
)
).parsed

View file

@ -0,0 +1,268 @@
import ssl
from typing import Any, Optional, Union
import httpx
from attrs import define, evolve, field
@define
class Client:
"""A class for keeping track of data related to the API
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
``cookies``: A dictionary of cookies to be sent with every request
``headers``: A dictionary of headers to be sent with every request
``timeout``: The maximum amount of a time a request can take. API functions will raise
httpx.TimeoutException if this is exceeded.
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
but can be set to False for testing purposes.
``follow_redirects``: Whether or not to follow redirects. Default value is False.
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
Attributes:
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
argument to the constructor.
"""
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
_base_url: str = field(alias="base_url")
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout")
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl")
_follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects")
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
_client: Optional[httpx.Client] = field(default=None, init=False)
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
def with_headers(self, headers: dict[str, str]) -> "Client":
"""Get a new client matching this one with additional headers"""
if self._client is not None:
self._client.headers.update(headers)
if self._async_client is not None:
self._async_client.headers.update(headers)
return evolve(self, headers={**self._headers, **headers})
def with_cookies(self, cookies: dict[str, str]) -> "Client":
"""Get a new client matching this one with additional cookies"""
if self._client is not None:
self._client.cookies.update(cookies)
if self._async_client is not None:
self._async_client.cookies.update(cookies)
return evolve(self, cookies={**self._cookies, **cookies})
def with_timeout(self, timeout: httpx.Timeout) -> "Client":
"""Get a new client matching this one with a new timeout (in seconds)"""
if self._client is not None:
self._client.timeout = timeout
if self._async_client is not None:
self._async_client.timeout = timeout
return evolve(self, timeout=timeout)
def set_httpx_client(self, client: httpx.Client) -> "Client":
"""Manually set the underlying httpx.Client
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._client = client
return self
def get_httpx_client(self) -> httpx.Client:
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
if self._client is None:
self._client = httpx.Client(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._client
def __enter__(self) -> "Client":
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
self.get_httpx_client().__enter__()
return self
def __exit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
self.get_httpx_client().__exit__(*args, **kwargs)
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client":
"""Manually the underlying httpx.AsyncClient
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._async_client = async_client
return self
def get_async_httpx_client(self) -> httpx.AsyncClient:
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
if self._async_client is None:
self._async_client = httpx.AsyncClient(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._async_client
async def __aenter__(self) -> "Client":
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
await self.get_async_httpx_client().__aenter__()
return self
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
await self.get_async_httpx_client().__aexit__(*args, **kwargs)
@define
class AuthenticatedClient:
"""A Client which has been authenticated for use on secured endpoints
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
``cookies``: A dictionary of cookies to be sent with every request
``headers``: A dictionary of headers to be sent with every request
``timeout``: The maximum amount of a time a request can take. API functions will raise
httpx.TimeoutException if this is exceeded.
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
but can be set to False for testing purposes.
``follow_redirects``: Whether or not to follow redirects. Default value is False.
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
Attributes:
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
argument to the constructor.
token: The token to use for authentication
prefix: The prefix to use for the Authorization header
auth_header_name: The name of the Authorization header
"""
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
_base_url: str = field(alias="base_url")
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout")
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl")
_follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects")
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
_client: Optional[httpx.Client] = field(default=None, init=False)
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
token: str
prefix: str = "Bearer"
auth_header_name: str = "Authorization"
def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient":
"""Get a new client matching this one with additional headers"""
if self._client is not None:
self._client.headers.update(headers)
if self._async_client is not None:
self._async_client.headers.update(headers)
return evolve(self, headers={**self._headers, **headers})
def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient":
"""Get a new client matching this one with additional cookies"""
if self._client is not None:
self._client.cookies.update(cookies)
if self._async_client is not None:
self._async_client.cookies.update(cookies)
return evolve(self, cookies={**self._cookies, **cookies})
def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient":
"""Get a new client matching this one with a new timeout (in seconds)"""
if self._client is not None:
self._client.timeout = timeout
if self._async_client is not None:
self._async_client.timeout = timeout
return evolve(self, timeout=timeout)
def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient":
"""Manually set the underlying httpx.Client
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._client = client
return self
def get_httpx_client(self) -> httpx.Client:
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
if self._client is None:
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
self._client = httpx.Client(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._client
def __enter__(self) -> "AuthenticatedClient":
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
self.get_httpx_client().__enter__()
return self
def __exit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
self.get_httpx_client().__exit__(*args, **kwargs)
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient":
"""Manually the underlying httpx.AsyncClient
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._async_client = async_client
return self
def get_async_httpx_client(self) -> httpx.AsyncClient:
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
if self._async_client is None:
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
self._async_client = httpx.AsyncClient(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._async_client
async def __aenter__(self) -> "AuthenticatedClient":
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
await self.get_async_httpx_client().__aenter__()
return self
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
await self.get_async_httpx_client().__aexit__(*args, **kwargs)

View file

@ -0,0 +1,16 @@
"""Contains shared errors types that can be raised from API functions"""
class UnexpectedStatus(Exception):
"""Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True"""
def __init__(self, status_code: int, content: bytes):
self.status_code = status_code
self.content = content
super().__init__(
f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}"
)
__all__ = ["UnexpectedStatus"]

View file

@ -0,0 +1,15 @@
"""Contains all the data models used in inputs/outputs"""
from .email import Email
from .paginated_email_list import PaginatedEmailList
from .paginated_push_message_list import PaginatedPushMessageList
from .push_message import PushMessage
from .push_user import PushUser
__all__ = (
"Email",
"PaginatedEmailList",
"PaginatedPushMessageList",
"PushMessage",
"PushUser",
)

View file

@ -0,0 +1,133 @@
from collections.abc import Mapping
from typing import Any, TypeVar, Union, cast
from uuid import UUID
from attrs import define as _attrs_define
from attrs import field as _attrs_field
from ..types import UNSET, Unset
T = TypeVar("T", bound="Email")
@_attrs_define
class Email:
"""
Attributes:
title (str):
message (str):
user (UUID):
application (Union[None, UUID]):
queue (Union[None, UUID, Unset]):
"""
title: str
message: str
user: UUID
application: Union[None, UUID]
queue: Union[None, UUID, Unset] = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
title = self.title
message = self.message
user = str(self.user)
application: Union[None, str]
if isinstance(self.application, UUID):
application = str(self.application)
else:
application = self.application
queue: Union[None, Unset, str]
if isinstance(self.queue, Unset):
queue = UNSET
elif isinstance(self.queue, UUID):
queue = str(self.queue)
else:
queue = self.queue
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"title": title,
"message": message,
"user": user,
"application": application,
}
)
if queue is not UNSET:
field_dict["queue"] = queue
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
d = dict(src_dict)
title = d.pop("title")
message = d.pop("message")
user = UUID(d.pop("user"))
def _parse_application(data: object) -> Union[None, UUID]:
if data is None:
return data
try:
if not isinstance(data, str):
raise TypeError()
application_type_0 = UUID(data)
return application_type_0
except: # noqa: E722
pass
return cast(Union[None, UUID], data)
application = _parse_application(d.pop("application"))
def _parse_queue(data: object) -> Union[None, UUID, Unset]:
if data is None:
return data
if isinstance(data, Unset):
return data
try:
if not isinstance(data, str):
raise TypeError()
queue_type_0 = UUID(data)
return queue_type_0
except: # noqa: E722
pass
return cast(Union[None, UUID, Unset], data)
queue = _parse_queue(d.pop("queue", UNSET))
email = cls(
title=title,
message=message,
user=user,
application=application,
queue=queue,
)
email.additional_properties = d
return email
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1,123 @@
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, TypeVar, Union, cast
from attrs import define as _attrs_define
from attrs import field as _attrs_field
from ..types import UNSET, Unset
if TYPE_CHECKING:
from ..models.email import Email
T = TypeVar("T", bound="PaginatedEmailList")
@_attrs_define
class PaginatedEmailList:
"""
Attributes:
count (int): Example: 123.
results (list['Email']):
next_ (Union[None, Unset, str]): Example: http://api.example.org/accounts/?offset=400&limit=100.
previous (Union[None, Unset, str]): Example: http://api.example.org/accounts/?offset=200&limit=100.
"""
count: int
results: list["Email"]
next_: Union[None, Unset, str] = UNSET
previous: Union[None, Unset, str] = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
count = self.count
results = []
for results_item_data in self.results:
results_item = results_item_data.to_dict()
results.append(results_item)
next_: Union[None, Unset, str]
if isinstance(self.next_, Unset):
next_ = UNSET
else:
next_ = self.next_
previous: Union[None, Unset, str]
if isinstance(self.previous, Unset):
previous = UNSET
else:
previous = self.previous
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"count": count,
"results": results,
}
)
if next_ is not UNSET:
field_dict["next"] = next_
if previous is not UNSET:
field_dict["previous"] = previous
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.email import Email
d = dict(src_dict)
count = d.pop("count")
results = []
_results = d.pop("results")
for results_item_data in _results:
results_item = Email.from_dict(results_item_data)
results.append(results_item)
def _parse_next_(data: object) -> Union[None, Unset, str]:
if data is None:
return data
if isinstance(data, Unset):
return data
return cast(Union[None, Unset, str], data)
next_ = _parse_next_(d.pop("next", UNSET))
def _parse_previous(data: object) -> Union[None, Unset, str]:
if data is None:
return data
if isinstance(data, Unset):
return data
return cast(Union[None, Unset, str], data)
previous = _parse_previous(d.pop("previous", UNSET))
paginated_email_list = cls(
count=count,
results=results,
next_=next_,
previous=previous,
)
paginated_email_list.additional_properties = d
return paginated_email_list
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1,123 @@
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, TypeVar, Union, cast
from attrs import define as _attrs_define
from attrs import field as _attrs_field
from ..types import UNSET, Unset
if TYPE_CHECKING:
from ..models.push_message import PushMessage
T = TypeVar("T", bound="PaginatedPushMessageList")
@_attrs_define
class PaginatedPushMessageList:
"""
Attributes:
count (int): Example: 123.
results (list['PushMessage']):
next_ (Union[None, Unset, str]): Example: http://api.example.org/accounts/?offset=400&limit=100.
previous (Union[None, Unset, str]): Example: http://api.example.org/accounts/?offset=200&limit=100.
"""
count: int
results: list["PushMessage"]
next_: Union[None, Unset, str] = UNSET
previous: Union[None, Unset, str] = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
count = self.count
results = []
for results_item_data in self.results:
results_item = results_item_data.to_dict()
results.append(results_item)
next_: Union[None, Unset, str]
if isinstance(self.next_, Unset):
next_ = UNSET
else:
next_ = self.next_
previous: Union[None, Unset, str]
if isinstance(self.previous, Unset):
previous = UNSET
else:
previous = self.previous
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"count": count,
"results": results,
}
)
if next_ is not UNSET:
field_dict["next"] = next_
if previous is not UNSET:
field_dict["previous"] = previous
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.push_message import PushMessage
d = dict(src_dict)
count = d.pop("count")
results = []
_results = d.pop("results")
for results_item_data in _results:
results_item = PushMessage.from_dict(results_item_data)
results.append(results_item)
def _parse_next_(data: object) -> Union[None, Unset, str]:
if data is None:
return data
if isinstance(data, Unset):
return data
return cast(Union[None, Unset, str], data)
next_ = _parse_next_(d.pop("next", UNSET))
def _parse_previous(data: object) -> Union[None, Unset, str]:
if data is None:
return data
if isinstance(data, Unset):
return data
return cast(Union[None, Unset, str], data)
previous = _parse_previous(d.pop("previous", UNSET))
paginated_push_message_list = cls(
count=count,
results=results,
next_=next_,
previous=previous,
)
paginated_push_message_list.additional_properties = d
return paginated_push_message_list
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1,121 @@
from collections.abc import Mapping
from typing import Any, TypeVar, Union, cast
from uuid import UUID
from attrs import define as _attrs_define
from attrs import field as _attrs_field
from ..types import UNSET, Unset
T = TypeVar("T", bound="PushMessage")
@_attrs_define
class PushMessage:
"""
Attributes:
push_user (UUID):
application (Union[None, UUID]):
title (str):
message (str):
priority (Union[Unset, int]):
extras (Union[Unset, Any]):
"""
push_user: UUID
application: Union[None, UUID]
title: str
message: str
priority: Union[Unset, int] = UNSET
extras: Union[Unset, Any] = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
push_user = str(self.push_user)
application: Union[None, str]
if isinstance(self.application, UUID):
application = str(self.application)
else:
application = self.application
title = self.title
message = self.message
priority = self.priority
extras = self.extras
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"push_user": push_user,
"application": application,
"title": title,
"message": message,
}
)
if priority is not UNSET:
field_dict["priority"] = priority
if extras is not UNSET:
field_dict["extras"] = extras
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
d = dict(src_dict)
push_user = UUID(d.pop("push_user"))
def _parse_application(data: object) -> Union[None, UUID]:
if data is None:
return data
try:
if not isinstance(data, str):
raise TypeError()
application_type_0 = UUID(data)
return application_type_0
except: # noqa: E722
pass
return cast(Union[None, UUID], data)
application = _parse_application(d.pop("application"))
title = d.pop("title")
message = d.pop("message")
priority = d.pop("priority", UNSET)
extras = d.pop("extras", UNSET)
push_message = cls(
push_user=push_user,
application=application,
title=title,
message=message,
priority=priority,
extras=extras,
)
push_message.additional_properties = d
return push_message
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1,102 @@
from collections.abc import Mapping
from typing import Any, TypeVar, Union, cast
from uuid import UUID
from attrs import define as _attrs_define
from attrs import field as _attrs_field
from ..types import UNSET, Unset
T = TypeVar("T", bound="PushUser")
@_attrs_define
class PushUser:
"""
Attributes:
user (UUID):
client_token (Union[None, Unset, str]):
application_token (Union[None, Unset, str]):
"""
user: UUID
client_token: Union[None, Unset, str] = UNSET
application_token: Union[None, Unset, str] = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
user = str(self.user)
client_token: Union[None, Unset, str]
if isinstance(self.client_token, Unset):
client_token = UNSET
else:
client_token = self.client_token
application_token: Union[None, Unset, str]
if isinstance(self.application_token, Unset):
application_token = UNSET
else:
application_token = self.application_token
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"user": user,
}
)
if client_token is not UNSET:
field_dict["client_token"] = client_token
if application_token is not UNSET:
field_dict["application_token"] = application_token
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
d = dict(src_dict)
user = UUID(d.pop("user"))
def _parse_client_token(data: object) -> Union[None, Unset, str]:
if data is None:
return data
if isinstance(data, Unset):
return data
return cast(Union[None, Unset, str], data)
client_token = _parse_client_token(d.pop("client_token", UNSET))
def _parse_application_token(data: object) -> Union[None, Unset, str]:
if data is None:
return data
if isinstance(data, Unset):
return data
return cast(Union[None, Unset, str], data)
application_token = _parse_application_token(d.pop("application_token", UNSET))
push_user = cls(
user=user,
client_token=client_token,
application_token=application_token,
)
push_user.additional_properties = d
return push_user
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1 @@
# Marker file for PEP 561

View file

@ -0,0 +1,54 @@
"""Contains some shared types for properties"""
from collections.abc import Mapping, MutableMapping
from http import HTTPStatus
from typing import IO, BinaryIO, Generic, Literal, Optional, TypeVar, Union
from attrs import define
class Unset:
def __bool__(self) -> Literal[False]:
return False
UNSET: Unset = Unset()
# The types that `httpx.Client(files=)` can accept, copied from that library.
FileContent = Union[IO[bytes], bytes, str]
FileTypes = Union[
# (filename, file (or bytes), content_type)
tuple[Optional[str], FileContent, Optional[str]],
# (filename, file (or bytes), content_type, headers)
tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]],
]
RequestFiles = list[tuple[str, FileTypes]]
@define
class File:
"""Contains information for file uploads"""
payload: BinaryIO
file_name: Optional[str] = None
mime_type: Optional[str] = None
def to_tuple(self) -> FileTypes:
"""Return a tuple representation that httpx will accept for multipart/form-data"""
return self.file_name, self.payload, self.mime_type
T = TypeVar("T")
@define
class Response(Generic[T]):
"""A response from an endpoint"""
status_code: HTTPStatus
content: bytes
headers: MutableMapping[str, str]
parsed: Optional[T]
__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"]

View file

@ -0,0 +1,81 @@
import time
import requests
from django.conf import settings
from django.core.cache import cache
from utils.clients.gooyal_notifications_client import AuthenticatedClient
from utils.clients.gooyal_notifications_client.api.push import push_application_application_create
ACCESS_TOKEN_CACHE_KEY = "chat_notifications_access_token"
def login_as_client_credentials() -> dict:
"""
Client-credentials OAuth2 grant against the gooyal accounts service,
reusing a cached access token until it's within 5s of expiry.
"""
cached = cache.get(ACCESS_TOKEN_CACHE_KEY)
if cached:
return cached
data = {
"grant_type": "client_credentials",
"scope": settings.OAUTH2_PROVIDER_SCOPES,
}
auth = (settings.OAUTH2_PROVIDER_CLIENT_ID, settings.OAUTH2_PROVIDER_CLIENT_SECRET)
response = requests.post(
f"{settings.OAUTH2_PROVIDER_BASE_PUBLIC_URL}/token/", data=data, auth=auth
)
response.raise_for_status()
auth_data = response.json()
if "access_token" in auth_data:
auth_data["created_at"] = time.time()
cache.set(
ACCESS_TOKEN_CACHE_KEY, auth_data, timeout=auth_data.get("expires_in", 300) - 5
)
return auth_data
def get_client() -> AuthenticatedClient:
access_token = login_as_client_credentials()["access_token"]
return AuthenticatedClient(
base_url=settings.NOTIFICATIONS_BASE_PUBLIC_URL, token=access_token
)
class _PushBody:
"""
Duck-typed request body: push_user is already in the URL and
application is resolved server-side from the OAuth2 client, so only
these fields are sent (mirrors the advertising service's client).
"""
def __init__(self, title: str, message: str, priority: int, extras):
self._title = title
self._message = message
self._priority = priority
self._extras = extras
def to_dict(self) -> dict:
return {
"title": self._title,
"message": self._message,
"priority": self._priority,
"extras": self._extras,
}
def push_user(user_uuid, title: str, message: str, priority: int = 5, extras=None):
"""
Send a push notification to a single user via the gooyal notifications
service. Requires the `notifications.application.push:send` scope on
this app's OAuth2 client credentials. Raises on failure — callers
decide whether a down notifications service should be swallowed.
"""
client = get_client()
body = _PushBody(title=title, message=message, priority=priority, extras=extras)
return push_application_application_create.sync(user_uuid=str(user_uuid), client=client, body=body)