ntc-dev-kit 1.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.
Files changed (148) hide show
  1. ntc/__init__.py +5 -0
  2. ntc/app_creator.py +296 -0
  3. ntc/cli.py +4 -0
  4. ntc/cli_enhanced.py +820 -0
  5. ntc/composer/__init__.py +0 -0
  6. ntc/composer/main.py +443 -0
  7. ntc/deployment/__init__.py +0 -0
  8. ntc/templates/__init__.py +2 -0
  9. ntc/templates/auth_app/__init__.py +0 -0
  10. ntc/templates/auth_app/admin.py +24 -0
  11. ntc/templates/auth_app/apps.py +6 -0
  12. ntc/templates/auth_app/models.py +30 -0
  13. ntc/templates/auth_app/serializers.py +101 -0
  14. ntc/templates/auth_app/urls.py +19 -0
  15. ntc/templates/auth_app/views.py +175 -0
  16. ntc/templates/drf_structure/__init__.py +1 -0
  17. ntc/templates/drf_structure/drf_structure.md +83 -0
  18. ntc/templates/drf_structure/main.py +389 -0
  19. ntc/templates/drf_structure/structure.json +126 -0
  20. ntc/templates/drf_structure/templates/.entry/start_core_backend.sh +37 -0
  21. ntc/templates/drf_structure/templates/.entry/start_tasks_backend.sh +30 -0
  22. ntc/templates/drf_structure/templates/CLAUDE.md +194 -0
  23. ntc/templates/drf_structure/templates/README.md +117 -0
  24. ntc/templates/drf_structure/templates/apps/app_admin.py +3 -0
  25. ntc/templates/drf_structure/templates/apps/app_apps.py +6 -0
  26. ntc/templates/drf_structure/templates/apps/app_models.py +4 -0
  27. ntc/templates/drf_structure/templates/apps/app_urls.py +8 -0
  28. ntc/templates/drf_structure/templates/apps/app_views.py +6 -0
  29. ntc/templates/drf_structure/templates/apps/common_apps.py +6 -0
  30. ntc/templates/drf_structure/templates/apps/common_models.py +8 -0
  31. ntc/templates/drf_structure/templates/config/gunicorn_config.py +9 -0
  32. ntc/templates/drf_structure/templates/config/http.conf +33 -0
  33. ntc/templates/drf_structure/templates/core/base_model.py +9 -0
  34. ntc/templates/drf_structure/templates/core/formatter.py +5 -0
  35. ntc/templates/drf_structure/templates/core/logger.py +8 -0
  36. ntc/templates/drf_structure/templates/core/pagination.py +6 -0
  37. ntc/templates/drf_structure/templates/core/response_wrapper.py +7 -0
  38. ntc/templates/drf_structure/templates/core/services/azure/email/backend.py +108 -0
  39. ntc/templates/drf_structure/templates/core/services/azure/email/client.py +296 -0
  40. ntc/templates/drf_structure/templates/core/services/azure/email/notifier.py +102 -0
  41. ntc/templates/drf_structure/templates/core/services/object_storage.py +285 -0
  42. ntc/templates/drf_structure/templates/django_project/asgi.py +5 -0
  43. ntc/templates/drf_structure/templates/django_project/celery.py +8 -0
  44. ntc/templates/drf_structure/templates/django_project/dev_settings.py +2 -0
  45. ntc/templates/drf_structure/templates/django_project/django_settings.txt +322 -0
  46. ntc/templates/drf_structure/templates/django_project/management_command.py +114 -0
  47. ntc/templates/drf_structure/templates/django_project/prod_settings.py +3 -0
  48. ntc/templates/drf_structure/templates/django_project/schema.py +7 -0
  49. ntc/templates/drf_structure/templates/django_project/urls.py +6 -0
  50. ntc/templates/drf_structure/templates/django_project/wsgi.py +5 -0
  51. ntc/templates/drf_structure/templates/docker/docker-compose.yml +11 -0
  52. ntc/templates/drf_structure/templates/knowledge/knowledge_description.md +201 -0
  53. ntc/templates/drf_structure/templates/knowledge/knowledge_design_pattern.md +722 -0
  54. ntc/templates/drf_structure/templates/knowledge/knowledge_structure.md +151 -0
  55. ntc/templates/drf_structure/templates/root/README.md +307 -0
  56. ntc/templates/drf_structure/templates/root/django_env.txt +98 -0
  57. ntc/templates/drf_structure/templates/root/django_requirements.txt +24 -0
  58. ntc/templates/drf_structure/templates/root/gitignore.txt +6 -0
  59. ntc/templates/drf_structure/templates/root/manage.py +11 -0
  60. ntc/templates/flutter/.gitignore +44 -0
  61. ntc/templates/flutter/.knowledge/knowledge_description.md +215 -0
  62. ntc/templates/flutter/.knowledge/knowledge_design_pattern.md +310 -0
  63. ntc/templates/flutter/.knowledge/knowledge_structure.md +83 -0
  64. ntc/templates/flutter/CLAUDE.md +190 -0
  65. ntc/templates/flutter/__init__.py +0 -0
  66. ntc/templates/flutter/analysis_options.yaml +9 -0
  67. ntc/templates/flutter/main.py +187 -0
  68. ntc/templates/flutter/pubspec.yaml +65 -0
  69. ntc/templates/flutter/structure.json +51 -0
  70. ntc/templates/nextjs/.env.example +10 -0
  71. ntc/templates/nextjs/.gitignore +36 -0
  72. ntc/templates/nextjs/CLAUDE.md +181 -0
  73. ntc/templates/nextjs/README.md +94 -0
  74. ntc/templates/nextjs/__init__.py +0 -0
  75. ntc/templates/nextjs/knowledge/knowledge_description.md +208 -0
  76. ntc/templates/nextjs/knowledge/knowledge_design_pattern.md +460 -0
  77. ntc/templates/nextjs/knowledge/knowledge_structure.md +75 -0
  78. ntc/templates/nextjs/main.py +224 -0
  79. ntc/templates/nextjs/package.json +25 -0
  80. ntc/templates/nextjs/src/app/globals.css +26 -0
  81. ntc/templates/nextjs/src/app/layout.tsx +59 -0
  82. ntc/templates/nextjs/src/app/page.tsx +21 -0
  83. ntc/templates/nextjs/src/components/Header.tsx +16 -0
  84. ntc/templates/nextjs/src/services/api.ts +81 -0
  85. ntc/templates/nextjs/src/utils/constants.ts +1 -0
  86. ntc/templates/nextjs/structure.json +32 -0
  87. ntc/templates/nextjs/tsconfig.json +27 -0
  88. ntc/templates/react_admin/.env.example +1 -0
  89. ntc/templates/react_admin/.gitignore +10 -0
  90. ntc/templates/react_admin/CLAUDE.md +210 -0
  91. ntc/templates/react_admin/README.md +72 -0
  92. ntc/templates/react_admin/__init__.py +0 -0
  93. ntc/templates/react_admin/index.html +13 -0
  94. ntc/templates/react_admin/knowledge/knowledge_description.md +202 -0
  95. ntc/templates/react_admin/knowledge/knowledge_design_pattern.md +448 -0
  96. ntc/templates/react_admin/knowledge/knowledge_structure.md +97 -0
  97. ntc/templates/react_admin/main.py +208 -0
  98. ntc/templates/react_admin/package.json +35 -0
  99. ntc/templates/react_admin/src/App.tsx +60 -0
  100. ntc/templates/react_admin/src/components/ProtectedRoute.tsx +16 -0
  101. ntc/templates/react_admin/src/components/Sidebar.tsx +90 -0
  102. ntc/templates/react_admin/src/components/TopNav.tsx +44 -0
  103. ntc/templates/react_admin/src/context/AuthContext.tsx +107 -0
  104. ntc/templates/react_admin/src/context/ThemeContext.tsx +34 -0
  105. ntc/templates/react_admin/src/context/WorkspaceContext.tsx +28 -0
  106. ntc/templates/react_admin/src/hooks/useApi.ts +39 -0
  107. ntc/templates/react_admin/src/index.css +34 -0
  108. ntc/templates/react_admin/src/layouts/AuthLayout.tsx +23 -0
  109. ntc/templates/react_admin/src/layouts/DashboardLayout.tsx +24 -0
  110. ntc/templates/react_admin/src/main.tsx +5 -0
  111. ntc/templates/react_admin/src/pages/DashboardPage.tsx +10 -0
  112. ntc/templates/react_admin/src/pages/auth/LoginPage.tsx +79 -0
  113. ntc/templates/react_admin/src/pages/auth/RegisterPage.tsx +93 -0
  114. ntc/templates/react_admin/src/services/api.service.ts +44 -0
  115. ntc/templates/react_admin/src/services/base.service.ts +193 -0
  116. ntc/templates/react_admin/structure.json +52 -0
  117. ntc/templates/react_admin/tsconfig.app.json +25 -0
  118. ntc/templates/react_admin/tsconfig.json +7 -0
  119. ntc/templates/react_admin/tsconfig.node.json +19 -0
  120. ntc/templates/react_admin/vite.config.ts +13 -0
  121. ntc/templates/react_structure/CLAUDE.md +176 -0
  122. ntc/templates/react_structure/__init__.py +1 -0
  123. ntc/templates/react_structure/index.css +21 -0
  124. ntc/templates/react_structure/index.html +13 -0
  125. ntc/templates/react_structure/knowledge/knowledge_description.md +170 -0
  126. ntc/templates/react_structure/knowledge/knowledge_design_pattern.md +411 -0
  127. ntc/templates/react_structure/knowledge/knowledge_structure.md +74 -0
  128. ntc/templates/react_structure/main.py +217 -0
  129. ntc/templates/react_structure/package.json +32 -0
  130. ntc/templates/react_structure/src/App.tsx +63 -0
  131. ntc/templates/react_structure/src/components/Header.tsx +51 -0
  132. ntc/templates/react_structure/src/main.tsx +5 -0
  133. ntc/templates/react_structure/src/services/api.ts +90 -0
  134. ntc/templates/react_structure/src/utils/constants.ts +1 -0
  135. ntc/templates/react_structure/structure.json +65 -0
  136. ntc/templates/react_structure/tsconfig.app.json +25 -0
  137. ntc/templates/react_structure/tsconfig.json +7 -0
  138. ntc/templates/react_structure/tsconfig.node.json +19 -0
  139. ntc/templates/react_structure/vite.config.ts +12 -0
  140. ntc/utils/__init__.py +1 -0
  141. ntc/utils/environment.py +67 -0
  142. ntc/utils/output.py +41 -0
  143. ntc_dev_kit-1.0.0.dist-info/METADATA +188 -0
  144. ntc_dev_kit-1.0.0.dist-info/RECORD +148 -0
  145. ntc_dev_kit-1.0.0.dist-info/WHEEL +5 -0
  146. ntc_dev_kit-1.0.0.dist-info/entry_points.txt +2 -0
  147. ntc_dev_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
  148. ntc_dev_kit-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,108 @@
1
+ """Django email backend that sends through Azure Communication Services.
2
+
3
+ Configure with:
4
+
5
+ EMAIL_BACKEND = 'core.services.azure.email.backend.AzureCommunicationEmailBackend'
6
+
7
+ This backend is a drop-in replacement for the SMTP / console backends.
8
+ It supports ``EmailMessage`` and ``EmailMultiAlternatives`` (HTML +
9
+ text). When ``fail_silently=False`` (the default), Azure errors bubble
10
+ up; otherwise they are logged and counted as 0 successful sends.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import Iterable
16
+
17
+ from django.core.mail.backends.base import BaseEmailBackend
18
+ from django.core.mail.message import EmailMessage, EmailMultiAlternatives
19
+
20
+ from core.services.azure.email.client import (
21
+ AzureEmailClient,
22
+ AzureEmailNotConfigured,
23
+ AzureEmailSendError,
24
+ EmailMessageSpec,
25
+ EmailRecipient,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _to_recipients(addresses: Iterable[str]):
32
+ return [EmailRecipient(address=a) for a in addresses if a]
33
+
34
+
35
+ def _split_html_text(message: EmailMessage):
36
+ """Return ``(plain_text, html)`` from a Django ``EmailMessage``."""
37
+ plain = None
38
+ html = None
39
+ if isinstance(message, EmailMultiAlternatives):
40
+ plain = message.body or None
41
+ for content, mime in message.alternatives or []:
42
+ if mime == 'text/html':
43
+ html = content
44
+ break
45
+ else:
46
+ if (message.content_subtype or 'plain') == 'html':
47
+ html = message.body or None
48
+ else:
49
+ plain = message.body or None
50
+ return plain, html
51
+
52
+
53
+ class AzureCommunicationEmailBackend(BaseEmailBackend):
54
+ """Send Django ``EmailMessage`` instances through Azure ACS Email."""
55
+
56
+ def __init__(self, fail_silently: bool = False, **kwargs):
57
+ super().__init__(fail_silently=fail_silently)
58
+ self._client = AzureEmailClient()
59
+
60
+ def send_messages(self, email_messages):
61
+ if not email_messages:
62
+ return 0
63
+
64
+ sent = 0
65
+ for message in email_messages:
66
+ if self._send(message):
67
+ sent += 1
68
+ return sent
69
+
70
+ # ── Internal ──────────────────────────────────────────────────────────
71
+ def _send(self, message: EmailMessage) -> bool:
72
+ if not message.recipients():
73
+ return False
74
+
75
+ plain, html = _split_html_text(message)
76
+ spec = EmailMessageSpec(
77
+ subject=message.subject or '(no subject)',
78
+ to=_to_recipients(message.to),
79
+ cc=_to_recipients(message.cc),
80
+ bcc=_to_recipients(message.bcc),
81
+ reply_to=_to_recipients(message.reply_to),
82
+ sender=message.from_email or None,
83
+ plain_text=plain,
84
+ html=html,
85
+ headers=dict(message.extra_headers or {}),
86
+ )
87
+
88
+ try:
89
+ self._client.send(spec)
90
+ return True
91
+ except AzureEmailNotConfigured:
92
+ if not self.fail_silently:
93
+ raise
94
+ logger.warning(
95
+ 'Azure ACS email is not configured; dropping message to %s',
96
+ message.to,
97
+ )
98
+ return False
99
+ except AzureEmailSendError as exc:
100
+ if not self.fail_silently:
101
+ raise
102
+ logger.error('ACS email send failed: %s', exc)
103
+ return False
104
+ except Exception as exc: # pragma: no cover - defensive
105
+ if not self.fail_silently:
106
+ raise
107
+ logger.exception('Unexpected error sending ACS email: %s', exc)
108
+ return False
@@ -0,0 +1,296 @@
1
+ """Thin wrapper around Azure Communication Services Email.
2
+
3
+ This module hides the Azure SDK behind a small, mockable surface so the
4
+ rest of the codebase only has to know about :class:`AzureEmailClient`.
5
+
6
+ Configuration (read from ``django.conf.settings``):
7
+
8
+ - ``AZURE_COMMUNICATION_ENDPOINT`` (e.g. ``https://xxx.communication.azure.com``)
9
+ - ``AZURE_COMMUNICATION_ACCESS_KEY``
10
+ - ``AZURE_COMMUNICATION_EMAIL_SENDER`` (the verified MailFrom address,
11
+ e.g. ``DoNotReply@xxxxxxxx.azurecomm.net``) — or the per-purpose
12
+ ``AZURE_COMMUNICATION_EMAIL_SENDER_*`` slots resolved via
13
+ :func:`resolve_sender`.
14
+
15
+ Optional tuning:
16
+
17
+ - ``AZURE_COMMUNICATION_EMAIL_POLLING_INTERVAL_SECONDS`` (default ``2``)
18
+ - ``AZURE_COMMUNICATION_EMAIL_TIMEOUT_SECONDS`` (default ``60``)
19
+
20
+ When the endpoint or access key is missing, the client raises
21
+ :class:`AzureEmailNotConfigured` so callers (or the Django backend) can
22
+ fall back to ``django.core.mail.backends.console.EmailBackend`` in dev.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import time
28
+ from dataclasses import dataclass, field
29
+ from typing import Iterable, List, Optional
30
+
31
+ from django.conf import settings
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class AzureEmailNotConfigured(RuntimeError):
37
+ """Raised when the Azure Communication Services email client is used
38
+ without a connection string + sender configured."""
39
+
40
+
41
+ class AzureEmailSendError(RuntimeError):
42
+ """Raised when Azure rejects a send or polling fails."""
43
+
44
+
45
+ @dataclass
46
+ class EmailRecipient:
47
+ """An ACS recipient row (``{address, displayName}``)."""
48
+
49
+ address: str
50
+ display_name: str = ''
51
+
52
+ def to_dict(self) -> dict:
53
+ d = {'address': self.address}
54
+ if self.display_name:
55
+ d['displayName'] = self.display_name
56
+ return d
57
+
58
+
59
+ @dataclass
60
+ class EmailMessageSpec:
61
+ """Strict, validated message spec passed to :meth:`AzureEmailClient.send`.
62
+
63
+ Bodies are optional individually but at least one of ``html`` / ``plain_text``
64
+ must be set.
65
+ """
66
+
67
+ subject: str
68
+ to: List[EmailRecipient]
69
+ plain_text: Optional[str] = None
70
+ html: Optional[str] = None
71
+ cc: List[EmailRecipient] = field(default_factory=list)
72
+ bcc: List[EmailRecipient] = field(default_factory=list)
73
+ reply_to: List[EmailRecipient] = field(default_factory=list)
74
+ sender: Optional[str] = None
75
+ headers: dict = field(default_factory=dict)
76
+
77
+ def to_acs_payload(self, default_sender: str) -> dict:
78
+ if not self.subject:
79
+ raise ValueError('subject is required')
80
+ if not self.to:
81
+ raise ValueError('at least one "to" recipient is required')
82
+ if not self.html and not self.plain_text:
83
+ raise ValueError('one of html or plain_text body is required')
84
+
85
+ content: dict = {'subject': self.subject}
86
+ if self.plain_text:
87
+ content['plainText'] = self.plain_text
88
+ if self.html:
89
+ content['html'] = self.html
90
+
91
+ recipients: dict = {'to': [r.to_dict() for r in self.to]}
92
+ if self.cc:
93
+ recipients['cc'] = [r.to_dict() for r in self.cc]
94
+ if self.bcc:
95
+ recipients['bcc'] = [r.to_dict() for r in self.bcc]
96
+
97
+ message: dict = {
98
+ 'senderAddress': self.sender or default_sender,
99
+ 'content': content,
100
+ 'recipients': recipients,
101
+ }
102
+ if self.reply_to:
103
+ message['replyTo'] = [r.to_dict() for r in self.reply_to]
104
+ if self.headers:
105
+ # ACS exposes headers under ``content`` for some SDK versions; we
106
+ # keep them as ``headers`` which the SDK normalises.
107
+ message['headers'] = [
108
+ {'name': k, 'value': v} for k, v in self.headers.items()
109
+ ]
110
+ return message
111
+
112
+
113
+ def _coerce_recipients(addresses: Iterable[str]) -> List[EmailRecipient]:
114
+ return [EmailRecipient(address=a) for a in addresses if a]
115
+
116
+
117
+ # Canonical sender purposes. Keep in sync with
118
+ # ``settings.AZURE_COMMUNICATION_EMAIL_SENDERS``.
119
+ SENDER_NO_REPLY = 'no_reply'
120
+ SENDER_NOTIFICATION = 'notification'
121
+ SENDER_SUPPORT = 'support'
122
+ SENDER_MARKETING = 'marketing'
123
+ VALID_SENDER_KEYS = frozenset({
124
+ SENDER_NO_REPLY, SENDER_NOTIFICATION, SENDER_SUPPORT, SENDER_MARKETING,
125
+ })
126
+
127
+
128
+ def resolve_sender(sender: Optional[str]) -> str:
129
+ """Turn a purpose keyword or literal address into a concrete MailFrom.
130
+
131
+ - ``None`` → default sender (``AZURE_COMMUNICATION_EMAIL_DEFAULT_SENDER_KEY``
132
+ or legacy ``AZURE_COMMUNICATION_EMAIL_SENDER``).
133
+ - A key in ``VALID_SENDER_KEYS`` → looked up in
134
+ ``AZURE_COMMUNICATION_EMAIL_SENDERS``.
135
+ - Anything containing ``@`` → treated as a literal address and
136
+ returned verbatim.
137
+ - Unknown keys → ``ValueError``.
138
+ """
139
+ senders = getattr(settings, 'AZURE_COMMUNICATION_EMAIL_SENDERS', {}) or {}
140
+ default_key = getattr(
141
+ settings, 'AZURE_COMMUNICATION_EMAIL_DEFAULT_SENDER_KEY', 'notification',
142
+ )
143
+ legacy = getattr(settings, 'AZURE_COMMUNICATION_EMAIL_SENDER', '') or ''
144
+
145
+ if sender is None or sender == '':
146
+ return senders.get(default_key) or legacy
147
+
148
+ if '@' in sender:
149
+ return sender
150
+
151
+ if sender not in VALID_SENDER_KEYS:
152
+ raise ValueError(
153
+ f'Unknown sender key {sender!r}. Expected one of '
154
+ f'{sorted(VALID_SENDER_KEYS)} or an email address.'
155
+ )
156
+ resolved = senders.get(sender) or legacy
157
+ if not resolved:
158
+ raise ValueError(
159
+ f'Sender {sender!r} is not configured. Set '
160
+ f'AZURE_COMMUNICATION_EMAIL_SENDER_{sender.upper()}.'
161
+ )
162
+ return resolved
163
+
164
+
165
+ class AzureEmailClient:
166
+ """Wraps :class:`azure.communication.email.EmailClient`.
167
+
168
+ The client is lazily instantiated so importing this module never
169
+ forces an Azure SDK dependency check at start-up.
170
+ """
171
+
172
+ def __init__(
173
+ self,
174
+ endpoint: Optional[str] = None,
175
+ access_key: Optional[str] = None,
176
+ sender: Optional[str] = None,
177
+ polling_interval_seconds: Optional[int] = None,
178
+ timeout_seconds: Optional[int] = None,
179
+ ):
180
+ self._endpoint = endpoint if endpoint is not None else getattr(
181
+ settings, 'AZURE_COMMUNICATION_ENDPOINT', '',
182
+ )
183
+ self._access_key = access_key if access_key is not None else getattr(
184
+ settings, 'AZURE_COMMUNICATION_ACCESS_KEY', '',
185
+ )
186
+ self._sender = sender or getattr(
187
+ settings, 'AZURE_COMMUNICATION_EMAIL_SENDER', '',
188
+ )
189
+ self._polling_interval = polling_interval_seconds or getattr(
190
+ settings, 'AZURE_COMMUNICATION_EMAIL_POLLING_INTERVAL_SECONDS', 2,
191
+ )
192
+ self._timeout = timeout_seconds or getattr(
193
+ settings, 'AZURE_COMMUNICATION_EMAIL_TIMEOUT_SECONDS', 60,
194
+ )
195
+ self._client = None
196
+
197
+ # ── Internal ──────────────────────────────────────────────────────────
198
+ def _has_credentials(self) -> bool:
199
+ return bool(self._endpoint and self._access_key)
200
+
201
+ def _ensure_client(self):
202
+ if self._client is not None:
203
+ return self._client
204
+
205
+ if not self._has_credentials() or not self._sender:
206
+ raise AzureEmailNotConfigured(
207
+ 'Azure Communication Services email is not configured. '
208
+ 'Set AZURE_COMMUNICATION_ENDPOINT, '
209
+ 'AZURE_COMMUNICATION_ACCESS_KEY and '
210
+ 'AZURE_COMMUNICATION_EMAIL_SENDER (or one of the '
211
+ 'per-purpose AZURE_COMMUNICATION_EMAIL_SENDER_* slots).'
212
+ )
213
+
214
+ # Imported lazily so the rest of the codebase doesn't hard-fail
215
+ # if the Azure SDK ever gets removed.
216
+ from azure.communication.email import EmailClient
217
+ from azure.core.credentials import AzureKeyCredential
218
+
219
+ self._client = EmailClient(
220
+ endpoint=self._endpoint,
221
+ credential=AzureKeyCredential(self._access_key),
222
+ )
223
+ return self._client
224
+
225
+ # ── Public ────────────────────────────────────────────────────────────
226
+ @property
227
+ def is_configured(self) -> bool:
228
+ return self._has_credentials() and bool(self._sender)
229
+
230
+ def send(self, message: EmailMessageSpec) -> str:
231
+ """Send ``message`` via ACS and return the operation ``messageId``.
232
+
233
+ Blocks (politely) until ACS reports the operation completed or
234
+ the timeout is reached.
235
+ """
236
+ client = self._ensure_client()
237
+ payload = message.to_acs_payload(default_sender=self._sender)
238
+
239
+ try:
240
+ poller = client.begin_send(payload)
241
+ except Exception as exc:
242
+ raise AzureEmailSendError(f'ACS rejected message: {exc}') from exc
243
+
244
+ # ``begin_send`` returns an LRO poller. ``result`` blocks until done.
245
+ deadline = time.monotonic() + self._timeout
246
+ while not poller.done():
247
+ if time.monotonic() > deadline:
248
+ raise AzureEmailSendError(
249
+ f'ACS send polling timed out after {self._timeout}s',
250
+ )
251
+ time.sleep(self._polling_interval)
252
+
253
+ result = poller.result()
254
+ # ``result`` is a dict-like with at least ``id`` and ``status``.
255
+ status = (result.get('status') or '').lower() if isinstance(result, dict) else ''
256
+ msg_id = (result.get('id') if isinstance(result, dict) else None) or ''
257
+ if status not in ('', 'succeeded'):
258
+ raise AzureEmailSendError(
259
+ f'ACS send finished with status={status!r} id={msg_id!r}',
260
+ )
261
+ return msg_id
262
+
263
+ # ── Convenience wrapper ───────────────────────────────────────────────
264
+ def send_simple(
265
+ self,
266
+ *,
267
+ subject: str,
268
+ to: Iterable[str],
269
+ html: Optional[str] = None,
270
+ text: Optional[str] = None,
271
+ cc: Iterable[str] = (),
272
+ bcc: Iterable[str] = (),
273
+ reply_to: Iterable[str] = (),
274
+ headers: Optional[dict] = None,
275
+ ) -> str:
276
+ return self.send(EmailMessageSpec(
277
+ subject=subject,
278
+ to=_coerce_recipients(to),
279
+ cc=_coerce_recipients(cc),
280
+ bcc=_coerce_recipients(bcc),
281
+ reply_to=_coerce_recipients(reply_to),
282
+ html=html,
283
+ plain_text=text,
284
+ headers=headers or {},
285
+ ))
286
+
287
+
288
+ _default_client: Optional[AzureEmailClient] = None
289
+
290
+
291
+ def get_default_client() -> AzureEmailClient:
292
+ """Process-global singleton, lazily constructed."""
293
+ global _default_client
294
+ if _default_client is None:
295
+ _default_client = AzureEmailClient()
296
+ return _default_client
@@ -0,0 +1,102 @@
1
+ """High-level templated email sender.
2
+
3
+ The rest of the codebase should call :func:`send_template_email` rather
4
+ than touching the Azure SDK or :class:`EmailMultiAlternatives` directly.
5
+ The function renders ``<template>.html`` and (optionally) ``<template>.txt``
6
+ under the configured template loader and sends through Django's
7
+ ``EMAIL_BACKEND`` — which is wired to Azure ACS in production.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Iterable, Optional
13
+
14
+ from django.conf import settings
15
+ from django.core.mail import EmailMultiAlternatives
16
+ from django.template import TemplateDoesNotExist
17
+ from django.template.loader import render_to_string
18
+
19
+ from core.services.azure.email.client import resolve_sender
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def send_template_email(
25
+ *,
26
+ subject: str,
27
+ to: Iterable[str] | str,
28
+ template: str,
29
+ context: Optional[dict] = None,
30
+ cc: Iterable[str] = (),
31
+ bcc: Iterable[str] = (),
32
+ reply_to: Iterable[str] = (),
33
+ from_email: Optional[str] = None,
34
+ sender: Optional[str] = None,
35
+ headers: Optional[dict] = None,
36
+ fail_silently: bool = False,
37
+ ) -> int:
38
+ """Render ``template`` and send the resulting message.
39
+
40
+ ``template`` is a basename. The function looks up
41
+ ``<template>.html`` (required) and ``<template>.txt`` (optional).
42
+ If the ``.txt`` file is missing, a stripped-text fallback is
43
+ generated from the HTML so non-HTML clients still see something.
44
+
45
+ Sender resolution (in order of precedence):
46
+
47
+ 1. Explicit ``from_email`` (literal address).
48
+ 2. ``sender`` keyword — one of ``no_reply`` / ``notification`` /
49
+ ``support`` / ``marketing`` — resolved against
50
+ ``settings.AZURE_COMMUNICATION_EMAIL_SENDERS``.
51
+ 3. ``settings.AZURE_COMMUNICATION_EMAIL_DEFAULT_SENDER_KEY`` →
52
+ ``AZURE_COMMUNICATION_EMAIL_SENDERS`` lookup.
53
+ 4. ``settings.DEFAULT_FROM_EMAIL``.
54
+
55
+ Returns the number of messages successfully delivered (0 or 1).
56
+ """
57
+ context = context or {}
58
+ if isinstance(to, str):
59
+ to_list = [to]
60
+ else:
61
+ to_list = [addr for addr in to if addr]
62
+ if not to_list:
63
+ logger.warning('send_template_email called without recipients')
64
+ return 0
65
+
66
+ try:
67
+ html_body = render_to_string(f'{template}.html', context)
68
+ except TemplateDoesNotExist:
69
+ # Re-raise loudly: a missing HTML template is a programmer error.
70
+ raise
71
+
72
+ try:
73
+ text_body = render_to_string(f'{template}.txt', context)
74
+ except TemplateDoesNotExist:
75
+ # Lightweight HTML→text fallback. Avoids a html2text dependency
76
+ # while still beating "no plain part at all".
77
+ from html import unescape
78
+ import re
79
+
80
+ text_body = unescape(re.sub(r'<[^>]+>', '', html_body)).strip()
81
+
82
+ if from_email:
83
+ resolved_from = from_email
84
+ else:
85
+ try:
86
+ resolved_from = resolve_sender(sender) or settings.DEFAULT_FROM_EMAIL
87
+ except ValueError as exc:
88
+ logger.error('send_template_email: %s', exc)
89
+ resolved_from = settings.DEFAULT_FROM_EMAIL
90
+
91
+ msg = EmailMultiAlternatives(
92
+ subject=subject,
93
+ body=text_body,
94
+ from_email=resolved_from,
95
+ to=to_list,
96
+ cc=list(cc) or None,
97
+ bcc=list(bcc) or None,
98
+ reply_to=list(reply_to) or None,
99
+ headers=headers or None,
100
+ )
101
+ msg.attach_alternative(html_body, 'text/html')
102
+ return msg.send(fail_silently=fail_silently)