BmorricalDjangoWorkflows 2.1.0__tar.gz → 2.1.2__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.1.0 → bmorricaldjangoworkflows-2.1.2}/BmorricalDjangoWorkflows.egg-info/PKG-INFO +1 -1
  2. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/PKG-INFO +1 -1
  3. bmorricaldjangoworkflows-2.1.2/VERSION +1 -0
  4. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/django_workflows/users/services/auth_flow.py +241 -46
  5. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/django_workflows/users/urls.py +2 -0
  6. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/django_workflows/users/views.py +121 -7
  7. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/tests/test_auth_flow.py +646 -41
  8. bmorricaldjangoworkflows-2.1.2/tests/test_user_views.py +666 -0
  9. bmorricaldjangoworkflows-2.1.0/VERSION +0 -1
  10. bmorricaldjangoworkflows-2.1.0/tests/test_user_views.py +0 -358
  11. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/BmorricalDjangoWorkflows.egg-info/SOURCES.txt +0 -0
  12. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/BmorricalDjangoWorkflows.egg-info/dependency_links.txt +0 -0
  13. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/BmorricalDjangoWorkflows.egg-info/requires.txt +0 -0
  14. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/BmorricalDjangoWorkflows.egg-info/top_level.txt +0 -0
  15. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/LICENSE +0 -0
  16. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/MANIFEST.in +0 -0
  17. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/README.md +0 -0
  18. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/django_workflows/__init__.py +0 -0
  19. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/django_workflows/services/__init__.py +0 -0
  20. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/django_workflows/services/email.py +0 -0
  21. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/django_workflows/users/__init__.py +0 -0
  22. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/django_workflows/users/services/__init__.py +0 -0
  23. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/setup.cfg +0 -0
  24. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/setup.py +0 -0
  25. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/tests/__init__.py +0 -0
  26. {bmorricaldjangoworkflows-2.1.0 → bmorricaldjangoworkflows-2.1.2}/tests/test_email.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: BmorricalDjangoWorkflows
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: Reusable Django Workflows
5
5
  Home-page: https://github.com/Bmorrical/django-workflows
6
6
  Author: Bradley Morrical
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: BmorricalDjangoWorkflows
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: Reusable Django Workflows
5
5
  Home-page: https://github.com/Bmorrical/django-workflows
6
6
  Author: Bradley Morrical
@@ -0,0 +1 @@
1
+ 2.1.2
@@ -32,6 +32,10 @@ def generate_verification_code(*, length: int = 6) -> str:
32
32
  return "".join(secrets.choice(_ALLOWED_CODE_CHARS) for _ in range(length))
33
33
 
34
34
 
35
+ def generate_strong_password(*, length: int = 32) -> str:
36
+ return secrets.token_urlsafe(length)
37
+
38
+
35
39
  def _redact_code(code: str) -> str:
36
40
  if not code:
37
41
  return ""
@@ -106,6 +110,26 @@ def _send_verification_email_or_compensate(*, user: Any, email: str, html: str,
106
110
  return False
107
111
 
108
112
 
113
+ def _send_admin_registration_email(*, user: Any, html: str, enabled: bool) -> bool:
114
+ response = send_email(
115
+ to=[user.email],
116
+ subject="Your account has been created",
117
+ html=html,
118
+ enabled=enabled,
119
+ )
120
+
121
+ if _is_delivery_success(response):
122
+ logger.info("Admin registration email sent to %s", user.email)
123
+ return True
124
+
125
+ logger.warning(
126
+ "Admin registration email could not be delivered to %s (response_code=%s)",
127
+ user.email,
128
+ getattr(response, "status_code", None),
129
+ )
130
+ return False
131
+
132
+
109
133
  def _workflows_settings() -> dict[str, Any]:
110
134
  if not settings.configured:
111
135
  return {}
@@ -156,6 +180,14 @@ def _get_group_model() -> Type[Any]:
156
180
  return Group
157
181
 
158
182
 
183
+ def _get_registration_username(*, email: str, username: Any = None) -> str:
184
+ if username is None:
185
+ return email
186
+
187
+ normalized_username = str(username).strip()
188
+ return normalized_username or email
189
+
190
+
159
191
  def _normalize_group_names(value: Any) -> list[str]:
160
192
  if value is None:
161
193
  return []
@@ -183,6 +215,33 @@ def _normalize_group_names(value: Any) -> list[str]:
183
215
  return normalized
184
216
 
185
217
 
218
+ def _normalize_group_payload(groups: Any) -> list[dict[str, Any]]:
219
+ if groups is None:
220
+ return []
221
+
222
+ if not isinstance(groups, list):
223
+ raise ValueError("groups must be a list of objects with name and enabled.")
224
+
225
+ normalized_groups: dict[str, bool] = {}
226
+ for group in groups:
227
+ if not isinstance(group, dict):
228
+ raise ValueError("groups must be a list of objects with name and enabled.")
229
+
230
+ name = str(group.get("name") or "").strip()
231
+ if not name or "enabled" not in group:
232
+ raise ValueError("Each group must include name and enabled.")
233
+
234
+ try:
235
+ normalized_groups[name] = _coerce_bool(group["enabled"])
236
+ except ValueError as exc:
237
+ raise ValueError("Each group enabled value must be boolean.") from exc
238
+
239
+ return [
240
+ {"name": name, "enabled": enabled}
241
+ for name, enabled in normalized_groups.items()
242
+ ]
243
+
244
+
186
245
  def _get_existing_groups(group_names: Any) -> list[Any]:
187
246
  normalized_names = _normalize_group_names(group_names)
188
247
  if not normalized_names:
@@ -192,21 +251,49 @@ def _get_existing_groups(group_names: Any) -> list[Any]:
192
251
  return list(Group.objects.filter(name__in=normalized_names))
193
252
 
194
253
 
195
- def _add_existing_groups_to_user(*, user: Any, group_names: Any) -> list[str]:
196
- groups = _get_existing_groups(group_names)
254
+ def _sync_user_groups(*, user: Any, groups: Any) -> dict[str, list[str]]:
255
+ normalized_groups = _normalize_group_payload(groups)
197
256
  group_manager = getattr(user, "groups", None)
198
257
 
199
- if not groups or group_manager is None or not hasattr(group_manager, "add"):
200
- return []
258
+ if not normalized_groups or group_manager is None:
259
+ return {"added": [], "removed": []}
260
+
261
+ existing_groups = {
262
+ group.name: group
263
+ for group in _get_existing_groups([group["name"] for group in normalized_groups])
264
+ }
265
+ groups_to_add = []
266
+ groups_to_remove = []
201
267
 
202
- group_manager.add(*groups)
203
- return [group.name for group in groups]
268
+ for group in normalized_groups:
269
+ existing_group = existing_groups.get(group["name"])
270
+ if existing_group is None:
271
+ continue
272
+
273
+ if group["enabled"]:
274
+ groups_to_add.append(existing_group)
275
+ else:
276
+ groups_to_remove.append(existing_group)
277
+
278
+ if groups_to_add and hasattr(group_manager, "add"):
279
+ group_manager.add(*groups_to_add)
280
+ if groups_to_remove and hasattr(group_manager, "remove"):
281
+ group_manager.remove(*groups_to_remove)
282
+
283
+ return {
284
+ "added": [group.name for group in groups_to_add],
285
+ "removed": [group.name for group in groups_to_remove],
286
+ }
204
287
 
205
288
 
206
289
  def _coerce_bool(value: Any) -> bool:
207
290
  if isinstance(value, bool):
208
291
  return value
209
292
 
293
+ if isinstance(value, int):
294
+ if value in {0, 1}:
295
+ return bool(value)
296
+
210
297
  if isinstance(value, str):
211
298
  normalized = value.strip().lower()
212
299
  if normalized in {"1", "true", "yes", "on"}:
@@ -242,18 +329,23 @@ def get_user_details(user: Any) -> dict[str, Any]:
242
329
 
243
330
  def list_users(*, exclude_groups: Any = None) -> list[dict[str, Any]]:
244
331
  User = get_user_model()
245
- excluded_group_names = set(_normalize_group_names(exclude_groups))
332
+ excluded_group_names = {"Superuser", *(_normalize_group_names(exclude_groups))}
246
333
  users = list(User.objects.all())
247
- users.sort(key=lambda user: getattr(user, "id", 0))
248
334
 
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
335
+ filtered_users = []
336
+ for user in users:
337
+ user_group_names = {group.name for group in user.groups.all()}
338
+ if user_group_names.intersection(excluded_group_names):
339
+ continue
340
+ filtered_users.append(user)
341
+
342
+ users = sorted(
343
+ filtered_users,
344
+ key=lambda user: (
345
+ not bool(getattr(user, "is_active", False)),
346
+ str(getattr(user, "username", "") or "").lower(),
347
+ ),
348
+ )
257
349
 
258
350
  return [get_user_details(user) for user in users]
259
351
 
@@ -296,7 +388,7 @@ def register_user_and_send_verification_email(
296
388
  first_name: str,
297
389
  last_name: str,
298
390
  password: str,
299
- group: Any = None,
391
+ username: Any = None,
300
392
  groups: Any = None,
301
393
  ) -> ServiceResult:
302
394
  """
@@ -310,6 +402,12 @@ def register_user_and_send_verification_email(
310
402
  enable_email_delivery = _is_enabled(
311
403
  _get_workflow_setting(_EMAIL_DELIVERY_ENABLED_SETTING, False)
312
404
  )
405
+ registration_username = _get_registration_username(email=email, username=username)
406
+
407
+ try:
408
+ validated_groups = _normalize_group_payload(groups)
409
+ except ValueError as exc:
410
+ return ServiceResult(success=False, message=str(exc))
313
411
 
314
412
  if User.objects.filter(email=email).exists():
315
413
  return ServiceResult(
@@ -325,7 +423,7 @@ def register_user_and_send_verification_email(
325
423
  with _registration_atomic_context():
326
424
  user = User.objects.create_user(
327
425
  email=email,
328
- username=email,
426
+ username=registration_username,
329
427
  first_name=first_name,
330
428
  last_name=last_name,
331
429
  password=password,
@@ -340,12 +438,7 @@ def register_user_and_send_verification_email(
340
438
  meta.verify_time = timezone.now()
341
439
  meta.save()
342
440
 
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)
441
+ _sync_user_groups(user=user, groups=validated_groups)
349
442
 
350
443
  message = (
351
444
  f"Hello {first_name} {last_name},<br><br>"
@@ -402,10 +495,119 @@ def register_user_and_send_verification_email(
402
495
  )
403
496
 
404
497
 
498
+ def admin_register_user_and_send_setup_email(
499
+ email: str,
500
+ first_name: str,
501
+ last_name: str,
502
+ username: Any = None,
503
+ groups: Any = None,
504
+ ) -> ServiceResult:
505
+ User = get_user_model()
506
+
507
+ company_name = _get_workflow_setting("COMPANY_NAME", "")
508
+ enable_email_delivery = _is_enabled(
509
+ _get_workflow_setting(_EMAIL_DELIVERY_ENABLED_SETTING, False)
510
+ )
511
+ registration_username = _get_registration_username(email=email, username=username)
512
+
513
+ try:
514
+ validated_groups = _normalize_group_payload(groups)
515
+ except ValueError as exc:
516
+ return ServiceResult(success=False, message=str(exc))
517
+
518
+ if User.objects.filter(email=email).exists():
519
+ return ServiceResult(
520
+ success=False,
521
+ message="A user with this email already exists.",
522
+ )
523
+
524
+ generated_password = generate_strong_password()
525
+ user = None
526
+ message = ""
527
+
528
+ try:
529
+ with _registration_atomic_context():
530
+ user = User.objects.create_user(
531
+ email=email,
532
+ username=registration_username,
533
+ first_name=first_name,
534
+ last_name=last_name,
535
+ password=generated_password,
536
+ is_active=True,
537
+ )
538
+ _sync_user_groups(user=user, groups=validated_groups)
539
+
540
+ message = (
541
+ f"Hello {first_name} {last_name},<br><br>"
542
+ 'You have been added as a registered user by an administrator. '
543
+ 'Use the "Forgot Password" flow to establish your password and login.<br><br>'
544
+ f"Thank you!<br><br>{company_name}"
545
+ )
546
+ except IntegrityError:
547
+ logger.exception("Admin registration failed due to IntegrityError for email=%s", email)
548
+ return ServiceResult(
549
+ success=False,
550
+ message="Email already exists, login or reset password.",
551
+ )
552
+ except Exception:
553
+ logger.exception("Unexpected error during admin registration for email=%s", email)
554
+ return ServiceResult(
555
+ success=False,
556
+ message="An unexpected error occurred during admin registration.",
557
+ )
558
+
559
+ if enable_email_delivery:
560
+ def _send_after_commit() -> None:
561
+ _send_admin_registration_email(
562
+ user=user,
563
+ html=message,
564
+ enabled=enable_email_delivery,
565
+ )
566
+
567
+ if _register_on_commit(_send_after_commit):
568
+ logger.info(
569
+ "Admin registration email send scheduled after transaction commit for %s",
570
+ user.email,
571
+ )
572
+ else:
573
+ _send_admin_registration_email(
574
+ user=user,
575
+ html=message,
576
+ enabled=enable_email_delivery,
577
+ )
578
+ else:
579
+ logger.info("Email delivery is disabled. Admin registration email not sent.")
580
+
581
+ return ServiceResult(
582
+ success=True,
583
+ message="User created successfully. The user can use the Forgot Password flow to establish a password and login.",
584
+ )
585
+
586
+
405
587
  def update_user(user: Any, data: dict[str, Any]) -> UserUpdateResult:
406
588
  processed_update = False
407
589
  user_fields_updated = False
408
590
 
591
+ validated_groups = None
592
+ if "groups" in data:
593
+ try:
594
+ validated_groups = _normalize_group_payload(data.get("groups"))
595
+ except ValueError as exc:
596
+ return UserUpdateResult(
597
+ success=False,
598
+ message=str(exc),
599
+ )
600
+
601
+ next_is_active = None
602
+ if "isActive" in data:
603
+ try:
604
+ next_is_active = _coerce_bool(data["isActive"])
605
+ except ValueError:
606
+ return UserUpdateResult(
607
+ success=False,
608
+ message="isActive must be a boolean value.",
609
+ )
610
+
409
611
  email_value = data.get("email")
410
612
  username_value = data.get("username")
411
613
  if email_value is not None or username_value is not None:
@@ -415,43 +617,36 @@ def update_user(user: Any, data: dict[str, Any]) -> UserUpdateResult:
415
617
  processed_update = True
416
618
  user_fields_updated = True
417
619
 
418
- if "first_name" in data:
419
- user.first_name = data["first_name"]
620
+ if "firstName" in data:
621
+ user.first_name = data["firstName"]
420
622
  processed_update = True
421
623
  user_fields_updated = True
422
- if "last_name" in data:
423
- user.last_name = data["last_name"]
624
+ if "lastName" in data:
625
+ user.last_name = data["lastName"]
424
626
  processed_update = True
425
627
  user_fields_updated = True
426
628
  if "password" in data:
427
629
  user.set_password(data["password"])
428
630
  processed_update = True
429
631
  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
- )
632
+ if "isActive" in data:
633
+ user.is_active = next_is_active
438
634
  processed_update = True
439
635
  user_fields_updated = True
440
636
 
441
- requested_groups = []
442
637
  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
638
  processed_update = True
450
- if requested_groups:
451
- _add_existing_groups_to_user(user=user, group_names=requested_groups)
639
+ _sync_user_groups(user=user, groups=validated_groups)
452
640
 
453
641
  if user_fields_updated:
454
- user.save()
642
+ try:
643
+ user.save()
644
+ except IntegrityError:
645
+ logger.exception("User update failed due to IntegrityError for email=%s", user.email)
646
+ return UserUpdateResult(
647
+ success=False,
648
+ message="A user with this email already exists.",
649
+ )
455
650
 
456
651
  if not processed_update:
457
652
  return UserUpdateResult(
@@ -1,6 +1,7 @@
1
1
  from django.urls import path
2
2
 
3
3
  from django_workflows.users.views import (
4
+ AdminUserCreateView,
4
5
  ChangePasswordView,
5
6
  ForgotPasswordView,
6
7
  UserCreateView,
@@ -12,6 +13,7 @@ from django_workflows.users.views import (
12
13
  urlpatterns = [
13
14
  path("user/", UserDetailView.as_view(), name="user-detail"),
14
15
  path("users/", UserListView.as_view(), name="user-list"),
16
+ path("admin-register/", AdminUserCreateView.as_view(), name="admin-register"),
15
17
  path("forgot-password/", ForgotPasswordView.as_view(), name="forgot-password"),
16
18
  path("verify-code/", VerifyCodeView.as_view(), name="verify-code"),
17
19
  path("change-password/", ChangePasswordView.as_view(), name="change-password"),
@@ -1,11 +1,14 @@
1
1
  import logging
2
2
 
3
+ from django.contrib.auth import get_user_model
4
+
3
5
  from rest_framework import status
4
- from rest_framework.permissions import IsAuthenticated
6
+ from rest_framework.permissions import BasePermission, IsAuthenticated
5
7
  from rest_framework.response import Response
6
8
  from rest_framework.views import APIView
7
9
 
8
10
  from django_workflows.users.services.auth_flow import (
11
+ admin_register_user_and_send_setup_email,
9
12
  change_password,
10
13
  get_user_details,
11
14
  list_users,
@@ -18,6 +21,64 @@ from django_workflows.users.services.auth_flow import (
18
21
  logger = logging.getLogger(__name__)
19
22
 
20
23
 
24
+ def _user_has_group(user, group_name):
25
+ if not user or not getattr(user, "is_authenticated", False):
26
+ return False
27
+
28
+ group_manager = getattr(user, "groups", None)
29
+ if group_manager is None:
30
+ return False
31
+
32
+ if hasattr(group_manager, "filter"):
33
+ filtered_groups = group_manager.filter(name=group_name)
34
+ if hasattr(filtered_groups, "exists"):
35
+ return filtered_groups.exists()
36
+
37
+ if hasattr(group_manager, "all"):
38
+ return any(getattr(group, "name", None) == group_name for group in group_manager.all())
39
+
40
+ return False
41
+
42
+
43
+ class HasUserManagerGroup(BasePermission):
44
+ message = "User Manager group membership is required."
45
+
46
+ def has_permission(self, request, view):
47
+ return _user_has_group(request.user, "User Manager")
48
+
49
+
50
+ def _resolve_patch_target_user(request):
51
+ target_user_id = request.data.get("id")
52
+ if target_user_id is None:
53
+ return request.user, None
54
+
55
+ try:
56
+ target_user_id = int(target_user_id)
57
+ except (TypeError, ValueError):
58
+ return None, Response(
59
+ {"detail": "id must be an integer value."},
60
+ status=status.HTTP_400_BAD_REQUEST,
61
+ )
62
+
63
+ if target_user_id == getattr(request.user, "id", None):
64
+ return request.user, None
65
+
66
+ if not _user_has_group(request.user, "User Manager"):
67
+ return None, Response(
68
+ {"detail": HasUserManagerGroup.message},
69
+ status=status.HTTP_403_FORBIDDEN,
70
+ )
71
+
72
+ User = get_user_model()
73
+ try:
74
+ return User.objects.get(id=target_user_id), None
75
+ except User.DoesNotExist:
76
+ return None, Response(
77
+ {"detail": "User not found."},
78
+ status=status.HTTP_404_NOT_FOUND,
79
+ )
80
+
81
+
21
82
  class AnonymousWorkflowView(APIView):
22
83
  authentication_classes = []
23
84
  permission_classes = []
@@ -52,7 +113,8 @@ class ForgotPasswordView(AnonymousWorkflowView):
52
113
 
53
114
 
54
115
  # Route: POST /register/
55
- # Body params: firstName, lastName, email, password, optional group, optional groups, optional company honeypot field
116
+ # Body params: firstName, lastName, email, password, optional username, optional company honeypot field, optional groups as [{name, enabled}]
117
+ # Notes: anonymous self-registration flow; user chooses password and receives a verification code by email
56
118
  class UserCreateView(AnonymousWorkflowView):
57
119
  def post(self, request):
58
120
  data = request.data
@@ -81,7 +143,54 @@ class UserCreateView(AnonymousWorkflowView):
81
143
  last_name=data.get("lastName"),
82
144
  email=data.get("email"),
83
145
  password=data.get("password"),
84
- group=data.get("group"),
146
+ username=data.get("username"),
147
+ groups=data.get("groups"),
148
+ )
149
+
150
+ if not result.success:
151
+ response_status = status.HTTP_409_CONFLICT
152
+ if result.message != "A user with this email already exists.":
153
+ response_status = status.HTTP_400_BAD_REQUEST
154
+
155
+ return Response({"detail": result.message}, status=response_status)
156
+
157
+ return Response({"detail": result.message}, status=status.HTTP_201_CREATED)
158
+
159
+
160
+ # Route: POST /admin-register/
161
+ # Access: authenticated users in the User Manager group
162
+ # Body params: firstName, lastName, email, optional username, optional company honeypot field, optional groups as [{name, enabled}]
163
+ # Notes: server generates a strong password, creates an active user, and sends an informational email without verification code logic
164
+ class AdminUserCreateView(APIView):
165
+ permission_classes = [IsAuthenticated, HasUserManagerGroup]
166
+
167
+ def post(self, request):
168
+ data = request.data
169
+
170
+ if data.get("company") is not None and str(data.get("company")).strip():
171
+ logger.warning(
172
+ "Honeypot triggered for admin registration: company=%s",
173
+ data.get("company"),
174
+ )
175
+ return Response(
176
+ {"detail": "Bot detected. Registration blocked."},
177
+ status=status.HTTP_400_BAD_REQUEST,
178
+ )
179
+
180
+ required_fields = ["firstName", "lastName", "email"]
181
+ missing_fields = [field for field in required_fields if field not in data]
182
+ if missing_fields:
183
+ logger.error("Admin registration missing fields: %s", missing_fields)
184
+ return Response(
185
+ {"detail": f"Missing required field(s): {', '.join(missing_fields)}"},
186
+ status=status.HTTP_400_BAD_REQUEST,
187
+ )
188
+
189
+ result = admin_register_user_and_send_setup_email(
190
+ first_name=data.get("firstName"),
191
+ last_name=data.get("lastName"),
192
+ email=data.get("email"),
193
+ username=data.get("username"),
85
194
  groups=data.get("groups"),
86
195
  )
87
196
 
@@ -155,7 +264,7 @@ class ChangePasswordView(AnonymousWorkflowView):
155
264
 
156
265
 
157
266
  # Route: GET /users/
158
- # Query params: optional exclude_group as comma-separated group names
267
+ # Query params: optional exclude_group as comma-separated group names; users in the Superuser group are always excluded
159
268
  class UserListView(APIView):
160
269
  permission_classes = [IsAuthenticated]
161
270
 
@@ -167,7 +276,8 @@ class UserListView(APIView):
167
276
 
168
277
  # Route: GET /user/
169
278
  # Route: PATCH /user/
170
- # PATCH body params: username, email, first_name, last_name, password, is_active, optional group, optional groups
279
+ # PATCH body params: optional id, username, email, firstName, lastName, password, isActive, optional groups as [{name, enabled}]
280
+ # Notes: when id is omitted the authenticated user is updated; updating another user by id requires the User Manager group
171
281
  class UserDetailView(APIView):
172
282
  permission_classes = [IsAuthenticated]
173
283
 
@@ -177,7 +287,11 @@ class UserDetailView(APIView):
177
287
  return Response(data, status=status.HTTP_200_OK)
178
288
 
179
289
  def patch(self, request):
180
- result = update_user(request.user, request.data)
290
+ target_user, error_response = _resolve_patch_target_user(request)
291
+ if error_response is not None:
292
+ return error_response
293
+
294
+ result = update_user(target_user, request.data)
181
295
 
182
296
  if result.success:
183
297
  return Response(
@@ -185,5 +299,5 @@ class UserDetailView(APIView):
185
299
  status=status.HTTP_200_OK,
186
300
  )
187
301
 
188
- logger.error("UserDetailView: %s for user %s", result.message, request.user.email)
302
+ logger.error("UserDetailView: %s for user %s", result.message, target_user.email)
189
303
  return Response({"detail": result.message}, status=status.HTTP_400_BAD_REQUEST)