django-adminflow 1.0.0__py3-none-any.whl

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 (47) hide show
  1. adminflow/__init__.py +1 -0
  2. adminflow/apps.py +22 -0
  3. adminflow/context_processors.py +72 -0
  4. adminflow/forms.py +89 -0
  5. adminflow/import_export.py +224 -0
  6. adminflow/static/adminflow/img/logo.png +0 -0
  7. adminflow/templates/admin/actions.html +79 -0
  8. adminflow/templates/admin/base.html +1263 -0
  9. adminflow/templates/admin/base_site.html +5 -0
  10. adminflow/templates/admin/change_form.html +315 -0
  11. adminflow/templates/admin/change_list.html +216 -0
  12. adminflow/templates/admin/change_list_results.html +211 -0
  13. adminflow/templates/admin/delete_confirmation.html +55 -0
  14. adminflow/templates/admin/delete_selected_confirmation.html +118 -0
  15. adminflow/templates/admin/edit_inline/stacked.html +85 -0
  16. adminflow/templates/admin/edit_inline/tabular.html +81 -0
  17. adminflow/templates/admin/filter.html +17 -0
  18. adminflow/templates/admin/import_export/change_list_export_item.html +8 -0
  19. adminflow/templates/admin/import_export/change_list_import_item.html +8 -0
  20. adminflow/templates/admin/import_export/export.html +199 -0
  21. adminflow/templates/admin/includes/fieldset.html +73 -0
  22. adminflow/templates/admin/includes/toast.html +102 -0
  23. adminflow/templates/admin/index.html +227 -0
  24. adminflow/templates/admin/logged_out.html +38 -0
  25. adminflow/templates/admin/login.html +144 -0
  26. adminflow/templates/admin/object_history.html +60 -0
  27. adminflow/templates/admin/pagination.html +30 -0
  28. adminflow/templates/admin/search_form.html +28 -0
  29. adminflow/templates/admin/submit_line.html +48 -0
  30. adminflow/templates/admin/verify_2fa.html +280 -0
  31. adminflow/templates/otp/email/token.html +64 -0
  32. adminflow/templates/otp/email/token.txt +11 -0
  33. adminflow/templates/registration/logged_out.html +38 -0
  34. adminflow/templates/registration/password_change_form.html +272 -0
  35. adminflow/templates/simple_history/object_history.html +186 -0
  36. adminflow/templates/simple_history/object_history_form.html +103 -0
  37. adminflow/templates/simple_history/submit_line.html +17 -0
  38. adminflow/templatetags/__init__.py +1 -0
  39. adminflow/templatetags/__pycache__/__init__.cpython-314.pyc +0 -0
  40. adminflow/templatetags/__pycache__/adminflow_tags.cpython-314.pyc +0 -0
  41. adminflow/templatetags/adminflow_tags.py +233 -0
  42. adminflow/user_admin.py +662 -0
  43. adminflow/views.py +207 -0
  44. django_adminflow-1.0.0.dist-info/METADATA +398 -0
  45. django_adminflow-1.0.0.dist-info/RECORD +47 -0
  46. django_adminflow-1.0.0.dist-info/WHEEL +5 -0
  47. django_adminflow-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,662 @@
1
+ """
2
+ adminflow.user_admin
3
+ ====================
4
+ Plug-and-play 2FA admin helpers for Django admin.
5
+
6
+ Usage in your admin.py::
7
+
8
+ from adminflow.user_admin import AdminFlowUserAdminMixin, get_2fa_inlines
9
+ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
10
+
11
+ @admin.register(User)
12
+ class UserAdmin(AdminFlowUserAdminMixin, BaseUserAdmin):
13
+ inlines = (YourCustomInline,) + get_2fa_inlines()
14
+ """
15
+
16
+ import base64
17
+ import io
18
+ import json
19
+ import os
20
+
21
+ from django import forms
22
+ from django.conf import settings
23
+ from django.contrib import admin
24
+ from django.contrib.auth import get_user_model
25
+ from django.core.exceptions import ValidationError
26
+ from django.utils.html import format_html
27
+ from django.utils.safestring import mark_safe
28
+
29
+ User = get_user_model()
30
+
31
+ HAS_OTP_TOTP = "django_otp.plugins.otp_totp" in settings.INSTALLED_APPS
32
+ HAS_OTP_EMAIL = "django_otp.plugins.otp_email" in settings.INSTALLED_APPS
33
+ HAS_OTP_STATIC = "django_otp.plugins.otp_static" in settings.INSTALLED_APPS
34
+
35
+ # ─────────────────────────────────────────────
36
+ # TOTP (Authenticator App) Inline
37
+ # ─────────────────────────────────────────────
38
+ if HAS_OTP_TOTP:
39
+ from django_otp.plugins.otp_totp.models import TOTPDevice, default_key
40
+ import qrcode
41
+
42
+ AUTHENTICATOR_APP_CHOICES = [
43
+ ("", "— Select Authenticator App —"),
44
+ ("Google Authenticator", "Google Authenticator"),
45
+ ("1Password", "1Password"),
46
+ ("Authy", "Authy"),
47
+ ("Microsoft Authenticator", "Microsoft Authenticator"),
48
+ ("Bitwarden", "Bitwarden"),
49
+ ("Duo Mobile", "Duo Mobile"),
50
+ ("Aegis Authenticator", "Aegis Authenticator"),
51
+ ("Raivo OTP", "Raivo OTP"),
52
+ ("Keychain (iOS/macOS)", "Keychain (iOS/macOS)"),
53
+ ("Other", "Other"),
54
+ ]
55
+
56
+ class TOTPDeviceForm(forms.ModelForm):
57
+ key = forms.CharField(widget=forms.HiddenInput(), required=False)
58
+ name = forms.ChoiceField(
59
+ choices=AUTHENTICATOR_APP_CHOICES,
60
+ label="Authenticator App",
61
+ help_text="Select which authenticator app you are using. Each app must be unique per account.",
62
+ widget=forms.Select(attrs={"style": "width:100%; max-width:320px;"}),
63
+ )
64
+ verification_code = forms.CharField(
65
+ required=False, max_length=6, min_length=6,
66
+ label="6-Digit Verification Code (from Mobile App)",
67
+ widget=forms.TextInput(attrs={
68
+ "placeholder": "123456",
69
+ "style": "letter-spacing:2px;font-family:monospace;font-weight:bold;width:180px;text-align:center;font-size:14px;",
70
+ "autocomplete": "off",
71
+ }),
72
+ help_text="Scan the QR code above with your authenticator app, enter the 6-digit code, then click Save.",
73
+ )
74
+
75
+ class Meta:
76
+ model = TOTPDevice
77
+ fields = ("name", "key", "confirmed")
78
+
79
+ def _get_validation_exclusions(self):
80
+ exclusions = super()._get_validation_exclusions()
81
+ for f in ("name","key","step","t0","digits","tolerance","drift","last_t",
82
+ "throttling_failure_count","throttling_failure_timestamp"):
83
+ exclusions.add(f)
84
+ return exclusions
85
+
86
+ def __init__(self, *args, **kwargs):
87
+ super().__init__(*args, **kwargs)
88
+ if not self.instance.pk:
89
+ k = getattr(self.instance, "key", None) or self.initial.get("key") or default_key()
90
+ self.instance.key = k
91
+ self.initial["key"] = k
92
+ if "key" in self.fields:
93
+ self.fields["key"].initial = k
94
+
95
+ if "name" in self.fields:
96
+ user = getattr(self.instance, "user", None)
97
+ used_names = set()
98
+ if user and user.pk:
99
+ qs = TOTPDevice.objects.filter(user=user)
100
+ if self.instance.pk:
101
+ qs = qs.exclude(pk=self.instance.pk)
102
+ used_names = set(qs.values_list("name", flat=True))
103
+ available = [
104
+ (val, label) for val, label in AUTHENTICATOR_APP_CHOICES
105
+ if val == "" or val not in used_names
106
+ ]
107
+ if self.instance.pk and self.instance.name:
108
+ existing = (self.instance.name, self.instance.name)
109
+ if existing not in available:
110
+ available.insert(1, existing)
111
+ self.fields["name"].choices = available
112
+
113
+ def has_changed(self):
114
+ return True
115
+
116
+ def clean(self):
117
+ cleaned_data = super().clean()
118
+ code = cleaned_data.get("verification_code", "").strip()
119
+ submitted_key = (
120
+ self.data.get(self.add_prefix("key"))
121
+ or cleaned_data.get("key")
122
+ or self.initial.get("key")
123
+ )
124
+ device_name = cleaned_data.get("name", "").strip()
125
+
126
+ if submitted_key and not self.instance.pk:
127
+ self.instance.key = submitted_key
128
+
129
+ user = getattr(self.instance, "user", None) or cleaned_data.get("user")
130
+ if not user:
131
+ user_id = self.data.get(self.add_prefix("user"))
132
+ if user_id:
133
+ user = User.objects.filter(pk=user_id).first()
134
+ if user:
135
+ self.instance.user = user
136
+
137
+ if device_name and user and user.pk:
138
+ qs = TOTPDevice.objects.filter(user=user, name=device_name)
139
+ if self.instance.pk:
140
+ qs = qs.exclude(pk=self.instance.pk)
141
+ if qs.exists():
142
+ raise ValidationError({
143
+ "name": f"A 2FA device with '{device_name}' already exists for this account. "
144
+ "Please select a different authenticator app."
145
+ })
146
+
147
+ cleaned_data["confirmed"] = False
148
+ self.instance.confirmed = False
149
+ self._is_verified = False
150
+
151
+ if not code:
152
+ raise ValidationError({
153
+ "verification_code": "Verification Code Required! Enter the 6-digit code from your authenticator app and click Save."
154
+ })
155
+
156
+ if hasattr(self.instance, "verify_token") and getattr(self.instance, "bin_key", None):
157
+ self.instance.tolerance = 2
158
+ if not self.instance.verify_token(code):
159
+ cleaned_data["confirmed"] = False
160
+ self.instance.confirmed = False
161
+ if self.instance.pk:
162
+ try:
163
+ self.instance.delete()
164
+ except Exception:
165
+ pass
166
+ raise ValidationError({
167
+ "verification_code": "Verification Failed! The code is invalid or expired. Re-scan the QR code and try again."
168
+ })
169
+ cleaned_data["confirmed"] = True
170
+ self.instance.confirmed = True
171
+ self._is_verified = True
172
+ else:
173
+ if self.instance.pk:
174
+ try:
175
+ self.instance.delete()
176
+ except Exception:
177
+ pass
178
+ raise ValidationError({"verification_code": "Verification Failed! Secret key is missing."})
179
+
180
+ return cleaned_data
181
+
182
+ def save(self, commit=True):
183
+ instance = super().save(commit=False)
184
+ if getattr(self, "_is_verified", False):
185
+ instance.confirmed = True
186
+ if commit:
187
+ instance.save()
188
+ return instance
189
+
190
+ class TOTPDeviceInline(admin.StackedInline):
191
+ model = TOTPDevice
192
+ form = TOTPDeviceForm
193
+ extra = 0
194
+ max_num = 5
195
+ fields = ("name", "key", "get_secret_key", "get_qrcode", "verification_code", "verification_status", "confirmed")
196
+ readonly_fields = ("get_secret_key", "get_qrcode", "verification_status")
197
+ verbose_name = "2FA Authenticator Device"
198
+ verbose_name_plural = "2FA Authenticator Devices (OTP)"
199
+
200
+ def get_formset(self, request, obj=None, **kwargs):
201
+ self._parent_user = obj
202
+ return super().get_formset(request, obj, **kwargs)
203
+
204
+ def get_secret_key(self, obj):
205
+ if not obj:
206
+ return mark_safe("<span style='color:#64748b;font-size:12px;'>Secret key generated on first save.</span>")
207
+ if not getattr(obj, "bin_key", None) and not getattr(obj, "key", None):
208
+ obj.key = default_key()
209
+ try:
210
+ b32 = base64.b32encode(obj.bin_key).decode("utf-8")
211
+ return format_html(
212
+ "<div><code style='font-size:14px;font-weight:bold;letter-spacing:2px;background:#f1f5f9;"
213
+ "padding:6px 10px;border-radius:8px;font-family:monospace;color:#0f172a;border:1px solid #cbd5e1;'>{}</code>"
214
+ "<span style='font-size:11px;color:#64748b;margin-left:10px;'>(Base32 — for Google Authenticator / 1Password)</span></div>",
215
+ b32,
216
+ )
217
+ except Exception:
218
+ return "-"
219
+ get_secret_key.short_description = "Secret Key (Base32)"
220
+
221
+ def get_qrcode(self, obj):
222
+ if not obj:
223
+ return mark_safe("<span style='color:#64748b;font-size:12px;'>QR Code generated on first save.</span>")
224
+ if not getattr(obj, "bin_key", None) and not getattr(obj, "key", None):
225
+ obj.key = default_key()
226
+ try:
227
+ b32 = base64.b32encode(obj.bin_key).decode("utf-8")
228
+ user = getattr(obj, "user", None) or getattr(self, "_parent_user", None)
229
+ email = getattr(user, "email", None) if user else None
230
+ username = user.get_username() if (user and hasattr(user, "get_username")) else None
231
+ account_title = email or username or "User"
232
+ app_title = getattr(settings, "ADMINFLOW_TITLE", getattr(settings, "ADMINFLOW_COMPANY", "AdminFlow"))
233
+ config_url = f"otpauth://totp/{app_title}:{account_title}?secret={b32}&issuer={app_title}"
234
+ img = qrcode.make(config_url)
235
+ buf = io.BytesIO()
236
+ img.save(buf, format="PNG")
237
+ data = base64.b64encode(buf.getvalue()).decode("utf-8")
238
+ return format_html(
239
+ "<div style='display:flex;align-items:center;gap:16px;margin-top:6px;'>"
240
+ "<img src='data:image/png;base64,{}' style='width:140px;height:140px;border-radius:12px;border:1px solid #cbd5e1;padding:6px;background:white;'/>"
241
+ "<div><p style='font-weight:700;font-size:13px;color:#0f172a;margin:0 0 2px;'>Scan with Authenticator App</p>"
242
+ "<p style='font-size:11px;color:#374151;font-family:monospace;background:#f1f5f9;display:inline-block;padding:2px 8px;border-radius:4px;margin:0 0 6px;'>{}: {}</p>"
243
+ "<p style='font-size:11px;color:#64748b;margin:0;max-width:260px;line-height:1.4;'>Scan this QR code, enter the 6-digit code below, then click Save to verify.</p>"
244
+ "</div></div>",
245
+ data, app_title, account_title,
246
+ )
247
+ except Exception as e:
248
+ return format_html("<span style='color:#ef4444;font-size:11px;'>Error generating QR code: {}</span>", str(e))
249
+ get_qrcode.short_description = "QR Code (Scan for Authenticator App)"
250
+
251
+ def verification_status(self, obj):
252
+ if not obj or not getattr(obj, "bin_key", None):
253
+ return mark_safe("<span style='color:#64748b;font-size:12px;'>Save device to view status.</span>")
254
+ if obj.confirmed:
255
+ return mark_safe(
256
+ "<div style='display:inline-flex;align-items:center;gap:6px;background:#f0fdf4;"
257
+ "border:1px solid #bbf7d0;color:#166534;font-weight:700;font-size:12px;"
258
+ "padding:6px 14px;border-radius:8px;'>&#10003; 2FA Authenticator Device Verified &amp; Active</div>"
259
+ )
260
+ return mark_safe(
261
+ "<div style='background:#fffbeb;border:1px solid #fde68a;padding:10px 14px;border-radius:8px;margin-top:4px;'>"
262
+ "<p style='font-weight:700;font-size:12px;color:#b45309;margin:0 0 2px;'>&#9888;&#65039; Security Verification Pending</p>"
263
+ "<p style='font-size:11px;color:#78350f;margin:0;'>Enter your 6-digit code above and click <b>Save</b> to verify &amp; activate 2FA.</p>"
264
+ "</div>"
265
+ )
266
+ verification_status.short_description = "Security Verification"
267
+
268
+
269
+ # ─────────────────────────────────────────────
270
+ # Email OTP Inline
271
+ # ─────────────────────────────────────────────
272
+ if HAS_OTP_EMAIL:
273
+ from django_otp.plugins.otp_email.models import EmailDevice
274
+
275
+ class EmailDeviceForm(forms.ModelForm):
276
+ class Meta:
277
+ model = EmailDevice
278
+ fields = ("confirmed",)
279
+
280
+ def _get_validation_exclusions(self):
281
+ exclusions = super()._get_validation_exclusions()
282
+ exclusions.add("name")
283
+ exclusions.add("email")
284
+ return exclusions
285
+
286
+ def has_changed(self):
287
+ return True
288
+
289
+ def clean(self):
290
+ cleaned_data = super().clean()
291
+ if self.instance and hasattr(self.instance, "user") and self.instance.user:
292
+ self.instance.email = (
293
+ self.instance.user.email
294
+ or f"{self.instance.user.username}@example.com"
295
+ )
296
+ self.instance.name = "default"
297
+ return cleaned_data
298
+
299
+ class EmailDeviceInline(admin.StackedInline):
300
+ model = EmailDevice
301
+ form = EmailDeviceForm
302
+ extra = 0
303
+ max_num = 1
304
+ fields = ("confirmed",)
305
+ verbose_name = "Email OTP 2FA Device"
306
+ verbose_name_plural = "Email OTP 2FA Devices (Email Verification Code)"
307
+
308
+
309
+ # ─────────────────────────────────────────────
310
+ # Static / Backup Codes Inline
311
+ # ─────────────────────────────────────────────
312
+ if HAS_OTP_STATIC:
313
+ from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
314
+
315
+ def _get_backup_codes_file():
316
+ base_dir = getattr(settings, "BASE_DIR", ".")
317
+ return os.path.join(str(base_dir), ".adminflow_backup_codes.json")
318
+
319
+ def _load_batch_tokens(device_id):
320
+ filepath = _get_backup_codes_file()
321
+ if os.path.exists(filepath):
322
+ try:
323
+ with open(filepath, "r") as f:
324
+ return json.load(f).get(str(device_id), [])
325
+ except Exception:
326
+ pass
327
+ return []
328
+
329
+ def _save_batch_tokens(device_id, tokens):
330
+ filepath = _get_backup_codes_file()
331
+ data = {}
332
+ if os.path.exists(filepath):
333
+ try:
334
+ with open(filepath, "r") as f:
335
+ data = json.load(f)
336
+ except Exception:
337
+ data = {}
338
+ data[str(device_id)] = tokens
339
+ try:
340
+ with open(filepath, "w") as f:
341
+ json.dump(data, f)
342
+ except Exception:
343
+ pass
344
+
345
+ class StaticDeviceForm(forms.ModelForm):
346
+ class Meta:
347
+ model = StaticDevice
348
+ fields = ("confirmed",)
349
+
350
+ def has_changed(self):
351
+ return True
352
+
353
+ def clean(self):
354
+ cleaned_data = super().clean()
355
+ self.instance.name = "backup_codes"
356
+ self.instance.confirmed = True
357
+ return cleaned_data
358
+
359
+ class StaticDeviceInline(admin.StackedInline):
360
+ model = StaticDevice
361
+ form = StaticDeviceForm
362
+ extra = 0
363
+ max_num = 1
364
+ fields = ("confirmed", "get_backup_codes")
365
+ readonly_fields = ("get_backup_codes",)
366
+ verbose_name = "10 Emergency Recovery Backup Codes"
367
+ verbose_name_plural = "10 Emergency Recovery Backup Codes (Static OTP)"
368
+
369
+ def get_backup_codes(self, obj):
370
+ if not obj or not getattr(obj, "pk", None):
371
+ return mark_safe(
372
+ "<span style='color:#64748b;font-size:12px;'>Click <b>Save</b> to generate 10 Emergency Recovery Backup Codes.</span>"
373
+ )
374
+ active_tokens = list(StaticToken.objects.filter(device=obj).values_list("token", flat=True))
375
+ batch_tokens = _load_batch_tokens(obj.pk)
376
+ if not active_tokens:
377
+ active_tokens = []
378
+ for _ in range(10):
379
+ t = StaticToken.random_token()
380
+ StaticToken.objects.create(device=obj, token=t)
381
+ active_tokens.append(t)
382
+ batch_tokens = list(active_tokens)
383
+ _save_batch_tokens(obj.pk, batch_tokens)
384
+ if not obj.confirmed:
385
+ obj.confirmed = True
386
+ obj.save()
387
+ elif not batch_tokens:
388
+ batch_tokens = list(active_tokens)
389
+ _save_batch_tokens(obj.pk, batch_tokens)
390
+
391
+ display = batch_tokens if batch_tokens else active_tokens
392
+ lis = []
393
+ for t in display:
394
+ if t in active_tokens:
395
+ lis.append(
396
+ f"<li style='font-family:monospace;font-weight:bold;font-size:13px;background:#f1f5f9;"
397
+ f"padding:6px 10px;border-radius:8px;border:1px solid #cbd5e1;text-align:center;"
398
+ f"color:#0f172a;display:inline-flex;align-items:center;justify-content:center;' title='Active'>{t}</li>"
399
+ )
400
+ else:
401
+ lis.append(
402
+ f"<li style='font-family:monospace;font-weight:bold;font-size:13px;background:#fef2f2;"
403
+ f"padding:6px 10px;border-radius:8px;border:1px dashed #fca5a5;text-align:center;"
404
+ f"color:#991b1b;text-decoration:line-through;opacity:0.65;display:inline-flex;"
405
+ f"align-items:center;justify-content:center;gap:4px;' title='Used'>"
406
+ f"<span>{t}</span>"
407
+ f"<span style='font-size:9px;font-family:sans-serif;text-decoration:none;background:#ef4444;"
408
+ f"color:white;padding:1px 4px;border-radius:4px;font-weight:bold;line-height:1;'>USED</span></li>"
409
+ )
410
+ return format_html(
411
+ "<div style='margin-top:4px;'>"
412
+ "<p style='font-size:11px;color:#64748b;margin-bottom:8px;line-height:1.4;'>"
413
+ "Keep these 10 one-time emergency recovery codes in a safe place. Each code can be used ONCE.</p>"
414
+ "<ul style='display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));"
415
+ "gap:8px;list-style:none;padding:0;margin:0 0 12px;'>{}</ul>"
416
+ "<div style='display:flex;align-items:center;gap:16px;margin-top:8px;'>"
417
+ "<a href='/admin/auth/user/{}/download-backup-codes/' "
418
+ "style='background:#2563eb;color:white;font-weight:bold;font-size:11px;padding:6px 14px;"
419
+ "border-radius:8px;text-decoration:none;display:inline-flex;align-items:center;gap:6px;'>"
420
+ "&#128229; Download Backup Codes (.txt)</a>"
421
+ "<a href='/admin/auth/user/{}/generate-backup-codes/' "
422
+ "style='font-size:11px;color:#2563eb;font-weight:bold;text-decoration:underline;'>"
423
+ "&#128260; Regenerate 10 New Codes</a>"
424
+ "</div></div>",
425
+ mark_safe("".join(lis)), obj.user_id, obj.user_id,
426
+ )
427
+ get_backup_codes.short_description = "10 One-Time Recovery Backup Codes"
428
+
429
+
430
+ # ─────────────────────────────────────────────
431
+ # Public helper: get available inline classes
432
+ # ─────────────────────────────────────────────
433
+ def get_2fa_inlines():
434
+ """Return tuple of available 2FA inline classes based on installed OTP packages."""
435
+ inlines = []
436
+ if HAS_OTP_TOTP:
437
+ inlines.append(TOTPDeviceInline)
438
+ if HAS_OTP_EMAIL:
439
+ inlines.append(EmailDeviceInline)
440
+ if HAS_OTP_STATIC:
441
+ inlines.append(StaticDeviceInline)
442
+ return tuple(inlines)
443
+
444
+
445
+ # ─────────────────────────────────────────────
446
+ # AdminFlowUserAdminMixin
447
+ # ─────────────────────────────────────────────
448
+ class AdminFlowUserAdminMixin:
449
+ """
450
+ Mixin for Django UserAdmin that adds plug-and-play 2FA support.
451
+
452
+ Usage::
453
+
454
+ from adminflow.user_admin import AdminFlowUserAdminMixin, get_2fa_inlines
455
+
456
+ @admin.register(User)
457
+ class UserAdmin(AdminFlowUserAdminMixin, BaseUserAdmin):
458
+ inlines = (YourInline,) + get_2fa_inlines()
459
+ """
460
+
461
+ def save_formset(self, request, form, formset, change):
462
+ instances = formset.save(commit=False)
463
+ for obj in formset.deleted_objects:
464
+ obj.delete()
465
+ for instance in instances:
466
+ if HAS_OTP_TOTP and isinstance(instance, TOTPDevice):
467
+ if any(getattr(f, "_is_verified", False) for f in formset.forms):
468
+ instance.confirmed = True
469
+ if not instance.confirmed:
470
+ if instance.pk:
471
+ instance.delete()
472
+ TOTPDevice.objects.filter(user=instance.user, confirmed=False).delete()
473
+ continue
474
+ elif HAS_OTP_EMAIL and isinstance(instance, EmailDevice):
475
+ if hasattr(instance, "user") and instance.user:
476
+ instance.email = instance.user.email or f"{instance.user.username}@example.com"
477
+ instance.name = "default"
478
+ elif HAS_OTP_STATIC and isinstance(instance, StaticDevice):
479
+ instance.name = "backup_codes"
480
+ instance.confirmed = True
481
+ instance.save()
482
+ if StaticToken.objects.filter(device=instance).count() == 0:
483
+ codes = []
484
+ for _ in range(10):
485
+ t = StaticToken.random_token()
486
+ StaticToken.objects.create(device=instance, token=t)
487
+ codes.append(t)
488
+ _save_batch_tokens(instance.pk, codes)
489
+ instance.save()
490
+ formset.save_m2m()
491
+
492
+ def get_urls(self):
493
+ from django.urls import path
494
+ urls = super().get_urls()
495
+ custom = []
496
+ if HAS_OTP_TOTP:
497
+ custom.append(path(
498
+ "<path:object_id>/verify-totp/<int:device_id>/",
499
+ self.admin_site.admin_view(self.verify_totp_device_view),
500
+ name="user_verify_totp",
501
+ ))
502
+ if HAS_OTP_EMAIL:
503
+ custom.append(path(
504
+ "<path:object_id>/send-email-otp/",
505
+ self.admin_site.admin_view(self.send_email_otp_view),
506
+ name="user_send_email_otp",
507
+ ))
508
+ if HAS_OTP_STATIC:
509
+ custom.append(path(
510
+ "<path:object_id>/generate-backup-codes/",
511
+ self.admin_site.admin_view(self.generate_backup_codes_view),
512
+ name="user_generate_backup_codes",
513
+ ))
514
+ custom.append(path(
515
+ "<path:object_id>/download-backup-codes/",
516
+ self.admin_site.admin_view(self.download_backup_codes_view),
517
+ name="user_download_backup_codes",
518
+ ))
519
+ return custom + urls
520
+
521
+ def download_backup_codes_view(self, request, object_id):
522
+ from django.http import HttpResponse
523
+ from django.shortcuts import get_object_or_404
524
+ from django.utils import timezone
525
+ user = get_object_or_404(User, pk=object_id)
526
+ device, _ = StaticDevice.objects.get_or_create(
527
+ user=user, name="backup_codes", defaults={"confirmed": True}
528
+ )
529
+ tokens = list(StaticToken.objects.filter(device=device).values_list("token", flat=True))
530
+ if not tokens:
531
+ tokens = []
532
+ for _ in range(10):
533
+ t = StaticToken.random_token()
534
+ StaticToken.objects.create(device=device, token=t)
535
+ tokens.append(t)
536
+ device.confirmed = True
537
+ device.save()
538
+ app_title = getattr(settings, "ADMINFLOW_TITLE", "AdminFlow")
539
+ now_str = timezone.now().strftime("%Y-%m-%d %H:%M:%S UTC")
540
+ codes_txt = "\n".join([f" {i+1:2d}. {t}" for i, t in enumerate(tokens)])
541
+ user_label = user.email or user.get_username()
542
+ content = (
543
+ f"=====================================================\n"
544
+ f" {app_title.upper()} 2FA EMERGENCY RECOVERY CODES\n"
545
+ f"=====================================================\n"
546
+ f" User Email : {user_label}\n"
547
+ f" Generated On : {now_str}\n"
548
+ f"=====================================================\n\n"
549
+ f" IMPORTANT: Keep these codes in a safe place.\n"
550
+ f" Each code can be used ONCE to log in if you lose your phone.\n\n"
551
+ f" RECOVERY CODES:\n"
552
+ f" -----------------------------------------------------\n"
553
+ f"{codes_txt}\n"
554
+ f" -----------------------------------------------------\n\n"
555
+ f"=====================================================\n"
556
+ )
557
+ response = HttpResponse(content, content_type="text/plain; charset=utf-8")
558
+ response["Content-Disposition"] = f'attachment; filename="{user_label}_2fa_recovery_codes.txt"'
559
+ return response
560
+
561
+ def send_email_otp_view(self, request, object_id):
562
+ from django.contrib import messages
563
+ from django.shortcuts import get_object_or_404, redirect
564
+ user = get_object_or_404(User, pk=object_id)
565
+ device, _ = EmailDevice.objects.get_or_create(
566
+ user=user, name="default",
567
+ defaults={"confirmed": False, "email": user.email},
568
+ )
569
+ if not device.email:
570
+ device.email = user.email
571
+ device.save()
572
+ device.generate_challenge()
573
+ messages.success(
574
+ request,
575
+ f"Sent 6-digit verification code to {device.email}! Check your email, enter the code below, and click Save."
576
+ )
577
+ return redirect(request.META.get("HTTP_REFERER", f"/admin/auth/user/{object_id}/change/"))
578
+
579
+ def generate_backup_codes_view(self, request, object_id):
580
+ from django.contrib import messages
581
+ from django.shortcuts import get_object_or_404, redirect
582
+ user = get_object_or_404(User, pk=object_id)
583
+ device, _ = StaticDevice.objects.get_or_create(
584
+ user=user, name="backup_codes", defaults={"confirmed": True}
585
+ )
586
+ device.token_set.all().delete()
587
+ codes = []
588
+ for _ in range(10):
589
+ token = StaticToken.random_token()
590
+ device.token_set.create(token=token)
591
+ codes.append(token)
592
+ _save_batch_tokens(device.pk, codes)
593
+ messages.success(
594
+ request,
595
+ f"Successfully generated 10 emergency recovery backup codes for {user.email or user.get_username()}."
596
+ )
597
+ return redirect(request.META.get("HTTP_REFERER", f"/admin/auth/user/{object_id}/change/"))
598
+
599
+ def verify_totp_device_view(self, request, object_id, device_id):
600
+ from django.contrib import messages
601
+ from django.shortcuts import get_object_or_404, redirect
602
+ device = get_object_or_404(TOTPDevice, pk=device_id, user_id=object_id)
603
+ if request.method == "POST":
604
+ code = request.POST.get("verify_code", "").strip()
605
+ device.tolerance = 2
606
+ if device.verify_token(code):
607
+ device.confirmed = True
608
+ device.save()
609
+ messages.success(
610
+ request,
611
+ f"Verification Successful! 2FA Device '{device.name}' is now verified & active "
612
+ f"for {device.user.email or device.user.get_username()}."
613
+ )
614
+ else:
615
+ messages.error(
616
+ request,
617
+ f"Verification Failed: '{code}' is not a valid code for '{device.name}'. "
618
+ "Check your device time sync and try again."
619
+ )
620
+ return redirect(request.META.get("HTTP_REFERER", f"/admin/auth/user/{object_id}/change/"))
621
+
622
+ @admin.action(description="Enable 2FA (Create TOTP Device) for selected users")
623
+ def enable_2fa_otp(self, request, queryset):
624
+ if not HAS_OTP_TOTP:
625
+ return
626
+ count = 0
627
+ for u in queryset:
628
+ device, _ = TOTPDevice.objects.get_or_create(user=u, name="default", defaults={"confirmed": True})
629
+ if not device.confirmed:
630
+ device.confirmed = True
631
+ device.save()
632
+ count += 1
633
+ self.message_user(request, f"Successfully enabled 2FA OTP for {count} user(s).")
634
+
635
+ @admin.action(description="Generate 10 Emergency Backup Codes for selected users")
636
+ def generate_10_backup_codes(self, request, queryset):
637
+ if not HAS_OTP_STATIC:
638
+ return
639
+ count = 0
640
+ for u in queryset:
641
+ device, _ = StaticDevice.objects.get_or_create(user=u, name="backup_codes", defaults={"confirmed": True})
642
+ device.token_set.all().delete()
643
+ for _ in range(10):
644
+ device.token_set.create(token=StaticToken.random_token())
645
+ count += 1
646
+ self.message_user(request, f"Successfully generated 10 emergency backup codes for {count} user(s).")
647
+
648
+
649
+ def unregister_otp_models():
650
+ """Remove standalone OTP device models from the admin sidebar."""
651
+ _models = []
652
+ if HAS_OTP_TOTP:
653
+ _models.append(TOTPDevice)
654
+ if HAS_OTP_EMAIL:
655
+ _models.append(EmailDevice)
656
+ if HAS_OTP_STATIC:
657
+ _models.append(StaticDevice)
658
+ for m in _models:
659
+ try:
660
+ admin.site.unregister(m)
661
+ except Exception:
662
+ pass