Coverage for baibu/users/messaging.py: 100%
36 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1"""Messaging providers: the pluggable way text messages reach a phone.
3Phone sign-in sends one-time codes through the provider named by
4``MESSAGING_PROVIDER`` (a dotted path to a :class:`MessagingProvider`
5subclass). Two ship with the platform:
7- ``ConsoleMessagingProvider`` (default): writes the message to the log.
8 For development; nothing leaves the machine.
9- ``MemoryMessagingProvider``: keeps messages in a list, for tests.
11A deployment that sends real messages (SMS or a chat app) writes its own
12subclass and implements :meth:`MessagingProvider.send_text`. See the
13"Phone sign-in" page of the developer docs.
14"""
16import logging
17from dataclasses import dataclass
18from functools import cache
20from django.conf import settings
21from django.utils.module_loading import import_string
23from .phone import mask_phone
25logger = logging.getLogger(__name__)
28class MessagingError(Exception):
29 """The provider could not send the message."""
32class MessagingProvider:
33 name = "provider"
35 def send_text(self, phone: str, text: str) -> None:
36 """Send ``text`` to ``phone`` (E.164, e.g. ``+15550100``).
38 Raise :class:`MessagingError` if the message could not be sent.
39 """
40 raise NotImplementedError
43class ConsoleMessagingProvider(MessagingProvider):
44 """Logs each message instead of sending it. For development only."""
46 name = "console"
48 def send_text(self, phone: str, text: str) -> None:
49 logger.info("Text message to %s:\n%s", phone, text)
52@dataclass(frozen=True)
53class SentText:
54 phone: str
55 text: str
58class MemoryMessagingProvider(MessagingProvider):
59 """Records messages in :attr:`outbox`, like Django's in-memory email backend."""
61 name = "memory"
62 outbox: list[SentText] = []
64 def send_text(self, phone: str, text: str) -> None:
65 self.outbox.append(SentText(phone=phone, text=text))
68@cache
69def _provider_class(path: str) -> type[MessagingProvider]:
70 return import_string(path)
73def get_provider() -> MessagingProvider:
74 return _provider_class(settings.MESSAGING_PROVIDER)()
77def send_text(phone: str, text: str) -> None:
78 """Send a text through the configured provider; raises :class:`MessagingError`."""
79 provider = get_provider()
80 try:
81 provider.send_text(phone, text)
82 except MessagingError:
83 logger.warning("Text message not sent; provider=%s phone=%s", provider.name, mask_phone(phone))
84 raise
85 logger.info("Text message sent; provider=%s phone=%s", provider.name, mask_phone(phone))