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.
Files changed (26) hide show
  1. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/PKG-INFO +20 -1
  2. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/SOURCES.txt +4 -1
  3. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/PKG-INFO +20 -1
  4. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/README.md +19 -0
  5. bmorricaldjangoworkflows-2.1.0/VERSION +1 -0
  6. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/services/email.py +20 -0
  7. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/users/services/auth_flow.py +193 -1
  8. bmorricaldjangoworkflows-2.1.0/django_workflows/users/urls.py +19 -0
  9. bmorricaldjangoworkflows-2.1.0/django_workflows/users/views.py +189 -0
  10. bmorricaldjangoworkflows-2.1.0/tests/test_auth_flow.py +1259 -0
  11. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/tests/test_email.py +99 -1
  12. bmorricaldjangoworkflows-2.1.0/tests/test_user_views.py +358 -0
  13. bmorricaldjangoworkflows-2.0.3/VERSION +0 -1
  14. bmorricaldjangoworkflows-2.0.3/tests/test_auth_flow.py +0 -537
  15. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/dependency_links.txt +0 -0
  16. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/requires.txt +0 -0
  17. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/top_level.txt +0 -0
  18. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/LICENSE +0 -0
  19. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/MANIFEST.in +0 -0
  20. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/__init__.py +0 -0
  21. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/services/__init__.py +0 -0
  22. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/users/__init__.py +0 -0
  23. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/django_workflows/users/services/__init__.py +0 -0
  24. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/setup.cfg +0 -0
  25. {bmorricaldjangoworkflows-2.0.3 → bmorricaldjangoworkflows-2.1.0}/setup.py +0 -0
  26. {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
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
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
@@ -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(email: str, first_name: str, last_name: str, password: str) -> ServiceResult:
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
+ ]