BmorricalDjangoWorkflows 2.0.4__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.4 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/PKG-INFO +5 -1
  2. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/SOURCES.txt +4 -1
  3. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/PKG-INFO +5 -1
  4. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/README.md +4 -0
  5. bmorricaldjangoworkflows-2.1.0/VERSION +1 -0
  6. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/django_workflows/users/services/auth_flow.py +193 -1
  7. bmorricaldjangoworkflows-2.1.0/django_workflows/users/urls.py +19 -0
  8. bmorricaldjangoworkflows-2.1.0/django_workflows/users/views.py +189 -0
  9. bmorricaldjangoworkflows-2.1.0/tests/test_auth_flow.py +1259 -0
  10. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/tests/test_email.py +67 -0
  11. bmorricaldjangoworkflows-2.1.0/tests/test_user_views.py +358 -0
  12. bmorricaldjangoworkflows-2.0.4/VERSION +0 -1
  13. bmorricaldjangoworkflows-2.0.4/tests/test_auth_flow.py +0 -537
  14. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/dependency_links.txt +0 -0
  15. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/requires.txt +0 -0
  16. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/BmorricalDjangoWorkflows.egg-info/top_level.txt +0 -0
  17. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/LICENSE +0 -0
  18. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/MANIFEST.in +0 -0
  19. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/django_workflows/__init__.py +0 -0
  20. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/django_workflows/services/__init__.py +0 -0
  21. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/django_workflows/services/email.py +0 -0
  22. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/django_workflows/users/__init__.py +0 -0
  23. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/django_workflows/users/services/__init__.py +0 -0
  24. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/setup.cfg +0 -0
  25. {bmorricaldjangoworkflows-2.0.4 → bmorricaldjangoworkflows-2.1.0}/setup.py +0 -0
  26. {bmorricaldjangoworkflows-2.0.4 → 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.4
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
@@ -308,6 +308,10 @@ This is expected unless you set `BCC_RECIPIENTS`.
308
308
 
309
309
  ## Release
310
310
 
311
+ Update code and ./VERSION
312
+
313
+ Commit changes
314
+
311
315
  Create a release tag:
312
316
 
313
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.4
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
@@ -308,6 +308,10 @@ This is expected unless you set `BCC_RECIPIENTS`.
308
308
 
309
309
  ## Release
310
310
 
311
+ Update code and ./VERSION
312
+
313
+ Commit changes
314
+
311
315
  Create a release tag:
312
316
 
313
317
  ```bash
@@ -284,6 +284,10 @@ This is expected unless you set `BCC_RECIPIENTS`.
284
284
 
285
285
  ## Release
286
286
 
287
+ Update code and ./VERSION
288
+
289
+ Commit changes
290
+
287
291
  Create a release tag:
288
292
 
289
293
  ```bash
@@ -0,0 +1 @@
1
+ 2.1.0
@@ -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
+ ]
@@ -0,0 +1,189 @@
1
+ import logging
2
+
3
+ from rest_framework import status
4
+ from rest_framework.permissions import IsAuthenticated
5
+ from rest_framework.response import Response
6
+ from rest_framework.views import APIView
7
+
8
+ from django_workflows.users.services.auth_flow import (
9
+ change_password,
10
+ get_user_details,
11
+ list_users,
12
+ register_user_and_send_verification_email,
13
+ send_forgot_password_code,
14
+ update_user,
15
+ verify_reset_code,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class AnonymousWorkflowView(APIView):
22
+ authentication_classes = []
23
+ permission_classes = []
24
+
25
+
26
+ # Route: POST /forgot-password/
27
+ # Body params: email, optional company honeypot field
28
+ class ForgotPasswordView(AnonymousWorkflowView):
29
+ def post(self, request):
30
+ data = request.data
31
+
32
+ if data.get("company") is not None and str(data.get("company")).strip():
33
+ logger.warning(
34
+ "Honeypot triggered for password reset: company=%s",
35
+ data.get("company"),
36
+ )
37
+ return Response(
38
+ {"detail": "Password reset blocked."},
39
+ status=status.HTTP_400_BAD_REQUEST,
40
+ )
41
+
42
+ email = data.get("email")
43
+ if not email:
44
+ logger.error("ForgotPasswordView: Email is required.")
45
+ return Response(
46
+ {"detail": "Email is required."},
47
+ status=status.HTTP_400_BAD_REQUEST,
48
+ )
49
+
50
+ result = send_forgot_password_code(email)
51
+ return Response({"detail": result.message}, status=status.HTTP_200_OK)
52
+
53
+
54
+ # Route: POST /register/
55
+ # Body params: firstName, lastName, email, password, optional group, optional groups, optional company honeypot field
56
+ class UserCreateView(AnonymousWorkflowView):
57
+ def post(self, request):
58
+ data = request.data
59
+
60
+ if data.get("company") is not None and str(data.get("company")).strip():
61
+ logger.warning(
62
+ "Honeypot triggered for registration: company=%s",
63
+ data.get("company"),
64
+ )
65
+ return Response(
66
+ {"detail": "Bot detected. Registration blocked."},
67
+ status=status.HTTP_400_BAD_REQUEST,
68
+ )
69
+
70
+ required_fields = ["firstName", "lastName", "password", "email"]
71
+ missing_fields = [field for field in required_fields if field not in data]
72
+ if missing_fields:
73
+ logger.error("Registration missing fields: %s", missing_fields)
74
+ return Response(
75
+ {"detail": f"Missing required field(s): {', '.join(missing_fields)}"},
76
+ status=status.HTTP_400_BAD_REQUEST,
77
+ )
78
+
79
+ result = register_user_and_send_verification_email(
80
+ first_name=data.get("firstName"),
81
+ last_name=data.get("lastName"),
82
+ email=data.get("email"),
83
+ password=data.get("password"),
84
+ group=data.get("group"),
85
+ groups=data.get("groups"),
86
+ )
87
+
88
+ if not result.success:
89
+ response_status = status.HTTP_409_CONFLICT
90
+ if result.message != "A user with this email already exists.":
91
+ response_status = status.HTTP_400_BAD_REQUEST
92
+
93
+ return Response({"detail": result.message}, status=response_status)
94
+
95
+ return Response({"detail": result.message}, status=status.HTTP_201_CREATED)
96
+
97
+
98
+ # Route: POST /verify-code/
99
+ # Body params: email, code, optional company honeypot field
100
+ class VerifyCodeView(AnonymousWorkflowView):
101
+ def post(self, request):
102
+ data = request.data
103
+
104
+ if data.get("company") is not None and str(data.get("company")).strip():
105
+ logger.warning(
106
+ "Honeypot triggered for verification: company=%s",
107
+ data.get("company"),
108
+ )
109
+ return Response(
110
+ {"detail": "Verification blocked."},
111
+ status=status.HTTP_400_BAD_REQUEST,
112
+ )
113
+
114
+ email = data.get("email")
115
+ code = data.get("code")
116
+ if not email or not code:
117
+ return Response(
118
+ {"detail": "Email and code are required."},
119
+ status=status.HTTP_400_BAD_REQUEST,
120
+ )
121
+
122
+ result = verify_reset_code(email=email, code=code)
123
+ return Response(result.payload, status=result.status_code)
124
+
125
+
126
+ # Route: POST /change-password/
127
+ # Body params: email, password, passwordVerify, optional company honeypot field
128
+ class ChangePasswordView(AnonymousWorkflowView):
129
+ def post(self, request):
130
+ data = request.data
131
+
132
+ if data.get("company") is not None and str(data.get("company")).strip():
133
+ logger.warning(
134
+ "Honeypot triggered for change password: company=%s",
135
+ data.get("company"),
136
+ )
137
+ return Response(
138
+ {"detail": "Bot detected. Change password blocked."},
139
+ status=status.HTTP_400_BAD_REQUEST,
140
+ )
141
+
142
+ result = change_password(
143
+ email=data.get("email"),
144
+ password=data.get("password"),
145
+ password_verify=data.get("passwordVerify"),
146
+ )
147
+
148
+ if not result.success:
149
+ return Response(
150
+ {"detail": result.message},
151
+ status=status.HTTP_400_BAD_REQUEST,
152
+ )
153
+
154
+ return Response({"detail": result.message}, status=status.HTTP_200_OK)
155
+
156
+
157
+ # Route: GET /users/
158
+ # Query params: optional exclude_group as comma-separated group names
159
+ class UserListView(APIView):
160
+ permission_classes = [IsAuthenticated]
161
+
162
+ def get(self, request):
163
+ users = list_users(exclude_groups=request.query_params.get("exclude_group"))
164
+ logger.info("UserListView: Fetched %s users", len(users))
165
+ return Response(users, status=status.HTTP_200_OK)
166
+
167
+
168
+ # Route: GET /user/
169
+ # Route: PATCH /user/
170
+ # PATCH body params: username, email, first_name, last_name, password, is_active, optional group, optional groups
171
+ class UserDetailView(APIView):
172
+ permission_classes = [IsAuthenticated]
173
+
174
+ def get(self, request):
175
+ data = get_user_details(request.user)
176
+ logger.info("UserDetailView: Fetched details for user %s", request.user.email)
177
+ return Response(data, status=status.HTTP_200_OK)
178
+
179
+ def patch(self, request):
180
+ result = update_user(request.user, request.data)
181
+
182
+ if result.success:
183
+ return Response(
184
+ {"detail": result.message},
185
+ status=status.HTTP_200_OK,
186
+ )
187
+
188
+ logger.error("UserDetailView: %s for user %s", result.message, request.user.email)
189
+ return Response({"detail": result.message}, status=status.HTTP_400_BAD_REQUEST)