From 0d262c68eb909d27b9f3b2c10ef5f092136c84f2 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sat, 1 Aug 2026 14:10:56 +0330 Subject: [PATCH] Send push notifications on new chat messages Ports the gooyal notifications client (as already used by advertising) and wires it into the previously-empty PushPublisher: on each message send, the other conversation participant(s) get a push notification with the sender's name and a message preview. Failures are swallowed per-recipient so a down notifications service degrades silently rather than breaking message sending. Requires NOTIFICATIONS_BASE_PUBLIC_URL and the notifications.application.push:send OAuth2 scope to be configured before it will actually deliver. Co-Authored-By: Claude Sonnet 5 --- .env.example | 6 +- apps/chat/events/publishers/push.py | 39 ++- apps/chat/tests/test_push_publisher.py | 119 ++++++++ env.sample | 1 + .../gooyal_notifications_client/__init__.py | 8 + .../api/__init__.py | 1 + .../api/email/__init__.py | 1 + .../email_application_application_create.py | 164 +++++++++++ .../email_application_application_list.py | 183 ++++++++++++ .../api/push/__init__.py | 1 + .../push_application_application_create.py | 164 +++++++++++ .../push/push_application_application_list.py | 183 ++++++++++++ .../api/push/push_user_push_user_retrieve.py | 119 ++++++++ .../gooyal_notifications_client/client.py | 268 ++++++++++++++++++ .../gooyal_notifications_client/errors.py | 16 ++ .../models/__init__.py | 15 + .../models/email.py | 133 +++++++++ .../models/paginated_email_list.py | 123 ++++++++ .../models/paginated_push_message_list.py | 123 ++++++++ .../models/push_message.py | 121 ++++++++ .../models/push_user.py | 102 +++++++ .../gooyal_notifications_client/py.typed | 1 + .../gooyal_notifications_client/types.py | 54 ++++ utils/clients/notifications_client.py | 81 ++++++ 24 files changed, 2023 insertions(+), 3 deletions(-) create mode 100644 apps/chat/tests/test_push_publisher.py create mode 100644 utils/clients/gooyal_notifications_client/__init__.py create mode 100644 utils/clients/gooyal_notifications_client/api/__init__.py create mode 100644 utils/clients/gooyal_notifications_client/api/email/__init__.py create mode 100644 utils/clients/gooyal_notifications_client/api/email/email_application_application_create.py create mode 100644 utils/clients/gooyal_notifications_client/api/email/email_application_application_list.py create mode 100644 utils/clients/gooyal_notifications_client/api/push/__init__.py create mode 100644 utils/clients/gooyal_notifications_client/api/push/push_application_application_create.py create mode 100644 utils/clients/gooyal_notifications_client/api/push/push_application_application_list.py create mode 100644 utils/clients/gooyal_notifications_client/api/push/push_user_push_user_retrieve.py create mode 100644 utils/clients/gooyal_notifications_client/client.py create mode 100644 utils/clients/gooyal_notifications_client/errors.py create mode 100644 utils/clients/gooyal_notifications_client/models/__init__.py create mode 100644 utils/clients/gooyal_notifications_client/models/email.py create mode 100644 utils/clients/gooyal_notifications_client/models/paginated_email_list.py create mode 100644 utils/clients/gooyal_notifications_client/models/paginated_push_message_list.py create mode 100644 utils/clients/gooyal_notifications_client/models/push_message.py create mode 100644 utils/clients/gooyal_notifications_client/models/push_user.py create mode 100644 utils/clients/gooyal_notifications_client/py.typed create mode 100644 utils/clients/gooyal_notifications_client/types.py create mode 100644 utils/clients/notifications_client.py diff --git a/.env.example b/.env.example index 26e6e63..5ec56f1 100644 --- a/.env.example +++ b/.env.example @@ -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_CLIENT_ID= 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= # Gooyal Accounts service — used to fetch public user profiles (name, avatar) for chat responses 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_BASE_URL=redis://127.0.0.1:6379/1 diff --git a/apps/chat/events/publishers/push.py b/apps/chat/events/publishers/push.py index fbba604..27161a3 100644 --- a/apps/chat/events/publishers/push.py +++ b/apps/chat/events/publishers/push.py @@ -1,7 +1,42 @@ +import logging + 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: - # 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: - 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) diff --git a/apps/chat/tests/test_push_publisher.py b/apps/chat/tests/test_push_publisher.py new file mode 100644 index 0000000..eab41c3 --- /dev/null +++ b/apps/chat/tests/test_push_publisher.py @@ -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 diff --git a/env.sample b/env.sample index e43f957..a9c4d6f 100644 --- a/env.sample +++ b/env.sample @@ -10,6 +10,7 @@ OAUTH2_PROVIDER_CLIENT_ID=QkeJuvsPvwV2wjO2InQ7AIKfhCr0HqBWNtit2EHj OAUTH2_PROVIDER_CLIENT_SECRET=S9AYajQRffgI7SzsxUmpzY7IMkVqlux8sW0A1scoBKgNfUFjXk3uwIMGbkBF9erMYONNVbSWemgODo6ArCDlg4UXYhaSZ8zolLhagfJ5Fk92RmrMLyY2rKv4cb209tPw OAUTH2_PROVIDER_SCOPES=accounts.profile:retrieve wallet.application.deposit:verify wallet.application.deposit:submit 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 MATTERMOST_TOKEN=3fktq5yzw7yp7m4ty7d3nx8gph diff --git a/utils/clients/gooyal_notifications_client/__init__.py b/utils/clients/gooyal_notifications_client/__init__.py new file mode 100644 index 0000000..d98cfc3 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/__init__.py @@ -0,0 +1,8 @@ +"""A client library for accessing Notifications""" + +from .client import AuthenticatedClient, Client + +__all__ = ( + "AuthenticatedClient", + "Client", +) diff --git a/utils/clients/gooyal_notifications_client/api/__init__.py b/utils/clients/gooyal_notifications_client/api/__init__.py new file mode 100644 index 0000000..81f9fa2 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/api/__init__.py @@ -0,0 +1 @@ +"""Contains methods for accessing the API""" diff --git a/utils/clients/gooyal_notifications_client/api/email/__init__.py b/utils/clients/gooyal_notifications_client/api/email/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/api/email/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/utils/clients/gooyal_notifications_client/api/email/email_application_application_create.py b/utils/clients/gooyal_notifications_client/api/email/email_application_application_create.py new file mode 100644 index 0000000..1ff13d2 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/api/email/email_application_application_create.py @@ -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 diff --git a/utils/clients/gooyal_notifications_client/api/email/email_application_application_list.py b/utils/clients/gooyal_notifications_client/api/email/email_application_application_list.py new file mode 100644 index 0000000..90fef22 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/api/email/email_application_application_list.py @@ -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 diff --git a/utils/clients/gooyal_notifications_client/api/push/__init__.py b/utils/clients/gooyal_notifications_client/api/push/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/api/push/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/utils/clients/gooyal_notifications_client/api/push/push_application_application_create.py b/utils/clients/gooyal_notifications_client/api/push/push_application_application_create.py new file mode 100644 index 0000000..ed950f6 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/api/push/push_application_application_create.py @@ -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 diff --git a/utils/clients/gooyal_notifications_client/api/push/push_application_application_list.py b/utils/clients/gooyal_notifications_client/api/push/push_application_application_list.py new file mode 100644 index 0000000..28664e3 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/api/push/push_application_application_list.py @@ -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 diff --git a/utils/clients/gooyal_notifications_client/api/push/push_user_push_user_retrieve.py b/utils/clients/gooyal_notifications_client/api/push/push_user_push_user_retrieve.py new file mode 100644 index 0000000..b59ab13 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/api/push/push_user_push_user_retrieve.py @@ -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 diff --git a/utils/clients/gooyal_notifications_client/client.py b/utils/clients/gooyal_notifications_client/client.py new file mode 100644 index 0000000..e80446f --- /dev/null +++ b/utils/clients/gooyal_notifications_client/client.py @@ -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) diff --git a/utils/clients/gooyal_notifications_client/errors.py b/utils/clients/gooyal_notifications_client/errors.py new file mode 100644 index 0000000..5f92e76 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/errors.py @@ -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"] diff --git a/utils/clients/gooyal_notifications_client/models/__init__.py b/utils/clients/gooyal_notifications_client/models/__init__.py new file mode 100644 index 0000000..49acbfb --- /dev/null +++ b/utils/clients/gooyal_notifications_client/models/__init__.py @@ -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", +) diff --git a/utils/clients/gooyal_notifications_client/models/email.py b/utils/clients/gooyal_notifications_client/models/email.py new file mode 100644 index 0000000..aa9118e --- /dev/null +++ b/utils/clients/gooyal_notifications_client/models/email.py @@ -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 diff --git a/utils/clients/gooyal_notifications_client/models/paginated_email_list.py b/utils/clients/gooyal_notifications_client/models/paginated_email_list.py new file mode 100644 index 0000000..df445bc --- /dev/null +++ b/utils/clients/gooyal_notifications_client/models/paginated_email_list.py @@ -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 diff --git a/utils/clients/gooyal_notifications_client/models/paginated_push_message_list.py b/utils/clients/gooyal_notifications_client/models/paginated_push_message_list.py new file mode 100644 index 0000000..e746496 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/models/paginated_push_message_list.py @@ -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 diff --git a/utils/clients/gooyal_notifications_client/models/push_message.py b/utils/clients/gooyal_notifications_client/models/push_message.py new file mode 100644 index 0000000..2c428f4 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/models/push_message.py @@ -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 diff --git a/utils/clients/gooyal_notifications_client/models/push_user.py b/utils/clients/gooyal_notifications_client/models/push_user.py new file mode 100644 index 0000000..ecb6e65 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/models/push_user.py @@ -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 diff --git a/utils/clients/gooyal_notifications_client/py.typed b/utils/clients/gooyal_notifications_client/py.typed new file mode 100644 index 0000000..1aad327 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 \ No newline at end of file diff --git a/utils/clients/gooyal_notifications_client/types.py b/utils/clients/gooyal_notifications_client/types.py new file mode 100644 index 0000000..1b96ca4 --- /dev/null +++ b/utils/clients/gooyal_notifications_client/types.py @@ -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"] diff --git a/utils/clients/notifications_client.py b/utils/clients/notifications_client.py new file mode 100644 index 0000000..c3c324b --- /dev/null +++ b/utils/clients/notifications_client.py @@ -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)