BmorricalDjangoWorkflows 2.0.3__tar.gz → 2.1.0__tar.gz
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.
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/PKG-INFO +20 -1
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/SOURCES.txt +4 -1
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/PKG-INFO +20 -1
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/README.md +19 -0
- bmorricaldjangoworkflows-2.1.0/VERSION +1 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/services/email.py +20 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/users/services/auth_flow.py +193 -1
- bmorricaldjangoworkflows-2.1.0/django_workflows/users/urls.py +19 -0
- bmorricaldjangoworkflows-2.1.0/django_workflows/users/views.py +189 -0
- bmorricaldjangoworkflows-2.1.0/tests/test_auth_flow.py +1259 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/tests/test_email.py +99 -1
- bmorricaldjangoworkflows-2.1.0/tests/test_user_views.py +358 -0
- bmorricaldjangoworkflows-2.0.3/VERSION +0 -1
- bmorricaldjangoworkflows-2.0.3/tests/test_auth_flow.py +0 -537
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/dependency_links.txt +0 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/requires.txt +0 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/top_level.txt +0 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/LICENSE +0 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/MANIFEST.in +0 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/__init__.py +0 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/services/__init__.py +0 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/users/__init__.py +0 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/users/services/__init__.py +0 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/setup.cfg +0 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/setup.py +0 -0
- {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: BmorricalDjangoWorkflows
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Reusable Django Workflows
|
|
5
5
|
Home-page: https://github.com/Bmorrical/django-workflows
|
|
6
6
|
Author: Bradley Morrical
|
|
@@ -104,6 +104,7 @@ For the `mailgun` provider, also set:
|
|
|
104
104
|
Optional:
|
|
105
105
|
|
|
106
106
|
- `BCC_RECIPIENTS` as a comma-separated list
|
|
107
|
+
- `EMAIL_REPLY_TO` as a single email or comma-separated list for reply handling
|
|
107
108
|
- `EMAIL_LOG_RESPONSE_TEXT=true` to log response bodies at debug level
|
|
108
109
|
|
|
109
110
|
Example:
|
|
@@ -115,6 +116,7 @@ EMAIL_FROM="My Company <no-reply@example.com>"
|
|
|
115
116
|
EMAIL_DOMAIN="mg.example.com"
|
|
116
117
|
EMAIL_API_KEY="key-example"
|
|
117
118
|
BCC_RECIPIENTS="audit@example.com,ops@example.com"
|
|
119
|
+
EMAIL_REPLY_TO="support@example.com"
|
|
118
120
|
```
|
|
119
121
|
|
|
120
122
|
If `BCC_RECIPIENTS` is not set, no static BCC recipients are added.
|
|
@@ -129,6 +131,7 @@ If a consuming app previously used the Mailgun-specific names, update them as fo
|
|
|
129
131
|
- `MAILGUN_API_KEY` -> `EMAIL_API_KEY`
|
|
130
132
|
- `MAILGUN_DOMAIN` -> `EMAIL_DOMAIN`
|
|
131
133
|
- `MAILGUN_BCC_RECIPIENTS` -> `BCC_RECIPIENTS`
|
|
134
|
+
- no old equivalent -> `EMAIL_REPLY_TO`
|
|
132
135
|
|
|
133
136
|
### Built-In Defaults
|
|
134
137
|
|
|
@@ -141,6 +144,7 @@ If a consuming app does not define one of these values in `.env`, the package no
|
|
|
141
144
|
- `DEVELOPMENT_MODE=False`
|
|
142
145
|
- `DEFAULT_FROM_EMAIL=""`
|
|
143
146
|
- `BCC_RECIPIENTS=""`
|
|
147
|
+
- `EMAIL_REPLY_TO=""`
|
|
144
148
|
- `EMAIL_LOG_RESPONSE_TEXT=False`
|
|
145
149
|
|
|
146
150
|
Additional behavior:
|
|
@@ -154,9 +158,19 @@ Additional behavior:
|
|
|
154
158
|
Practical implication:
|
|
155
159
|
|
|
156
160
|
- Production apps using Mailgun should set `ENABLE_EMAIL_DELIVERY`, `EMAIL_FROM`, `EMAIL_API_KEY`, and `EMAIL_DOMAIN`.
|
|
161
|
+
- If replies should go to a real inbox, also set `EMAIL_REPLY_TO`.
|
|
157
162
|
- Local apps using Mailpit should usually set `EMAIL_DELIVERY_PROVIDER=mailpit`, `EMAIL_FROM`, and the Django SMTP settings shown below.
|
|
158
163
|
- Apps that do not want this package to send email can omit everything and leave delivery disabled.
|
|
159
164
|
|
|
165
|
+
Example for a branded sender with a monitored reply inbox:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
EMAIL_FROM="Bourbonnais Township Highway Department <no-reply@mail.bthwy.org>"
|
|
169
|
+
EMAIL_REPLY_TO="office@bthwy.org"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
This keeps the authenticated sender in the `From` header while directing user replies to the `EMAIL_REPLY_TO` inbox.
|
|
173
|
+
|
|
160
174
|
## Django Email Backend Settings
|
|
161
175
|
|
|
162
176
|
If a consuming app uses `EMAIL_DELIVERY_PROVIDER=smtp` or `EMAIL_DELIVERY_PROVIDER=mailpit`, it should configure Django's email backend in `settings.py`.
|
|
@@ -227,6 +241,7 @@ response = send_email(
|
|
|
227
241
|
html="<p>Thanks for joining.</p>",
|
|
228
242
|
cc="manager@example.com",
|
|
229
243
|
bcc=["audit@example.com"],
|
|
244
|
+
reply_to="support@example.com",
|
|
230
245
|
attachments=[("report.pdf", pdf_bytes, "application/pdf")],
|
|
231
246
|
)
|
|
232
247
|
|
|
@@ -293,6 +308,10 @@ This is expected unless you set `BCC_RECIPIENTS`.
|
|
|
293
308
|
|
|
294
309
|
## Release
|
|
295
310
|
|
|
311
|
+
Update code and ./VERSION
|
|
312
|
+
|
|
313
|
+
Commit changes
|
|
314
|
+
|
|
296
315
|
Create a release tag:
|
|
297
316
|
|
|
298
317
|
```bash
|
|
@@ -12,8 +12,11 @@ django_workflows/__init__.py
|
|
|
12
12
|
django_workflows/services/__init__.py
|
|
13
13
|
django_workflows/services/email.py
|
|
14
14
|
django_workflows/users/__init__.py
|
|
15
|
+
django_workflows/users/urls.py
|
|
16
|
+
django_workflows/users/views.py
|
|
15
17
|
django_workflows/users/services/__init__.py
|
|
16
18
|
django_workflows/users/services/auth_flow.py
|
|
17
19
|
tests/__init__.py
|
|
18
20
|
tests/test_auth_flow.py
|
|
19
|
-
tests/test_email.py
|
|
21
|
+
tests/test_email.py
|
|
22
|
+
tests/test_user_views.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: BmorricalDjangoWorkflows
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Reusable Django Workflows
|
|
5
5
|
Home-page: https://github.com/Bmorrical/django-workflows
|
|
6
6
|
Author: Bradley Morrical
|
|
@@ -104,6 +104,7 @@ For the `mailgun` provider, also set:
|
|
|
104
104
|
Optional:
|
|
105
105
|
|
|
106
106
|
- `BCC_RECIPIENTS` as a comma-separated list
|
|
107
|
+
- `EMAIL_REPLY_TO` as a single email or comma-separated list for reply handling
|
|
107
108
|
- `EMAIL_LOG_RESPONSE_TEXT=true` to log response bodies at debug level
|
|
108
109
|
|
|
109
110
|
Example:
|
|
@@ -115,6 +116,7 @@ EMAIL_FROM="My Company <no-reply@example.com>"
|
|
|
115
116
|
EMAIL_DOMAIN="mg.example.com"
|
|
116
117
|
EMAIL_API_KEY="key-example"
|
|
117
118
|
BCC_RECIPIENTS="audit@example.com,ops@example.com"
|
|
119
|
+
EMAIL_REPLY_TO="support@example.com"
|
|
118
120
|
```
|
|
119
121
|
|
|
120
122
|
If `BCC_RECIPIENTS` is not set, no static BCC recipients are added.
|
|
@@ -129,6 +131,7 @@ If a consuming app previously used the Mailgun-specific names, update them as fo
|
|
|
129
131
|
- `MAILGUN_API_KEY` -> `EMAIL_API_KEY`
|
|
130
132
|
- `MAILGUN_DOMAIN` -> `EMAIL_DOMAIN`
|
|
131
133
|
- `MAILGUN_BCC_RECIPIENTS` -> `BCC_RECIPIENTS`
|
|
134
|
+
- no old equivalent -> `EMAIL_REPLY_TO`
|
|
132
135
|
|
|
133
136
|
### Built-In Defaults
|
|
134
137
|
|
|
@@ -141,6 +144,7 @@ If a consuming app does not define one of these values in `.env`, the package no
|
|
|
141
144
|
- `DEVELOPMENT_MODE=False`
|
|
142
145
|
- `DEFAULT_FROM_EMAIL=""`
|
|
143
146
|
- `BCC_RECIPIENTS=""`
|
|
147
|
+
- `EMAIL_REPLY_TO=""`
|
|
144
148
|
- `EMAIL_LOG_RESPONSE_TEXT=False`
|
|
145
149
|
|
|
146
150
|
Additional behavior:
|
|
@@ -154,9 +158,19 @@ Additional behavior:
|
|
|
154
158
|
Practical implication:
|
|
155
159
|
|
|
156
160
|
- Production apps using Mailgun should set `ENABLE_EMAIL_DELIVERY`, `EMAIL_FROM`, `EMAIL_API_KEY`, and `EMAIL_DOMAIN`.
|
|
161
|
+
- If replies should go to a real inbox, also set `EMAIL_REPLY_TO`.
|
|
157
162
|
- Local apps using Mailpit should usually set `EMAIL_DELIVERY_PROVIDER=mailpit`, `EMAIL_FROM`, and the Django SMTP settings shown below.
|
|
158
163
|
- Apps that do not want this package to send email can omit everything and leave delivery disabled.
|
|
159
164
|
|
|
165
|
+
Example for a branded sender with a monitored reply inbox:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
EMAIL_FROM="Bourbonnais Township Highway Department <no-reply@mail.bthwy.org>"
|
|
169
|
+
EMAIL_REPLY_TO="office@bthwy.org"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
This keeps the authenticated sender in the `From` header while directing user replies to the `EMAIL_REPLY_TO` inbox.
|
|
173
|
+
|
|
160
174
|
## Django Email Backend Settings
|
|
161
175
|
|
|
162
176
|
If a consuming app uses `EMAIL_DELIVERY_PROVIDER=smtp` or `EMAIL_DELIVERY_PROVIDER=mailpit`, it should configure Django's email backend in `settings.py`.
|
|
@@ -227,6 +241,7 @@ response = send_email(
|
|
|
227
241
|
html="<p>Thanks for joining.</p>",
|
|
228
242
|
cc="manager@example.com",
|
|
229
243
|
bcc=["audit@example.com"],
|
|
244
|
+
reply_to="support@example.com",
|
|
230
245
|
attachments=[("report.pdf", pdf_bytes, "application/pdf")],
|
|
231
246
|
)
|
|
232
247
|
|
|
@@ -293,6 +308,10 @@ This is expected unless you set `BCC_RECIPIENTS`.
|
|
|
293
308
|
|
|
294
309
|
## Release
|
|
295
310
|
|
|
311
|
+
Update code and ./VERSION
|
|
312
|
+
|
|
313
|
+
Commit changes
|
|
314
|
+
|
|
296
315
|
Create a release tag:
|
|
297
316
|
|
|
298
317
|
```bash
|
|
@@ -80,6 +80,7 @@ For the `mailgun` provider, also set:
|
|
|
80
80
|
Optional:
|
|
81
81
|
|
|
82
82
|
- `BCC_RECIPIENTS` as a comma-separated list
|
|
83
|
+
- `EMAIL_REPLY_TO` as a single email or comma-separated list for reply handling
|
|
83
84
|
- `EMAIL_LOG_RESPONSE_TEXT=true` to log response bodies at debug level
|
|
84
85
|
|
|
85
86
|
Example:
|
|
@@ -91,6 +92,7 @@ EMAIL_FROM="My Company <no-reply@example.com>"
|
|
|
91
92
|
EMAIL_DOMAIN="mg.example.com"
|
|
92
93
|
EMAIL_API_KEY="key-example"
|
|
93
94
|
BCC_RECIPIENTS="audit@example.com,ops@example.com"
|
|
95
|
+
EMAIL_REPLY_TO="support@example.com"
|
|
94
96
|
```
|
|
95
97
|
|
|
96
98
|
If `BCC_RECIPIENTS` is not set, no static BCC recipients are added.
|
|
@@ -105,6 +107,7 @@ If a consuming app previously used the Mailgun-specific names, update them as fo
|
|
|
105
107
|
- `MAILGUN_API_KEY` -> `EMAIL_API_KEY`
|
|
106
108
|
- `MAILGUN_DOMAIN` -> `EMAIL_DOMAIN`
|
|
107
109
|
- `MAILGUN_BCC_RECIPIENTS` -> `BCC_RECIPIENTS`
|
|
110
|
+
- no old equivalent -> `EMAIL_REPLY_TO`
|
|
108
111
|
|
|
109
112
|
### Built-In Defaults
|
|
110
113
|
|
|
@@ -117,6 +120,7 @@ If a consuming app does not define one of these values in `.env`, the package no
|
|
|
117
120
|
- `DEVELOPMENT_MODE=False`
|
|
118
121
|
- `DEFAULT_FROM_EMAIL=""`
|
|
119
122
|
- `BCC_RECIPIENTS=""`
|
|
123
|
+
- `EMAIL_REPLY_TO=""`
|
|
120
124
|
- `EMAIL_LOG_RESPONSE_TEXT=False`
|
|
121
125
|
|
|
122
126
|
Additional behavior:
|
|
@@ -130,9 +134,19 @@ Additional behavior:
|
|
|
130
134
|
Practical implication:
|
|
131
135
|
|
|
132
136
|
- Production apps using Mailgun should set `ENABLE_EMAIL_DELIVERY`, `EMAIL_FROM`, `EMAIL_API_KEY`, and `EMAIL_DOMAIN`.
|
|
137
|
+
- If replies should go to a real inbox, also set `EMAIL_REPLY_TO`.
|
|
133
138
|
- Local apps using Mailpit should usually set `EMAIL_DELIVERY_PROVIDER=mailpit`, `EMAIL_FROM`, and the Django SMTP settings shown below.
|
|
134
139
|
- Apps that do not want this package to send email can omit everything and leave delivery disabled.
|
|
135
140
|
|
|
141
|
+
Example for a branded sender with a monitored reply inbox:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
EMAIL_FROM="Bourbonnais Township Highway Department <no-reply@mail.bthwy.org>"
|
|
145
|
+
EMAIL_REPLY_TO="office@bthwy.org"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
This keeps the authenticated sender in the `From` header while directing user replies to the `EMAIL_REPLY_TO` inbox.
|
|
149
|
+
|
|
136
150
|
## Django Email Backend Settings
|
|
137
151
|
|
|
138
152
|
If a consuming app uses `EMAIL_DELIVERY_PROVIDER=smtp` or `EMAIL_DELIVERY_PROVIDER=mailpit`, it should configure Django's email backend in `settings.py`.
|
|
@@ -203,6 +217,7 @@ response = send_email(
|
|
|
203
217
|
html="<p>Thanks for joining.</p>",
|
|
204
218
|
cc="manager@example.com",
|
|
205
219
|
bcc=["audit@example.com"],
|
|
220
|
+
reply_to="support@example.com",
|
|
206
221
|
attachments=[("report.pdf", pdf_bytes, "application/pdf")],
|
|
207
222
|
)
|
|
208
223
|
|
|
@@ -269,6 +284,10 @@ This is expected unless you set `BCC_RECIPIENTS`.
|
|
|
269
284
|
|
|
270
285
|
## Release
|
|
271
286
|
|
|
287
|
+
Update code and ./VERSION
|
|
288
|
+
|
|
289
|
+
Commit changes
|
|
290
|
+
|
|
272
291
|
Create a release tag:
|
|
273
292
|
|
|
274
293
|
```bash
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.1.0
|
{bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/services/email.py
RENAMED
|
@@ -19,6 +19,7 @@ EMAIL_DOMAIN = os.getenv("EMAIL_DOMAIN", "")
|
|
|
19
19
|
DEVELOPMENT_MODE = (os.getenv("DEVELOPMENT_MODE") or "").lower() == "true"
|
|
20
20
|
EMAIL_DELIVERY_PROVIDER = (os.getenv("EMAIL_DELIVERY_PROVIDER") or "").lower()
|
|
21
21
|
BCC_RECIPIENTS = os.getenv("BCC_RECIPIENTS", "")
|
|
22
|
+
EMAIL_REPLY_TO = os.getenv("EMAIL_REPLY_TO", "")
|
|
22
23
|
LOG_EMAIL_RESPONSE_TEXT = (os.getenv("EMAIL_LOG_RESPONSE_TEXT") or "").lower() == "true"
|
|
23
24
|
|
|
24
25
|
|
|
@@ -112,6 +113,12 @@ def _build_bcc_recipients(bcc: RecipientInput) -> List[str]:
|
|
|
112
113
|
return deduped_bcc
|
|
113
114
|
|
|
114
115
|
|
|
116
|
+
def _build_reply_to(reply_to: RecipientInput) -> List[str]:
|
|
117
|
+
configured_reply_to = _recipients_to_list(globals().get("EMAIL_REPLY_TO", ""))
|
|
118
|
+
passed_reply_to = _recipients_to_list(reply_to)
|
|
119
|
+
return _filter_valid_emails(configured_reply_to + passed_reply_to, field_name="reply_to")
|
|
120
|
+
|
|
121
|
+
|
|
115
122
|
def _send_via_mailgun(
|
|
116
123
|
to: Union[str, Sequence[str]],
|
|
117
124
|
subject: str,
|
|
@@ -119,6 +126,7 @@ def _send_via_mailgun(
|
|
|
119
126
|
attachments: Optional[Sequence[Attachment]] = None,
|
|
120
127
|
cc: RecipientInput = None,
|
|
121
128
|
bcc: RecipientInput = None,
|
|
129
|
+
reply_to: RecipientInput = None,
|
|
122
130
|
) -> Optional[requests.Response]:
|
|
123
131
|
if not _has_mailgun_transport_config():
|
|
124
132
|
return None
|
|
@@ -137,6 +145,10 @@ def _send_via_mailgun(
|
|
|
137
145
|
"html": html,
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
reply_to_list = _build_reply_to(reply_to)
|
|
149
|
+
if reply_to_list:
|
|
150
|
+
data["h:Reply-To"] = ", ".join(reply_to_list)
|
|
151
|
+
|
|
140
152
|
if cc is not None:
|
|
141
153
|
cc_list = _filter_valid_emails(_recipients_to_list(cc), field_name="cc")
|
|
142
154
|
if cc_list:
|
|
@@ -174,6 +186,7 @@ def _send_via_smtp(
|
|
|
174
186
|
attachments: Optional[Sequence[Attachment]] = None,
|
|
175
187
|
cc: RecipientInput = None,
|
|
176
188
|
bcc: RecipientInput = None,
|
|
189
|
+
reply_to: RecipientInput = None,
|
|
177
190
|
) -> Optional[DeliveryResult]:
|
|
178
191
|
to_list = _filter_valid_emails(_recipients_to_list(to), field_name="to")
|
|
179
192
|
if not to_list:
|
|
@@ -182,6 +195,7 @@ def _send_via_smtp(
|
|
|
182
195
|
|
|
183
196
|
cc_list = _filter_valid_emails(_recipients_to_list(cc), field_name="cc") if cc is not None else []
|
|
184
197
|
bcc_list = _build_bcc_recipients(bcc)
|
|
198
|
+
reply_to_list = _build_reply_to(reply_to)
|
|
185
199
|
|
|
186
200
|
message = EmailMultiAlternatives(
|
|
187
201
|
subject=subject,
|
|
@@ -190,6 +204,7 @@ def _send_via_smtp(
|
|
|
190
204
|
to=to_list,
|
|
191
205
|
cc=cc_list,
|
|
192
206
|
bcc=bcc_list,
|
|
207
|
+
reply_to=reply_to_list,
|
|
193
208
|
)
|
|
194
209
|
message.attach_alternative(html, "text/html")
|
|
195
210
|
|
|
@@ -255,6 +270,7 @@ def send_email(
|
|
|
255
270
|
attachments: Optional[Sequence[Attachment]] = None,
|
|
256
271
|
cc: RecipientInput = None,
|
|
257
272
|
bcc: RecipientInput = None,
|
|
273
|
+
reply_to: RecipientInput = None,
|
|
258
274
|
enabled: Any = None,
|
|
259
275
|
) -> EmailDeliveryResponse:
|
|
260
276
|
"""Send an email using the configured delivery transport.
|
|
@@ -267,6 +283,8 @@ def send_email(
|
|
|
267
283
|
cc: Optional CC recipient(s). Accepts a string (single or comma-separated) or an iterable of emails.
|
|
268
284
|
bcc: Optional additional BCC recipient(s). Accepts a string (single or comma-separated) or an iterable.
|
|
269
285
|
Note: `BCC_RECIPIENTS` (if defined) are always included when sending.
|
|
286
|
+
reply_to: Optional reply-to recipient(s). Accepts a string (single or comma-separated) or an iterable.
|
|
287
|
+
Note: `EMAIL_REPLY_TO` (if defined) is always included when sending.
|
|
270
288
|
enabled: Optional application-level override. Truthy values force delivery,
|
|
271
289
|
falsey values disable delivery, and `None` uses the configured transport.
|
|
272
290
|
"""
|
|
@@ -283,6 +301,7 @@ def send_email(
|
|
|
283
301
|
attachments=attachments,
|
|
284
302
|
cc=cc,
|
|
285
303
|
bcc=bcc,
|
|
304
|
+
reply_to=reply_to,
|
|
286
305
|
)
|
|
287
306
|
|
|
288
307
|
if delivery_provider in {"mailpit", "smtp"}:
|
|
@@ -293,6 +312,7 @@ def send_email(
|
|
|
293
312
|
attachments=attachments,
|
|
294
313
|
cc=cc,
|
|
295
314
|
bcc=bcc,
|
|
315
|
+
reply_to=reply_to,
|
|
296
316
|
)
|
|
297
317
|
|
|
298
318
|
logger.info("Email delivery is disabled. Email not sent.")
|
|
@@ -149,6 +149,120 @@ def _get_user_meta_model() -> Type[Any]:
|
|
|
149
149
|
f"Unable to import WORKFLOWS['USER_META_MODEL']: {model_path!r}"
|
|
150
150
|
) from exc
|
|
151
151
|
|
|
152
|
+
|
|
153
|
+
def _get_group_model() -> Type[Any]:
|
|
154
|
+
from django.contrib.auth.models import Group
|
|
155
|
+
|
|
156
|
+
return Group
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _normalize_group_names(value: Any) -> list[str]:
|
|
160
|
+
if value is None:
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
if isinstance(value, str):
|
|
164
|
+
candidates = value.split(",")
|
|
165
|
+
elif isinstance(value, (list, tuple, set)):
|
|
166
|
+
candidates = []
|
|
167
|
+
for item in value:
|
|
168
|
+
if isinstance(item, str):
|
|
169
|
+
candidates.extend(item.split(","))
|
|
170
|
+
elif item is not None:
|
|
171
|
+
candidates.append(str(item))
|
|
172
|
+
else:
|
|
173
|
+
candidates = [str(value)]
|
|
174
|
+
|
|
175
|
+
normalized: list[str] = []
|
|
176
|
+
seen: set[str] = set()
|
|
177
|
+
for candidate in candidates:
|
|
178
|
+
name = candidate.strip()
|
|
179
|
+
if name and name not in seen:
|
|
180
|
+
seen.add(name)
|
|
181
|
+
normalized.append(name)
|
|
182
|
+
|
|
183
|
+
return normalized
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _get_existing_groups(group_names: Any) -> list[Any]:
|
|
187
|
+
normalized_names = _normalize_group_names(group_names)
|
|
188
|
+
if not normalized_names:
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
Group = _get_group_model()
|
|
192
|
+
return list(Group.objects.filter(name__in=normalized_names))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _add_existing_groups_to_user(*, user: Any, group_names: Any) -> list[str]:
|
|
196
|
+
groups = _get_existing_groups(group_names)
|
|
197
|
+
group_manager = getattr(user, "groups", None)
|
|
198
|
+
|
|
199
|
+
if not groups or group_manager is None or not hasattr(group_manager, "add"):
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
group_manager.add(*groups)
|
|
203
|
+
return [group.name for group in groups]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _coerce_bool(value: Any) -> bool:
|
|
207
|
+
if isinstance(value, bool):
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
if isinstance(value, str):
|
|
211
|
+
normalized = value.strip().lower()
|
|
212
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
213
|
+
return True
|
|
214
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
raise ValueError("Boolean value required.")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _get_force_password_reset(user: Any) -> bool:
|
|
221
|
+
UserMeta = _get_user_meta_model()
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
meta = UserMeta.objects.get(user=user)
|
|
225
|
+
return bool(getattr(meta, "force_password_reset", False))
|
|
226
|
+
except UserMeta.DoesNotExist:
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def get_user_details(user: Any) -> dict[str, Any]:
|
|
231
|
+
return {
|
|
232
|
+
"id": user.id,
|
|
233
|
+
"username": user.username,
|
|
234
|
+
"email": user.email,
|
|
235
|
+
"first_name": user.first_name,
|
|
236
|
+
"last_name": user.last_name,
|
|
237
|
+
"is_active": bool(getattr(user, "is_active", False)),
|
|
238
|
+
"force_password_reset": _get_force_password_reset(user),
|
|
239
|
+
"groups": [group.name for group in user.groups.all()],
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def list_users(*, exclude_groups: Any = None) -> list[dict[str, Any]]:
|
|
244
|
+
User = get_user_model()
|
|
245
|
+
excluded_group_names = set(_normalize_group_names(exclude_groups))
|
|
246
|
+
users = list(User.objects.all())
|
|
247
|
+
users.sort(key=lambda user: getattr(user, "id", 0))
|
|
248
|
+
|
|
249
|
+
if excluded_group_names:
|
|
250
|
+
filtered_users = []
|
|
251
|
+
for user in users:
|
|
252
|
+
user_group_names = {group.name for group in user.groups.all()}
|
|
253
|
+
if user_group_names.intersection(excluded_group_names):
|
|
254
|
+
continue
|
|
255
|
+
filtered_users.append(user)
|
|
256
|
+
users = filtered_users
|
|
257
|
+
|
|
258
|
+
return [get_user_details(user) for user in users]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass(frozen=True)
|
|
262
|
+
class UserUpdateResult:
|
|
263
|
+
success: bool
|
|
264
|
+
message: str
|
|
265
|
+
|
|
152
266
|
class VerifyCodeResult(Enum):
|
|
153
267
|
SUCCESS = "success"
|
|
154
268
|
INVALID = "invalid"
|
|
@@ -177,7 +291,14 @@ class ChangePasswordResult:
|
|
|
177
291
|
message: str
|
|
178
292
|
|
|
179
293
|
|
|
180
|
-
def register_user_and_send_verification_email(
|
|
294
|
+
def register_user_and_send_verification_email(
|
|
295
|
+
email: str,
|
|
296
|
+
first_name: str,
|
|
297
|
+
last_name: str,
|
|
298
|
+
password: str,
|
|
299
|
+
group: Any = None,
|
|
300
|
+
groups: Any = None,
|
|
301
|
+
) -> ServiceResult:
|
|
181
302
|
"""
|
|
182
303
|
Registers a new user and sends a verification email.
|
|
183
304
|
"""
|
|
@@ -219,6 +340,13 @@ def register_user_and_send_verification_email(email: str, first_name: str, last_
|
|
|
219
340
|
meta.verify_time = timezone.now()
|
|
220
341
|
meta.save()
|
|
221
342
|
|
|
343
|
+
requested_groups = _normalize_group_names(groups)
|
|
344
|
+
if group is not None:
|
|
345
|
+
requested_groups.extend(
|
|
346
|
+
name for name in _normalize_group_names(group) if name not in requested_groups
|
|
347
|
+
)
|
|
348
|
+
_add_existing_groups_to_user(user=user, group_names=requested_groups)
|
|
349
|
+
|
|
222
350
|
message = (
|
|
223
351
|
f"Hello {first_name} {last_name},<br><br>"
|
|
224
352
|
f"Your account verification code is: <strong>{verification_code}</strong><br /><br />"
|
|
@@ -273,6 +401,70 @@ def register_user_and_send_verification_email(email: str, first_name: str, last_
|
|
|
273
401
|
message="Registration successful. Please check your email for the verification code.",
|
|
274
402
|
)
|
|
275
403
|
|
|
404
|
+
|
|
405
|
+
def update_user(user: Any, data: dict[str, Any]) -> UserUpdateResult:
|
|
406
|
+
processed_update = False
|
|
407
|
+
user_fields_updated = False
|
|
408
|
+
|
|
409
|
+
email_value = data.get("email")
|
|
410
|
+
username_value = data.get("username")
|
|
411
|
+
if email_value is not None or username_value is not None:
|
|
412
|
+
next_email = email_value if email_value is not None else username_value
|
|
413
|
+
user.username = next_email
|
|
414
|
+
user.email = next_email
|
|
415
|
+
processed_update = True
|
|
416
|
+
user_fields_updated = True
|
|
417
|
+
|
|
418
|
+
if "first_name" in data:
|
|
419
|
+
user.first_name = data["first_name"]
|
|
420
|
+
processed_update = True
|
|
421
|
+
user_fields_updated = True
|
|
422
|
+
if "last_name" in data:
|
|
423
|
+
user.last_name = data["last_name"]
|
|
424
|
+
processed_update = True
|
|
425
|
+
user_fields_updated = True
|
|
426
|
+
if "password" in data:
|
|
427
|
+
user.set_password(data["password"])
|
|
428
|
+
processed_update = True
|
|
429
|
+
user_fields_updated = True
|
|
430
|
+
if "is_active" in data:
|
|
431
|
+
try:
|
|
432
|
+
user.is_active = _coerce_bool(data["is_active"])
|
|
433
|
+
except ValueError:
|
|
434
|
+
return UserUpdateResult(
|
|
435
|
+
success=False,
|
|
436
|
+
message="is_active must be a boolean value.",
|
|
437
|
+
)
|
|
438
|
+
processed_update = True
|
|
439
|
+
user_fields_updated = True
|
|
440
|
+
|
|
441
|
+
requested_groups = []
|
|
442
|
+
if "groups" in data:
|
|
443
|
+
requested_groups.extend(_normalize_group_names(data.get("groups")))
|
|
444
|
+
processed_update = True
|
|
445
|
+
if "group" in data:
|
|
446
|
+
requested_groups.extend(
|
|
447
|
+
name for name in _normalize_group_names(data.get("group")) if name not in requested_groups
|
|
448
|
+
)
|
|
449
|
+
processed_update = True
|
|
450
|
+
if requested_groups:
|
|
451
|
+
_add_existing_groups_to_user(user=user, group_names=requested_groups)
|
|
452
|
+
|
|
453
|
+
if user_fields_updated:
|
|
454
|
+
user.save()
|
|
455
|
+
|
|
456
|
+
if not processed_update:
|
|
457
|
+
return UserUpdateResult(
|
|
458
|
+
success=False,
|
|
459
|
+
message="No valid fields provided.",
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
logger.info("User updated for %s", user.email)
|
|
463
|
+
return UserUpdateResult(
|
|
464
|
+
success=True,
|
|
465
|
+
message="User updated successfully.",
|
|
466
|
+
)
|
|
467
|
+
|
|
276
468
|
def send_forgot_password_code(email: str) -> ServiceResult:
|
|
277
469
|
"""
|
|
278
470
|
Generates and sends a password reset code.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from django.urls import path
|
|
2
|
+
|
|
3
|
+
from django_workflows.users.views import (
|
|
4
|
+
ChangePasswordView,
|
|
5
|
+
ForgotPasswordView,
|
|
6
|
+
UserCreateView,
|
|
7
|
+
UserDetailView,
|
|
8
|
+
UserListView,
|
|
9
|
+
VerifyCodeView,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
urlpatterns = [
|
|
13
|
+
path("user/", UserDetailView.as_view(), name="user-detail"),
|
|
14
|
+
path("users/", UserListView.as_view(), name="user-list"),
|
|
15
|
+
path("forgot-password/", ForgotPasswordView.as_view(), name="forgot-password"),
|
|
16
|
+
path("verify-code/", VerifyCodeView.as_view(), name="verify-code"),
|
|
17
|
+
path("change-password/", ChangePasswordView.as_view(), name="change-password"),
|
|
18
|
+
path("register/", UserCreateView.as_view(), name="register"),
|
|
19
|
+
]
|