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

1"""Messaging providers: the pluggable way text messages reach a phone. 

2 

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: 

6 

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. 

10 

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""" 

15 

16import logging 

17from dataclasses import dataclass 

18from functools import cache 

19 

20from django.conf import settings 

21from django.utils.module_loading import import_string 

22 

23from .phone import mask_phone 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28class MessagingError(Exception): 

29 """The provider could not send the message.""" 

30 

31 

32class MessagingProvider: 

33 name = "provider" 

34 

35 def send_text(self, phone: str, text: str) -> None: 

36 """Send ``text`` to ``phone`` (E.164, e.g. ``+15550100``). 

37 

38 Raise :class:`MessagingError` if the message could not be sent. 

39 """ 

40 raise NotImplementedError 

41 

42 

43class ConsoleMessagingProvider(MessagingProvider): 

44 """Logs each message instead of sending it. For development only.""" 

45 

46 name = "console" 

47 

48 def send_text(self, phone: str, text: str) -> None: 

49 logger.info("Text message to %s:\n%s", phone, text) 

50 

51 

52@dataclass(frozen=True) 

53class SentText: 

54 phone: str 

55 text: str 

56 

57 

58class MemoryMessagingProvider(MessagingProvider): 

59 """Records messages in :attr:`outbox`, like Django's in-memory email backend.""" 

60 

61 name = "memory" 

62 outbox: list[SentText] = [] 

63 

64 def send_text(self, phone: str, text: str) -> None: 

65 self.outbox.append(SentText(phone=phone, text=text)) 

66 

67 

68@cache 

69def _provider_class(path: str) -> type[MessagingProvider]: 

70 return import_string(path) 

71 

72 

73def get_provider() -> MessagingProvider: 

74 return _provider_class(settings.MESSAGING_PROVIDER)() 

75 

76 

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))