BmorricalDjangoWorkflows 2.1.2__tar.gz → 2.1.4__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.2 → bmorricaldjangoworkflows-2.1.4}/BmorricalDjangoWorkflows.egg-info/PKG-INFO +15 -1
  2. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/PKG-INFO +15 -1
  3. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/README.md +14 -0
  4. bmorricaldjangoworkflows-2.1.4/VERSION +1 -0
  5. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/django_workflows/users/services/auth_flow.py +36 -10
  6. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/django_workflows/users/views.py +2 -2
  7. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/tests/test_auth_flow.py +56 -2
  8. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/tests/test_user_views.py +0 -2
  9. bmorricaldjangoworkflows-2.1.2/VERSION +0 -1
  10. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/BmorricalDjangoWorkflows.egg-info/SOURCES.txt +0 -0
  11. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/BmorricalDjangoWorkflows.egg-info/dependency_links.txt +0 -0
  12. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/BmorricalDjangoWorkflows.egg-info/requires.txt +0 -0
  13. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/BmorricalDjangoWorkflows.egg-info/top_level.txt +0 -0
  14. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/LICENSE +0 -0
  15. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/MANIFEST.in +0 -0
  16. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/django_workflows/__init__.py +0 -0
  17. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/django_workflows/services/__init__.py +0 -0
  18. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/django_workflows/services/email.py +0 -0
  19. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/django_workflows/users/__init__.py +0 -0
  20. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/django_workflows/users/services/__init__.py +0 -0
  21. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/django_workflows/users/urls.py +0 -0
  22. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/setup.cfg +0 -0
  23. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/setup.py +0 -0
  24. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/tests/__init__.py +0 -0
  25. {bmorricaldjangoworkflows-2.1.2 → bmorricaldjangoworkflows-2.1.4}/tests/test_email.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: BmorricalDjangoWorkflows
3
- Version: 2.1.2
3
+ Version: 2.1.4
4
4
  Summary: Reusable Django Workflows
5
5
  Home-page: https://github.com/Bmorrical/django-workflows
6
6
  Author: Bradley Morrical
@@ -78,6 +78,7 @@ WORKFLOWS = {
78
78
  # Optional settings with defaults shown.
79
79
  "COMPANY_NAME": "My Company",
80
80
  "ENABLE_EMAIL_DELIVERY": False,
81
+ "FORGOT_PASSWORD_URL": "https://app.example.com/forgot-password",
81
82
  "RESET_CODE_TTL_MINUTES": 10,
82
83
  "MAX_VERIFY_ATTEMPTS": 3,
83
84
  }
@@ -86,8 +87,21 @@ WORKFLOWS = {
86
87
  Notes:
87
88
 
88
89
  - `USER_META_MODEL` must be a dotted import path such as `myapp.models.UserMeta`.
90
+ - `FORGOT_PASSWORD_URL` is used in the admin-created account email to link users into the host app's forgot-password flow.
91
+ - Host apps will usually source `FORGOT_PASSWORD_URL` from an environment variable in their own settings module.
89
92
  - If `ENABLE_EMAIL_DELIVERY` is true, configure the email transport environment variables used by the mail service.
90
93
 
94
+ Example host-project wiring:
95
+
96
+ ```python
97
+ import os
98
+
99
+ WORKFLOWS = {
100
+ "USER_META_MODEL": "users.models_user_meta.UserMeta",
101
+ "FORGOT_PASSWORD_URL": os.getenv("FORGOT_PASSWORD_URL", ""),
102
+ }
103
+ ```
104
+
91
105
  ## Email Delivery Environment Variables
92
106
 
93
107
  When email delivery is enabled, set:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: BmorricalDjangoWorkflows
3
- Version: 2.1.2
3
+ Version: 2.1.4
4
4
  Summary: Reusable Django Workflows
5
5
  Home-page: https://github.com/Bmorrical/django-workflows
6
6
  Author: Bradley Morrical
@@ -78,6 +78,7 @@ WORKFLOWS = {
78
78
  # Optional settings with defaults shown.
79
79
  "COMPANY_NAME": "My Company",
80
80
  "ENABLE_EMAIL_DELIVERY": False,
81
+ "FORGOT_PASSWORD_URL": "https://app.example.com/forgot-password",
81
82
  "RESET_CODE_TTL_MINUTES": 10,
82
83
  "MAX_VERIFY_ATTEMPTS": 3,
83
84
  }
@@ -86,8 +87,21 @@ WORKFLOWS = {
86
87
  Notes:
87
88
 
88
89
  - `USER_META_MODEL` must be a dotted import path such as `myapp.models.UserMeta`.
90
+ - `FORGOT_PASSWORD_URL` is used in the admin-created account email to link users into the host app's forgot-password flow.
91
+ - Host apps will usually source `FORGOT_PASSWORD_URL` from an environment variable in their own settings module.
89
92
  - If `ENABLE_EMAIL_DELIVERY` is true, configure the email transport environment variables used by the mail service.
90
93
 
94
+ Example host-project wiring:
95
+
96
+ ```python
97
+ import os
98
+
99
+ WORKFLOWS = {
100
+ "USER_META_MODEL": "users.models_user_meta.UserMeta",
101
+ "FORGOT_PASSWORD_URL": os.getenv("FORGOT_PASSWORD_URL", ""),
102
+ }
103
+ ```
104
+
91
105
  ## Email Delivery Environment Variables
92
106
 
93
107
  When email delivery is enabled, set:
@@ -54,6 +54,7 @@ WORKFLOWS = {
54
54
  # Optional settings with defaults shown.
55
55
  "COMPANY_NAME": "My Company",
56
56
  "ENABLE_EMAIL_DELIVERY": False,
57
+ "FORGOT_PASSWORD_URL": "https://app.example.com/forgot-password",
57
58
  "RESET_CODE_TTL_MINUTES": 10,
58
59
  "MAX_VERIFY_ATTEMPTS": 3,
59
60
  }
@@ -62,8 +63,21 @@ WORKFLOWS = {
62
63
  Notes:
63
64
 
64
65
  - `USER_META_MODEL` must be a dotted import path such as `myapp.models.UserMeta`.
66
+ - `FORGOT_PASSWORD_URL` is used in the admin-created account email to link users into the host app's forgot-password flow.
67
+ - Host apps will usually source `FORGOT_PASSWORD_URL` from an environment variable in their own settings module.
65
68
  - If `ENABLE_EMAIL_DELIVERY` is true, configure the email transport environment variables used by the mail service.
66
69
 
70
+ Example host-project wiring:
71
+
72
+ ```python
73
+ import os
74
+
75
+ WORKFLOWS = {
76
+ "USER_META_MODEL": "users.models_user_meta.UserMeta",
77
+ "FORGOT_PASSWORD_URL": os.getenv("FORGOT_PASSWORD_URL", ""),
78
+ }
79
+ ```
80
+
67
81
  ## Email Delivery Environment Variables
68
82
 
69
83
  When email delivery is enabled, set:
@@ -0,0 +1 @@
1
+ 2.1.4
@@ -26,6 +26,7 @@ _ALLOWED_CODE_CHARS = [
26
26
  ]
27
27
  _LOG_DEV_CODES_SETTING = "LOG_VERIFICATION_CODES_IN_DEBUG"
28
28
  _EMAIL_DELIVERY_ENABLED_SETTING = "ENABLE_EMAIL_DELIVERY"
29
+ _FORGOT_PASSWORD_URL_SETTING = "FORGOT_PASSWORD_URL"
29
30
 
30
31
 
31
32
  def generate_verification_code(*, length: int = 6) -> str:
@@ -314,6 +315,32 @@ def _get_force_password_reset(user: Any) -> bool:
314
315
  return False
315
316
 
316
317
 
318
+ def _build_admin_setup_email_message(
319
+ *,
320
+ first_name: str,
321
+ last_name: str,
322
+ company_name: str,
323
+ forgot_password_url: Any,
324
+ ) -> str:
325
+ forgot_password_instructions = (
326
+ 'Use the "Forgot Password" flow to establish your password and login.<br><br>'
327
+ )
328
+ normalized_forgot_password_url = str(forgot_password_url or "").strip()
329
+
330
+ if normalized_forgot_password_url:
331
+ forgot_password_instructions = (
332
+ f'Click the link <a href="{normalized_forgot_password_url}">{normalized_forgot_password_url}</a> to use the '
333
+ '"Forgot Password" flow to establish your password and login.<br><br>'
334
+ )
335
+
336
+ return (
337
+ f"Hello {first_name} {last_name},<br><br>"
338
+ 'You have been added as a registered user by an administrator. '
339
+ f"{forgot_password_instructions}"
340
+ f"Thank you!<br><br>{company_name}"
341
+ )
342
+
343
+
317
344
  def get_user_details(user: Any) -> dict[str, Any]:
318
345
  return {
319
346
  "id": user.id,
@@ -505,6 +532,7 @@ def admin_register_user_and_send_setup_email(
505
532
  User = get_user_model()
506
533
 
507
534
  company_name = _get_workflow_setting("COMPANY_NAME", "")
535
+ forgot_password_url = _get_workflow_setting(_FORGOT_PASSWORD_URL_SETTING, "")
508
536
  enable_email_delivery = _is_enabled(
509
537
  _get_workflow_setting(_EMAIL_DELIVERY_ENABLED_SETTING, False)
510
538
  )
@@ -537,11 +565,11 @@ def admin_register_user_and_send_setup_email(
537
565
  )
538
566
  _sync_user_groups(user=user, groups=validated_groups)
539
567
 
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}"
568
+ message = _build_admin_setup_email_message(
569
+ first_name=first_name,
570
+ last_name=last_name,
571
+ company_name=company_name,
572
+ forgot_password_url=forgot_password_url,
545
573
  )
546
574
  except IntegrityError:
547
575
  logger.exception("Admin registration failed due to IntegrityError for email=%s", email)
@@ -609,11 +637,9 @@ def update_user(user: Any, data: dict[str, Any]) -> UserUpdateResult:
609
637
  )
610
638
 
611
639
  email_value = data.get("email")
612
- username_value = data.get("username")
613
- if email_value is not None or username_value is not None:
614
- next_email = email_value if email_value is not None else username_value
615
- user.username = next_email
616
- user.email = next_email
640
+ if email_value is not None:
641
+ user.username = email_value
642
+ user.email = email_value
617
643
  processed_update = True
618
644
  user_fields_updated = True
619
645
 
@@ -276,8 +276,8 @@ class UserListView(APIView):
276
276
 
277
277
  # Route: GET /user/
278
278
  # Route: PATCH /user/
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
279
+ # PATCH body params: optional id, 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; username stays aligned to email
281
281
  class UserDetailView(APIView):
282
282
  permission_classes = [IsAuthenticated]
283
283
 
@@ -47,6 +47,7 @@ class AuthFlowTests(TestCase):
47
47
  defaults = {
48
48
  "COMPANY_NAME": "Acme Co",
49
49
  "ENABLE_EMAIL_DELIVERY": False,
50
+ "FORGOT_PASSWORD_URL": "",
50
51
  "RESET_CODE_TTL_MINUTES": 10,
51
52
  "MAX_VERIFY_ATTEMPTS": 3,
52
53
  }
@@ -75,6 +76,35 @@ class AuthFlowTests(TestCase):
75
76
  self.assertIsInstance(password, str)
76
77
  self.assertTrue(password)
77
78
 
79
+ def test_build_admin_setup_email_message_includes_forgot_password_link_when_configured(self):
80
+ message = auth_flow._build_admin_setup_email_message(
81
+ first_name="New",
82
+ last_name="User",
83
+ company_name="Reusable Inc",
84
+ forgot_password_url="https://app.example.com/forgot-password",
85
+ )
86
+
87
+ self.assertIn(
88
+ 'Click the link <a href="https://app.example.com/forgot-password">https://app.example.com/forgot-password</a> to use the '
89
+ '"Forgot Password" flow to establish your password and login.<br><br>',
90
+ message,
91
+ )
92
+ self.assertIn("Reusable Inc", message)
93
+
94
+ def test_build_admin_setup_email_message_falls_back_when_url_missing(self):
95
+ message = auth_flow._build_admin_setup_email_message(
96
+ first_name="New",
97
+ last_name="User",
98
+ company_name="Reusable Inc",
99
+ forgot_password_url=" ",
100
+ )
101
+
102
+ self.assertIn(
103
+ 'Use the "Forgot Password" flow to establish your password and login.<br><br>',
104
+ message,
105
+ )
106
+ self.assertNotIn("href=", message)
107
+
78
108
  @patch.object(auth_flow.logger, "isEnabledFor", return_value=True)
79
109
  @patch.object(auth_flow.logger, "debug")
80
110
  @patch.object(auth_flow, "_get_workflow_setting", return_value=True)
@@ -757,6 +787,7 @@ class AuthFlowTests(TestCase):
757
787
  mock_get_setting.side_effect = self._settings_getter(
758
788
  ENABLE_EMAIL_DELIVERY=True,
759
789
  COMPANY_NAME="Reusable Inc",
790
+ FORGOT_PASSWORD_URL="https://app.example.com/forgot-password",
760
791
  )
761
792
  mock_send_email.return_value = SimpleNamespace(status_code=200, text="ok")
762
793
 
@@ -807,7 +838,7 @@ class AuthFlowTests(TestCase):
807
838
  self.assertEqual(kwargs["to"], ["new@example.com"])
808
839
  self.assertEqual(kwargs["subject"], "Your account has been created")
809
840
  self.assertIn(
810
- 'You have been added as a registered user by an administrator. Use the "Forgot Password" flow to establish your password and login.',
841
+ 'You have been added as a registered user by an administrator. Click the link <a href="https://app.example.com/forgot-password">https://app.example.com/forgot-password</a> to use the "Forgot Password" flow to establish your password and login.',
811
842
  kwargs["html"],
812
843
  )
813
844
  self.assertIn("Reusable Inc", kwargs["html"])
@@ -1526,6 +1557,10 @@ class AuthFlowTests(TestCase):
1526
1557
  self.assertTrue(result.success)
1527
1558
  self.assertEqual(user.first_name, "Updated")
1528
1559
  self.assertEqual(user.last_name, "Person")
1560
+ self.assertEqual(user.username, "member@example.com")
1561
+ self.assertEqual(user.email, "member@example.com")
1562
+ user.set_password.assert_not_called()
1563
+ user.save.assert_called_once()
1529
1564
  mock_sync_user_groups.assert_not_called()
1530
1565
 
1531
1566
  @patch.object(auth_flow, "_get_group_model")
@@ -1607,6 +1642,26 @@ class AuthFlowTests(TestCase):
1607
1642
  self.assertEqual(result.message, "groups must be a list of objects with name and enabled.")
1608
1643
  user.save.assert_not_called()
1609
1644
 
1645
+ def test_update_user_ignores_username_when_email_is_omitted(self):
1646
+ user = SimpleNamespace(
1647
+ username="member@example.com",
1648
+ email="member@example.com",
1649
+ first_name="Member",
1650
+ last_name="User",
1651
+ is_active=True,
1652
+ groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
1653
+ save=MagicMock(),
1654
+ set_password=MagicMock(),
1655
+ )
1656
+
1657
+ result = auth_flow.update_user(user, {"username": "updated@example.com"})
1658
+
1659
+ self.assertFalse(result.success)
1660
+ self.assertEqual(result.message, "No valid fields provided.")
1661
+ self.assertEqual(user.username, "member@example.com")
1662
+ self.assertEqual(user.email, "member@example.com")
1663
+ user.save.assert_not_called()
1664
+
1610
1665
  def test_update_user_returns_conflict_when_save_hits_integrity_error(self):
1611
1666
  user = SimpleNamespace(
1612
1667
  username="member@example.com",
@@ -1623,7 +1678,6 @@ class AuthFlowTests(TestCase):
1623
1678
  user,
1624
1679
  {
1625
1680
  "email": "taken@example.com",
1626
- "username": "taken@example.com",
1627
1681
  },
1628
1682
  )
1629
1683
 
@@ -373,7 +373,6 @@ class PackageUserDetailViewTest(SimpleTestCase):
373
373
  data={
374
374
  "id": 9,
375
375
  "email": "target@example.com",
376
- "username": "target@example.com",
377
376
  "isActive": 1,
378
377
  "groups": [{"name": "Time Entry User", "enabled": True}],
379
378
  },
@@ -402,7 +401,6 @@ class PackageUserDetailViewTest(SimpleTestCase):
402
401
  data={
403
402
  "id": 9,
404
403
  "email": "member@example.com",
405
- "username": "member@example.com",
406
404
  "firstName": "Updated",
407
405
  },
408
406
  )
@@ -1 +0,0 @@
1
- 2.1.2