uzsms 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
uzsms/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """uzsms: SMS sending API for Django.
2
+
3
+ This module defines the package's public API surface (``__all__``) and
4
+ must stay free of two things: reading ``SMS_SETTINGS`` at import time, and
5
+ importing Django models at module level. Django imports an app's
6
+ ``__init__.py`` while populating the app registry, before it is ready --
7
+ an eager import of anything that reaches ``uzsms.models`` (directly, or
8
+ transitively through ``uzsms.services`` or ``uzsms.repository``) would
9
+ raise ``AppRegistryNotReady``. Names that reach the ORM are therefore
10
+ resolved lazily, on first attribute access, via a module-level
11
+ ``__getattr__`` (PEP 562).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ from typing import Any
18
+
19
+ from uzsms.backends import get_async_backend, get_backend
20
+ from uzsms.dto import SendResult, SmsMessage
21
+ from uzsms.exceptions import (
22
+ SmsBackendError,
23
+ SmsConfigurationError,
24
+ SmsError,
25
+ SmsProviderError,
26
+ SmsTransportError,
27
+ SmsValidationError,
28
+ )
29
+
30
+ __all__ = [
31
+ "AsyncSmsClient",
32
+ "SMS_Sender",
33
+ "SendResult",
34
+ "SmsBackendError",
35
+ "SmsClient",
36
+ "SmsConfigurationError",
37
+ "SmsError",
38
+ "SmsLogRecorder",
39
+ "SmsMessage",
40
+ "SmsProviderError",
41
+ "SmsTransportError",
42
+ "SmsValidationError",
43
+ "get_async_backend",
44
+ "get_backend",
45
+ ]
46
+
47
+ # Names that resolve lazily, via __getattr__ below, because importing their
48
+ # home module reaches Django models (directly or transitively).
49
+ _LAZY_ATTRS: dict[str, tuple[str, str]] = {
50
+ "SmsClient": ("uzsms.services", "SmsClient"),
51
+ "AsyncSmsClient": ("uzsms.services", "AsyncSmsClient"),
52
+ "SmsLogRecorder": ("uzsms.repository", "SmsLogRecorder"),
53
+ "SMS_Sender": ("uzsms.compat", "SMS_Sender"),
54
+ }
55
+
56
+
57
+ def __getattr__(name: str) -> Any:
58
+ target = _LAZY_ATTRS.get(name)
59
+ if target is None:
60
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
61
+
62
+ module_name, attr_name = target
63
+ module = importlib.import_module(module_name)
64
+ value = getattr(module, attr_name)
65
+ globals()[name] = value
66
+ return value
uzsms/admin.py ADDED
@@ -0,0 +1,30 @@
1
+ """Admin registration for uzsms models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django.contrib import admin
6
+
7
+ from .models import SmsLog
8
+
9
+
10
+ @admin.register(SmsLog)
11
+ class SmsLogAdmin(admin.ModelAdmin):
12
+ list_display = (
13
+ "id",
14
+ "phone_number",
15
+ "text",
16
+ "status",
17
+ "created_at",
18
+ "sent_at",
19
+ )
20
+ list_display_links = ("id", "phone_number")
21
+ list_filter = ("status", "created_at")
22
+ search_fields = ("phone_number", "message_id")
23
+ date_hierarchy = "created_at"
24
+ readonly_fields = (
25
+ "provider_response",
26
+ "error",
27
+ "created_at",
28
+ "sent_at",
29
+ "message_id",
30
+ )
uzsms/api/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """The uzsms HTTP API.
2
+
3
+ Everything under this package depends on Django REST Framework, an
4
+ optional extra (``uzsms[drf]``). ``uzsms/urls.py`` is the guarded
5
+ entry point that host projects include; importing anything under this
6
+ package directly requires DRF to already be installed.
7
+ """
8
+
9
+ from __future__ import annotations
uzsms/api/responses.py ADDED
@@ -0,0 +1,36 @@
1
+ """The uzsms API's uniform response envelope.
2
+
3
+ Owner decision D2 (binding): every response body this API produces —
4
+ success or failure — has exactly the keys ``success``, ``data``, and
5
+ ``error``. This module is the ONE place that envelope is constructed; no
6
+ view or exception handler may build that dict inline.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from rest_framework.response import Response
14
+
15
+
16
+ def success_response(data: dict[str, Any], status_code: int) -> Response:
17
+ """Build a success envelope carrying ``data``, with ``error`` set to ``None``."""
18
+ return Response({"success": True, "data": data, "error": None}, status=status_code)
19
+
20
+
21
+ def error_response(
22
+ code: str,
23
+ message: str,
24
+ detail: Any = None,
25
+ *,
26
+ status_code: int,
27
+ ) -> Response:
28
+ """Build a failure envelope carrying an ``error`` object, with ``data`` set to ``None``."""
29
+ return Response(
30
+ {
31
+ "success": False,
32
+ "data": None,
33
+ "error": {"code": code, "message": message, "detail": detail},
34
+ },
35
+ status=status_code,
36
+ )
@@ -0,0 +1,34 @@
1
+ """Serializers for the uzsms API.
2
+
3
+ ``SendSmsSerializer`` delegates all validation to :mod:`uzsms.validators` —
4
+ the single source of truth for phone number and message text rules —
5
+ rather than reimplementing it. This replaces 1.0.1's ``ValidatePhoneNumber``
6
+ serializer, which reimplemented a weak ``len(phone_number) != 12`` check of
7
+ its own and assigned directly to the DRF-internal ``self._errors``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from rest_framework import serializers
13
+
14
+ from uzsms.exceptions import SmsValidationError
15
+ from uzsms.validators import validate_message_text, validate_uz_phone
16
+
17
+
18
+ class SendSmsSerializer(serializers.Serializer):
19
+ """Validates the payload for :class:`uzsms.api.views.SendSmsAPIView`."""
20
+
21
+ phone_number = serializers.CharField()
22
+ message = serializers.CharField()
23
+
24
+ def validate_phone_number(self, value: str) -> str:
25
+ try:
26
+ return validate_uz_phone(value)
27
+ except SmsValidationError as exc:
28
+ raise serializers.ValidationError(str(exc)) from exc
29
+
30
+ def validate_message(self, value: str) -> str:
31
+ try:
32
+ return validate_message_text(value)
33
+ except SmsValidationError as exc:
34
+ raise serializers.ValidationError(str(exc)) from exc
uzsms/api/urls.py ADDED
@@ -0,0 +1,18 @@
1
+ """URLconf for the uzsms API.
2
+
3
+ Host projects include this (indirectly, via ``uzsms.urls``) under their
4
+ own prefix, e.g. ``path("sms/", include("uzsms.urls"))`` — which, with
5
+ owner decision D7's route path, puts the send endpoint at ``/sms/send/``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from django.urls import path
11
+
12
+ from uzsms.api.views import send_sms_api_view
13
+
14
+ app_name = "uzsms"
15
+
16
+ urlpatterns = [
17
+ path("send/", send_sms_api_view, name="send_sms"),
18
+ ]
uzsms/api/views.py ADDED
@@ -0,0 +1,195 @@
1
+ """Views for the uzsms API.
2
+
3
+ Fixes the two worst defects in 1.0.1's ``uzsms/views.py``:
4
+
5
+ 1. **Open relay.** The old view hardcoded ``permission_classes =
6
+ [AllowAny]``, so anyone on the internet could send SMS through the
7
+ operator's broker credentials. ``SendSmsAPIView.get_permissions``
8
+ resolves ``sms_settings.PERMISSION_CLASSES`` (default
9
+ ``IsAuthenticated``) lazily via ``import_string``, so authentication is
10
+ required by default and a host project can only relax it deliberately,
11
+ through its own settings.
12
+ 2. **Unserializable response.** The old view did ``return
13
+ Response(result)`` where ``result`` was a raw ``requests.Response``
14
+ object, which DRF cannot render — every request to it crashed. Every
15
+ body this view returns is built by :mod:`uzsms.api.responses`, whose
16
+ envelope contains only JSON-primitive values.
17
+
18
+ ``handle_exception`` re-shapes DRF's own error responses (authentication,
19
+ permission, throttling, parsing, and validation failures) into the same
20
+ envelope, by delegating to DRF's default exception handling for status
21
+ codes and headers and then rewriting only the body.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ from django.utils.module_loading import import_string
29
+ from rest_framework.exceptions import APIException
30
+ from rest_framework.exceptions import AuthenticationFailed as DRFAuthenticationFailed
31
+ from rest_framework.exceptions import NotAuthenticated as DRFNotAuthenticated
32
+ from rest_framework.exceptions import PermissionDenied as DRFPermissionDenied
33
+ from rest_framework.exceptions import Throttled as DRFThrottled
34
+ from rest_framework.exceptions import ValidationError as DRFValidationError
35
+ from rest_framework.permissions import BasePermission
36
+ from rest_framework.response import Response
37
+ from rest_framework.throttling import ScopedRateThrottle
38
+ from rest_framework.views import APIView
39
+
40
+ from uzsms.api.responses import error_response, success_response
41
+ from uzsms.api.serializers import SendSmsSerializer
42
+ from uzsms.conf import sms_settings
43
+ from uzsms.exceptions import SmsProviderError, SmsTransportError, SmsValidationError
44
+ from uzsms.models import SmsLog
45
+ from uzsms.services import SmsClient
46
+
47
+ # Maps a DRF exception type to the error code this API reports for it.
48
+ # Checked in order, most specific first; anything else that reaches
49
+ # ``handle_exception`` falls back to the exception's own ``default_code``.
50
+ _DRF_ERROR_CODES: tuple[tuple[type[APIException], str], ...] = (
51
+ (DRFThrottled, "throttled"),
52
+ (DRFNotAuthenticated, "authentication_failed"),
53
+ (DRFAuthenticationFailed, "authentication_failed"),
54
+ (DRFPermissionDenied, "permission_denied"),
55
+ (DRFValidationError, "validation_error"),
56
+ )
57
+
58
+
59
+ def _error_code_for(exc: Exception) -> str:
60
+ for exc_type, code in _DRF_ERROR_CODES:
61
+ if isinstance(exc, exc_type):
62
+ return code
63
+ if isinstance(exc, APIException):
64
+ return str(exc.default_code)
65
+ return "error"
66
+
67
+
68
+ class _SendSmsThrottle(ScopedRateThrottle):
69
+ """A :class:`ScopedRateThrottle` whose rate comes from ``SMS_SETTINGS``.
70
+
71
+ DRF's own ``ScopedRateThrottle`` reads its rate from
72
+ ``DEFAULT_THROTTLE_RATES`` in the ``REST_FRAMEWORK`` setting, keyed by
73
+ scope. This overrides that lookup so ``sms_settings.THROTTLE_RATE``
74
+ stays the single place the send endpoint's throttle is configured,
75
+ consistent with every other tunable this package exposes.
76
+ """
77
+
78
+ def get_rate(self) -> str:
79
+ return sms_settings.THROTTLE_RATE
80
+
81
+
82
+ class SendSmsAPIView(APIView):
83
+ """Sends a single SMS message.
84
+
85
+ Requires authentication by default; see the module docstring and
86
+ ``sms_settings.PERMISSION_CLASSES``.
87
+ """
88
+
89
+ serializer_class = SendSmsSerializer
90
+ throttle_classes = (_SendSmsThrottle,)
91
+ throttle_scope = "uzsms.send"
92
+
93
+ def get_permissions(self) -> list[BasePermission]:
94
+ """Resolve permission classes lazily, so tests can override the setting."""
95
+ permission_paths = sms_settings.PERMISSION_CLASSES
96
+ return [import_string(path)() for path in permission_paths]
97
+
98
+ def post(self, request: Any, *args: Any, **kwargs: Any) -> Response:
99
+ serializer = self.serializer_class(data=request.data)
100
+ serializer.is_valid(raise_exception=True)
101
+
102
+ phone_number = serializer.validated_data["phone_number"]
103
+ text = serializer.validated_data["message"]
104
+
105
+ client = SmsClient()
106
+ try:
107
+ result = client.send(phone_number, text)
108
+ except SmsValidationError as exc:
109
+ return error_response("validation_error", str(exc), status_code=400)
110
+ except SmsProviderError as exc:
111
+ # ``exc.body`` is the broker's raw, unfiltered response body —
112
+ # it can carry broker-internal error text, account, or routing
113
+ # details, and this endpoint's caller (potentially anonymous,
114
+ # if PERMISSION_CLASSES has been relaxed) is not entitled to
115
+ # any of that. Only the upstream HTTP status code — not
116
+ # sensitive — is forwarded; the message stays a fixed, generic
117
+ # string rather than anything derived from the broker's own
118
+ # wording.
119
+ return error_response(
120
+ "provider_error",
121
+ "The SMS provider rejected the request.",
122
+ detail={"status_code": exc.status_code},
123
+ status_code=502,
124
+ )
125
+ except SmsTransportError:
126
+ # ``str(exc)`` here is whatever the underlying HTTP client
127
+ # raised (e.g. ``requests``'s ``ConnectionError``/``Timeout``
128
+ # repr), which routinely embeds the broker's hostname, port,
129
+ # and URL path — operator infrastructure details this
130
+ # endpoint's caller (potentially anonymous, if
131
+ # PERMISSION_CLASSES has been relaxed) is not entitled to.
132
+ # Mirrors the ``SmsProviderError`` branch above: a fixed,
133
+ # generic message only. The real detail is still persisted to
134
+ # ``SmsLog.error`` for operators.
135
+ return error_response(
136
+ "transport_error",
137
+ "The SMS provider could not be reached.",
138
+ status_code=502,
139
+ )
140
+
141
+ if not result.ok:
142
+ # ``FAIL_SILENTLY=True`` makes the backend RETURN an ``ok=False``
143
+ # ``SendResult`` instead of raising one of the exceptions caught
144
+ # above -- so this branch is the only place that catches that
145
+ # case. Without it, a failed send under FAIL_SILENTLY fell
146
+ # through to the 201 success response below. As with the
147
+ # ``SmsProviderError`` branch above, the broker's raw response
148
+ # body is never forwarded to the caller; only the log id, so an
149
+ # operator can look up ``SmsLog.error``/``.provider_response``.
150
+ return error_response(
151
+ "provider_error",
152
+ "The SMS provider rejected the request.",
153
+ detail={"log_id": result.log_id},
154
+ status_code=502,
155
+ )
156
+
157
+ # ``result.log_id`` is carried straight out of ``SmsClient.send()``
158
+ # (see ``uzsms.services._with_log_ids``) — it is the pk of exactly
159
+ # the log row this result belongs to, no re-query needed. It is
160
+ # ``None`` when ``sms_settings.LOG_MESSAGES`` is disabled, since no
161
+ # log row was ever created. ``result.ok`` is always True here — a
162
+ # ``False`` result returns 502 above, before this point.
163
+ data = {
164
+ "message_id": result.message.message_id,
165
+ "log_id": result.log_id,
166
+ "phone_number": result.message.phone_number,
167
+ "status": SmsLog.Status.SENT.value,
168
+ }
169
+ return success_response(data, status_code=201)
170
+
171
+ def handle_exception(self, exc: Exception) -> Response:
172
+ """Re-shape DRF's own error responses into this API's envelope.
173
+
174
+ Delegates to DRF's default handling for the status code and any
175
+ headers it attaches (``WWW-Authenticate``, ``Retry-After``), and
176
+ rewrites only the body — so auth, permission, throttling, and
177
+ parsing failures never leak DRF's bare ``{"detail": ...}`` shape.
178
+ """
179
+ response = super().handle_exception(exc)
180
+ code = _error_code_for(exc)
181
+
182
+ if isinstance(response.data, dict) and set(response.data) == {"detail"}:
183
+ message = str(response.data["detail"])
184
+ detail = None
185
+ else:
186
+ message = "Invalid request." if code == "validation_error" else str(exc)
187
+ detail = response.data
188
+
189
+ enveloped = error_response(code, message, detail, status_code=response.status_code)
190
+ for header, value in response.items():
191
+ enveloped[header] = value
192
+ return enveloped
193
+
194
+
195
+ send_sms_api_view = SendSmsAPIView.as_view()
uzsms/apps.py ADDED
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class UzSmsConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'uzsms'
7
+ label = 'SMS'
8
+ verbose_name = 'SMS'
@@ -0,0 +1,64 @@
1
+ """Backend loader for uzsms.
2
+
3
+ Resolves a dotted backend path (``sms_settings.BACKEND`` for
4
+ :func:`get_backend`, ``sms_settings.ASYNC_BACKEND`` for
5
+ :func:`get_async_backend`, when no explicit ``path`` is given) to a backend
6
+ class via :func:`django.utils.module_loading.import_string`, and
7
+ instantiates it. The resolved class is cached per ``(path, base)`` pair so
8
+ repeated calls avoid re-importing; each call still returns a fresh
9
+ *instance*, since backends may hold open connections.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from django.utils.module_loading import import_string
17
+
18
+ from uzsms.backends.base import BaseAsyncSmsBackend, BaseSmsBackend
19
+ from uzsms.conf import sms_settings
20
+ from uzsms.exceptions import SmsConfigurationError
21
+
22
+ _backend_class_cache: dict[tuple[str, type], type] = {}
23
+
24
+
25
+ def _resolve_backend_class(path: str, base: type) -> type:
26
+ cache_key = (path, base)
27
+ if cache_key in _backend_class_cache:
28
+ return _backend_class_cache[cache_key]
29
+
30
+ try:
31
+ backend_class = import_string(path)
32
+ except ImportError as exc:
33
+ raise SmsConfigurationError(f"Could not import SMS backend {path!r}: {exc}") from exc
34
+
35
+ if not (isinstance(backend_class, type) and issubclass(backend_class, base)):
36
+ raise SmsConfigurationError(
37
+ f"SMS backend {path!r} does not resolve to a subclass of {base.__name__}."
38
+ )
39
+
40
+ _backend_class_cache[cache_key] = backend_class
41
+ return backend_class
42
+
43
+
44
+ def get_backend(path: str | None = None, **kwargs: Any) -> BaseSmsBackend:
45
+ """Resolve and instantiate a synchronous SMS backend.
46
+
47
+ Defaults to ``sms_settings.BACKEND`` when ``path`` is ``None``.
48
+ """
49
+ resolved_path = sms_settings.BACKEND if path is None else path
50
+ backend_class = _resolve_backend_class(resolved_path, BaseSmsBackend)
51
+ return backend_class(**kwargs)
52
+
53
+
54
+ def get_async_backend(path: str | None = None, **kwargs: Any) -> BaseAsyncSmsBackend:
55
+ """Resolve and instantiate an asynchronous SMS backend.
56
+
57
+ Defaults to ``sms_settings.ASYNC_BACKEND`` when ``path`` is ``None`` --
58
+ a separate setting from ``sms_settings.BACKEND`` (which defaults to the
59
+ *sync* Playmobile backend), so ``SmsClient`` and ``AsyncSmsClient`` can
60
+ both be constructed from one unmodified ``SMS_SETTINGS`` dict.
61
+ """
62
+ resolved_path = sms_settings.ASYNC_BACKEND if path is None else path
63
+ backend_class = _resolve_backend_class(resolved_path, BaseAsyncSmsBackend)
64
+ return backend_class(**kwargs)
uzsms/backends/base.py ADDED
@@ -0,0 +1,79 @@
1
+ """Abstract backend interface for uzsms.
2
+
3
+ This module must not import ``django.conf.settings`` at module import
4
+ time, so that importing ``uzsms.backends.base`` never requires
5
+ ``SMS_SETTINGS`` to be configured.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import abc
11
+ from collections.abc import Sequence
12
+ from typing import TYPE_CHECKING
13
+
14
+ from uzsms.conf import sms_settings
15
+ from uzsms.dto import SendResult, SmsMessage
16
+
17
+ if TYPE_CHECKING:
18
+ from typing_extensions import Self
19
+
20
+
21
+ class BaseSmsBackend(abc.ABC):
22
+ """Abstract base class for synchronous SMS backends.
23
+
24
+ Subclasses implement :meth:`send_messages`. ``open``/``close`` are
25
+ no-ops by default; override them to acquire/release resources such
26
+ as a pooled HTTP connection.
27
+ """
28
+
29
+ def __init__(self, *, fail_silently: bool | None = None) -> None:
30
+ self.fail_silently = (
31
+ sms_settings.FAIL_SILENTLY if fail_silently is None else fail_silently
32
+ )
33
+
34
+ def open(self) -> None:
35
+ """Open any resources needed to send messages."""
36
+
37
+ def close(self) -> None:
38
+ """Close resources opened by :meth:`open`."""
39
+
40
+ def __enter__(self) -> Self:
41
+ self.open()
42
+ return self
43
+
44
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
45
+ self.close()
46
+
47
+ @abc.abstractmethod
48
+ def send_messages(self, messages: Sequence[SmsMessage]) -> list[SendResult]:
49
+ """Send ``messages`` and return one :class:`SendResult` per message, in order."""
50
+
51
+
52
+ class BaseAsyncSmsBackend(abc.ABC):
53
+ """Abstract base class for asynchronous SMS backends.
54
+
55
+ Mirrors :class:`BaseSmsBackend`, but with an ``async`` ``open``/``close``
56
+ and ``send_messages``.
57
+ """
58
+
59
+ def __init__(self, *, fail_silently: bool | None = None) -> None:
60
+ self.fail_silently = (
61
+ sms_settings.FAIL_SILENTLY if fail_silently is None else fail_silently
62
+ )
63
+
64
+ async def open(self) -> None:
65
+ """Open any resources needed to send messages."""
66
+
67
+ async def close(self) -> None:
68
+ """Close resources opened by :meth:`open`."""
69
+
70
+ async def __aenter__(self) -> Self:
71
+ await self.open()
72
+ return self
73
+
74
+ async def __aexit__(self, exc_type, exc_value, traceback) -> None:
75
+ await self.close()
76
+
77
+ @abc.abstractmethod
78
+ async def send_messages(self, messages: Sequence[SmsMessage]) -> list[SendResult]:
79
+ """Send ``messages`` and return one :class:`SendResult` per message, in order."""
@@ -0,0 +1,33 @@
1
+ """Console SMS backend.
2
+
3
+ Writes a readable rendering of each outgoing message to stdout instead of
4
+ sending it. Performs zero network I/O; useful for local development.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+
11
+ from uzsms.backends.base import BaseSmsBackend
12
+ from uzsms.dto import SendResult, SmsMessage
13
+
14
+
15
+ class ConsoleBackend(BaseSmsBackend):
16
+ """Prints each message to stdout and reports it as successfully sent."""
17
+
18
+ def send_messages(self, messages: Sequence[SmsMessage]) -> list[SendResult]:
19
+ results = []
20
+ for message in messages:
21
+ print(
22
+ "\n".join(
23
+ (
24
+ "-" * 40,
25
+ f"To: {message.phone_number}",
26
+ f"Message-ID: {message.message_id}",
27
+ message.text,
28
+ "-" * 40,
29
+ )
30
+ )
31
+ )
32
+ results.append(SendResult(message=message, ok=True))
33
+ return results
@@ -0,0 +1,19 @@
1
+ """Dummy SMS backend.
2
+
3
+ Discards every message without sending it. Performs zero network I/O;
4
+ useful for tests or environments where SMS sending should be a no-op.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+
11
+ from uzsms.backends.base import BaseSmsBackend
12
+ from uzsms.dto import SendResult, SmsMessage
13
+
14
+
15
+ class DummyBackend(BaseSmsBackend):
16
+ """Discards all messages and reports each as successfully sent."""
17
+
18
+ def send_messages(self, messages: Sequence[SmsMessage]) -> list[SendResult]:
19
+ return [SendResult(message=message, ok=True) for message in messages]
@@ -0,0 +1,39 @@
1
+ """In-memory SMS backend for tests.
2
+
3
+ Mirrors the idiom of Django's ``django.core.mail.outbox``: every message
4
+ sent through :class:`LocMemBackend` is appended to the module-level
5
+ ``outbox`` list, so tests can import this module and inspect it directly
6
+ without holding a reference to the backend instance. Performs zero
7
+ network I/O.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Sequence
13
+
14
+ from uzsms.backends.base import BaseAsyncSmsBackend, BaseSmsBackend
15
+ from uzsms.dto import SendResult, SmsMessage
16
+
17
+ outbox: list[SmsMessage] = []
18
+
19
+
20
+ class LocMemBackend(BaseSmsBackend):
21
+ """Appends each sent message to the module-level :data:`outbox`."""
22
+
23
+ def send_messages(self, messages: Sequence[SmsMessage]) -> list[SendResult]:
24
+ results = []
25
+ for message in messages:
26
+ outbox.append(message)
27
+ results.append(SendResult(message=message, ok=True))
28
+ return results
29
+
30
+
31
+ class AsyncLocMemBackend(BaseAsyncSmsBackend):
32
+ """Appends each sent message to the same module-level :data:`outbox`."""
33
+
34
+ async def send_messages(self, messages: Sequence[SmsMessage]) -> list[SendResult]:
35
+ results = []
36
+ for message in messages:
37
+ outbox.append(message)
38
+ results.append(SendResult(message=message, ok=True))
39
+ return results