BmorricalDjangoWorkflows 2.1.1__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 (25) hide show
  1. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/BmorricalDjangoWorkflows.egg-info/PKG-INFO +1 -1
  2. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/PKG-INFO +1 -1
  3. bmorricaldjangoworkflows-2.1.2/VERSION +1 -0
  4. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/django_workflows/users/services/auth_flow.py +9 -2
  5. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/django_workflows/users/views.py +47 -8
  6. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/tests/test_auth_flow.py +24 -1
  7. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/tests/test_user_views.py +113 -9
  8. bmorricaldjangoworkflows-2.1.1/VERSION +0 -1
  9. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/BmorricalDjangoWorkflows.egg-info/SOURCES.txt +0 -0
  10. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/BmorricalDjangoWorkflows.egg-info/dependency_links.txt +0 -0
  11. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/BmorricalDjangoWorkflows.egg-info/requires.txt +0 -0
  12. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/BmorricalDjangoWorkflows.egg-info/top_level.txt +0 -0
  13. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/LICENSE +0 -0
  14. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/MANIFEST.in +0 -0
  15. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/README.md +0 -0
  16. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/django_workflows/__init__.py +0 -0
  17. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/django_workflows/services/__init__.py +0 -0
  18. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/django_workflows/services/email.py +0 -0
  19. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/django_workflows/users/__init__.py +0 -0
  20. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/django_workflows/users/services/__init__.py +0 -0
  21. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/django_workflows/users/urls.py +0 -0
  22. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/setup.cfg +0 -0
  23. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/setup.py +0 -0
  24. {bmorricaldjangoworkflows-2.1.1 → bmorricaldjangoworkflows-2.1.2}/tests/__init__.py +0 -0
  25. {bmorricaldjangoworkflows-2.1.1 → 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.1
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.1
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
@@ -342,8 +342,8 @@ def list_users(*, exclude_groups: Any = None) -> list[dict[str, Any]]:
342
342
  users = sorted(
343
343
  filtered_users,
344
344
  key=lambda user: (
345
- str(getattr(user, "username", "") or "").lower(),
346
345
  not bool(getattr(user, "is_active", False)),
346
+ str(getattr(user, "username", "") or "").lower(),
347
347
  ),
348
348
  )
349
349
 
@@ -639,7 +639,14 @@ def update_user(user: Any, data: dict[str, Any]) -> UserUpdateResult:
639
639
  _sync_user_groups(user=user, groups=validated_groups)
640
640
 
641
641
  if user_fields_updated:
642
- 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
+ )
643
650
 
644
651
  if not processed_update:
645
652
  return UserUpdateResult(
@@ -1,5 +1,7 @@
1
1
  import logging
2
2
 
3
+ from django.contrib.auth import get_user_model
4
+
3
5
  from rest_framework import status
4
6
  from rest_framework.permissions import BasePermission, IsAuthenticated
5
7
  from rest_framework.response import Response
@@ -38,11 +40,43 @@ def _user_has_group(user, group_name):
38
40
  return False
39
41
 
40
42
 
41
- class HasTimeEntryAdminGroup(BasePermission):
42
- message = "Time Entry Admin group membership is required."
43
+ class HasUserManagerGroup(BasePermission):
44
+ message = "User Manager group membership is required."
43
45
 
44
46
  def has_permission(self, request, view):
45
- return _user_has_group(request.user, "Time Entry Admin")
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
+ )
46
80
 
47
81
 
48
82
  class AnonymousWorkflowView(APIView):
@@ -124,11 +158,11 @@ class UserCreateView(AnonymousWorkflowView):
124
158
 
125
159
 
126
160
  # Route: POST /admin-register/
127
- # Access: authenticated users in the Time Entry Admin group
161
+ # Access: authenticated users in the User Manager group
128
162
  # Body params: firstName, lastName, email, optional username, optional company honeypot field, optional groups as [{name, enabled}]
129
163
  # Notes: server generates a strong password, creates an active user, and sends an informational email without verification code logic
130
164
  class AdminUserCreateView(APIView):
131
- permission_classes = [IsAuthenticated, HasTimeEntryAdminGroup]
165
+ permission_classes = [IsAuthenticated, HasUserManagerGroup]
132
166
 
133
167
  def post(self, request):
134
168
  data = request.data
@@ -242,7 +276,8 @@ class UserListView(APIView):
242
276
 
243
277
  # Route: GET /user/
244
278
  # Route: PATCH /user/
245
- # PATCH body params: username, email, firstName, lastName, password, isActive, optional groups as [{name, enabled}]
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
246
281
  class UserDetailView(APIView):
247
282
  permission_classes = [IsAuthenticated]
248
283
 
@@ -252,7 +287,11 @@ class UserDetailView(APIView):
252
287
  return Response(data, status=status.HTTP_200_OK)
253
288
 
254
289
  def patch(self, request):
255
- 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)
256
295
 
257
296
  if result.success:
258
297
  return Response(
@@ -260,5 +299,5 @@ class UserDetailView(APIView):
260
299
  status=status.HTTP_200_OK,
261
300
  )
262
301
 
263
- 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)
264
303
  return Response({"detail": result.message}, status=status.HTTP_400_BAD_REQUEST)
@@ -376,8 +376,8 @@ class AuthFlowTests(TestCase):
376
376
  [(item["username"], item["is_active"], item["email"]) for item in result],
377
377
  [
378
378
  ("alex@example.com", True, "alex-active@example.com"),
379
- ("alex@example.com", False, "alex-inactive@example.com"),
380
379
  ("bravo@example.com", True, "bravo@example.com"),
380
+ ("alex@example.com", False, "alex-inactive@example.com"),
381
381
  ],
382
382
  )
383
383
 
@@ -1607,6 +1607,29 @@ class AuthFlowTests(TestCase):
1607
1607
  self.assertEqual(result.message, "groups must be a list of objects with name and enabled.")
1608
1608
  user.save.assert_not_called()
1609
1609
 
1610
+ def test_update_user_returns_conflict_when_save_hits_integrity_error(self):
1611
+ user = SimpleNamespace(
1612
+ username="member@example.com",
1613
+ email="member@example.com",
1614
+ first_name="Member",
1615
+ last_name="User",
1616
+ is_active=True,
1617
+ groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
1618
+ save=MagicMock(side_effect=auth_flow.IntegrityError("duplicate")),
1619
+ set_password=MagicMock(),
1620
+ )
1621
+
1622
+ result = auth_flow.update_user(
1623
+ user,
1624
+ {
1625
+ "email": "taken@example.com",
1626
+ "username": "taken@example.com",
1627
+ },
1628
+ )
1629
+
1630
+ self.assertFalse(result.success)
1631
+ self.assertEqual(result.message, "A user with this email already exists.")
1632
+
1610
1633
  def test_update_user_returns_error_for_empty_payload(self):
1611
1634
  user = SimpleNamespace(
1612
1635
  username="member@example.com",
@@ -349,6 +349,110 @@ class PackageUserDetailViewTest(SimpleTestCase):
349
349
  },
350
350
  )
351
351
 
352
+ @patch("django_workflows.users.views.get_user_model")
353
+ @patch("django_workflows.users.views.update_user")
354
+ def test_user_detail_patch_uses_payload_id_for_time_entry_admin(self, mock_update_user, mock_get_user_model):
355
+ mock_update_user.return_value = SimpleNamespace(
356
+ success=True,
357
+ message="User updated successfully.",
358
+ )
359
+
360
+ actor = self._make_user()
361
+ actor.id = 1
362
+ actor.groups = SimpleNamespace(all=lambda: [SimpleNamespace(name="User Manager")])
363
+ target_user = self._make_user()
364
+ target_user.id = 9
365
+ target_user.email = "target@example.com"
366
+ target_user.username = "target@example.com"
367
+
368
+ user_model = SimpleNamespace(objects=SimpleNamespace(get=lambda **kwargs: target_user), DoesNotExist=Exception)
369
+ mock_get_user_model.return_value = user_model
370
+
371
+ request = SimpleNamespace(
372
+ user=actor,
373
+ data={
374
+ "id": 9,
375
+ "email": "target@example.com",
376
+ "username": "target@example.com",
377
+ "isActive": 1,
378
+ "groups": [{"name": "Time Entry User", "enabled": True}],
379
+ },
380
+ )
381
+ response = workflow_views.UserDetailView().patch(request)
382
+
383
+ self.assertEqual(response.status_code, 200)
384
+ mock_update_user.assert_called_once_with(target_user, request.data)
385
+
386
+ @patch("django_workflows.users.views.get_user_model")
387
+ @patch("django_workflows.users.views.update_user")
388
+ def test_user_detail_patch_uses_authenticated_user_when_payload_id_matches_self(
389
+ self,
390
+ mock_update_user,
391
+ mock_get_user_model,
392
+ ):
393
+ mock_update_user.return_value = SimpleNamespace(
394
+ success=True,
395
+ message="User updated successfully.",
396
+ )
397
+
398
+ actor = self._make_user()
399
+ actor.id = 9
400
+ request = SimpleNamespace(
401
+ user=actor,
402
+ data={
403
+ "id": 9,
404
+ "email": "member@example.com",
405
+ "username": "member@example.com",
406
+ "firstName": "Updated",
407
+ },
408
+ )
409
+
410
+ response = workflow_views.UserDetailView().patch(request)
411
+
412
+ self.assertEqual(response.status_code, 200)
413
+ mock_update_user.assert_called_once_with(actor, request.data)
414
+ mock_get_user_model.assert_not_called()
415
+
416
+ def test_user_detail_patch_returns_400_for_invalid_id(self):
417
+ request = SimpleNamespace(user=self._make_user(), data={"id": "not-an-int"})
418
+
419
+ response = workflow_views.UserDetailView().patch(request)
420
+
421
+ self.assertEqual(response.status_code, 400)
422
+ self.assertEqual(response.data["detail"], "id must be an integer value.")
423
+
424
+ def test_user_detail_patch_returns_403_for_foreign_id_without_time_entry_admin(self):
425
+ actor = self._make_user()
426
+ actor.id = 1
427
+ actor.groups = SimpleNamespace(all=lambda: [SimpleNamespace(name="Time Entry User")])
428
+ request = SimpleNamespace(user=actor, data={"id": 9, "firstName": "Updated"})
429
+
430
+ response = workflow_views.UserDetailView().patch(request)
431
+
432
+ self.assertEqual(response.status_code, 403)
433
+ self.assertEqual(response.data["detail"], workflow_views.HasUserManagerGroup.message)
434
+
435
+ @patch("django_workflows.users.views.get_user_model")
436
+ def test_user_detail_patch_returns_404_when_target_id_is_missing(self, mock_get_user_model):
437
+ class DoesNotExist(Exception):
438
+ pass
439
+
440
+ actor = self._make_user()
441
+ actor.id = 1
442
+ actor.groups = SimpleNamespace(all=lambda: [SimpleNamespace(name="User Manager")])
443
+
444
+ user_model = SimpleNamespace(
445
+ objects=SimpleNamespace(get=lambda **kwargs: (_ for _ in ()).throw(DoesNotExist())),
446
+ DoesNotExist=DoesNotExist,
447
+ )
448
+ mock_get_user_model.return_value = user_model
449
+
450
+ request = SimpleNamespace(user=actor, data={"id": 99, "firstName": "Updated"})
451
+ response = workflow_views.UserDetailView().patch(request)
452
+
453
+ self.assertEqual(response.status_code, 404)
454
+ self.assertEqual(response.data["detail"], "User not found.")
455
+
352
456
  @patch("django_workflows.users.views.update_user")
353
457
  def test_user_detail_patch_validation_error_returns_400(self, mock_update_user):
354
458
  mock_update_user.return_value = SimpleNamespace(
@@ -424,7 +528,7 @@ class PackageAdminUserWorkflowViewsTest(SimpleTestCase):
424
528
  },
425
529
  format="json",
426
530
  )
427
- force_authenticate(request, user=self._make_authenticated_user(["Time Entry Admin"]))
531
+ force_authenticate(request, user=self._make_authenticated_user(["User Manager"]))
428
532
 
429
533
  response = workflow_views.AdminUserCreateView.as_view()(request)
430
534
 
@@ -467,7 +571,7 @@ class PackageAdminUserWorkflowViewsTest(SimpleTestCase):
467
571
  },
468
572
  format="json",
469
573
  )
470
- force_authenticate(request, user=self._make_authenticated_user(["Time Entry Admin"]))
574
+ force_authenticate(request, user=self._make_authenticated_user(["User Manager"]))
471
575
 
472
576
  response = workflow_views.AdminUserCreateView.as_view()(request)
473
577
 
@@ -483,7 +587,7 @@ class PackageAdminUserWorkflowViewsTest(SimpleTestCase):
483
587
  },
484
588
  format="json",
485
589
  )
486
- force_authenticate(request, user=self._make_authenticated_user(["Time Entry Admin"]))
590
+ force_authenticate(request, user=self._make_authenticated_user(["User Manager"]))
487
591
 
488
592
  response = workflow_views.AdminUserCreateView.as_view()(request)
489
593
 
@@ -507,7 +611,7 @@ class PackageAdminUserWorkflowViewsTest(SimpleTestCase):
507
611
  },
508
612
  format="json",
509
613
  )
510
- force_authenticate(request, user=self._make_authenticated_user(["Time Entry Admin"]))
614
+ force_authenticate(request, user=self._make_authenticated_user(["User Manager"]))
511
615
 
512
616
  response = workflow_views.AdminUserCreateView.as_view()(request)
513
617
 
@@ -530,7 +634,7 @@ class PackageAdminUserWorkflowViewsTest(SimpleTestCase):
530
634
  },
531
635
  format="json",
532
636
  )
533
- force_authenticate(request, user=self._make_authenticated_user(["Time Entry Admin"]))
637
+ force_authenticate(request, user=self._make_authenticated_user(["User Manager"]))
534
638
 
535
639
  response = workflow_views.AdminUserCreateView.as_view()(request)
536
640
 
@@ -541,8 +645,8 @@ class PackageAdminUserWorkflowViewsTest(SimpleTestCase):
541
645
  unauthenticated_user = SimpleNamespace(is_authenticated=False)
542
646
  missing_group_manager_user = SimpleNamespace(is_authenticated=True, groups=None)
543
647
 
544
- self.assertFalse(workflow_views._user_has_group(unauthenticated_user, "Time Entry Admin"))
545
- self.assertFalse(workflow_views._user_has_group(missing_group_manager_user, "Time Entry Admin"))
648
+ self.assertFalse(workflow_views._user_has_group(unauthenticated_user, "User Manager"))
649
+ self.assertFalse(workflow_views._user_has_group(missing_group_manager_user, "User Manager"))
546
650
 
547
651
  def test_user_has_group_uses_filter_exists_when_available(self):
548
652
  filtered_groups = SimpleNamespace(exists=lambda: True)
@@ -551,7 +655,7 @@ class PackageAdminUserWorkflowViewsTest(SimpleTestCase):
551
655
  groups=SimpleNamespace(filter=lambda **kwargs: filtered_groups),
552
656
  )
553
657
 
554
- self.assertTrue(workflow_views._user_has_group(user, "Time Entry Admin"))
658
+ self.assertTrue(workflow_views._user_has_group(user, "User Manager"))
555
659
 
556
660
  def test_user_has_group_returns_false_without_filter_or_all(self):
557
661
  user = SimpleNamespace(
@@ -559,4 +663,4 @@ class PackageAdminUserWorkflowViewsTest(SimpleTestCase):
559
663
  groups=SimpleNamespace(),
560
664
  )
561
665
 
562
- self.assertFalse(workflow_views._user_has_group(user, "Time Entry Admin"))
666
+ self.assertFalse(workflow_views._user_has_group(user, "User Manager"))
@@ -1 +0,0 @@
1
- 2.1.1