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 +66 -0
- uzsms/admin.py +30 -0
- uzsms/api/__init__.py +9 -0
- uzsms/api/responses.py +36 -0
- uzsms/api/serializers.py +34 -0
- uzsms/api/urls.py +18 -0
- uzsms/api/views.py +195 -0
- uzsms/apps.py +8 -0
- uzsms/backends/__init__.py +64 -0
- uzsms/backends/base.py +79 -0
- uzsms/backends/console.py +33 -0
- uzsms/backends/dummy.py +19 -0
- uzsms/backends/locmem.py +39 -0
- uzsms/backends/playmobile.py +343 -0
- uzsms/compat.py +67 -0
- uzsms/conf.py +102 -0
- uzsms/dto.py +30 -0
- uzsms/exceptions.py +47 -0
- uzsms/migrations/0001_initial.py +70 -0
- uzsms/migrations/__init__.py +0 -0
- uzsms/models.py +75 -0
- uzsms/py.typed +0 -0
- uzsms/repository.py +66 -0
- uzsms/services.py +184 -0
- uzsms/tasks.py +72 -0
- uzsms/urls.py +23 -0
- uzsms/validators.py +54 -0
- uzsms-2.0.0.dist-info/METADATA +450 -0
- uzsms-2.0.0.dist-info/RECORD +32 -0
- uzsms-2.0.0.dist-info/WHEEL +5 -0
- uzsms-2.0.0.dist-info/licenses/LICENSE +16 -0
- uzsms-2.0.0.dist-info/top_level.txt +1 -0
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
|
+
)
|
uzsms/api/serializers.py
ADDED
|
@@ -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,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
|
uzsms/backends/dummy.py
ADDED
|
@@ -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]
|
uzsms/backends/locmem.py
ADDED
|
@@ -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
|