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
adminflow/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # AdminFlow Django Admin Package
adminflow/apps.py ADDED
@@ -0,0 +1,22 @@
1
+ from django.apps import AppConfig
2
+ from django.conf import settings
3
+
4
+ class AdminflowConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'adminflow'
7
+ label = 'adminflow'
8
+
9
+ def ready(self):
10
+ # Pre-built Email OTP minimalist templates and subject defaults
11
+ if not hasattr(settings, 'OTP_EMAIL_BODY_HTML_TEMPLATE_PATH'):
12
+ setattr(settings, 'OTP_EMAIL_BODY_HTML_TEMPLATE_PATH', 'otp/email/token.html')
13
+ if not hasattr(settings, 'OTP_EMAIL_BODY_TEMPLATE_PATH'):
14
+ setattr(settings, 'OTP_EMAIL_BODY_TEMPLATE_PATH', 'otp/email/token.txt')
15
+ app_title = getattr(settings, 'ADMINFLOW_TITLE', getattr(settings, 'ADMINFLOW_COMPANY', 'AdminFlow'))
16
+ if not hasattr(settings, 'OTP_EMAIL_SUBJECT'):
17
+ setattr(settings, 'OTP_EMAIL_SUBJECT', f'Your {app_title} Verification Code: {{token}}')
18
+
19
+ if getattr(settings, 'ADMINFLOW_DJANGO_OTP', False):
20
+ from django.contrib import admin
21
+ from .views import adminflow_login_view
22
+ admin.site.login = adminflow_login_view
@@ -0,0 +1,72 @@
1
+ from django.conf import settings
2
+ from django.templatetags.static import static
3
+
4
+ DEFAULTS = {
5
+ 'title': 'AdminFlow',
6
+ 'logo': None,
7
+ 'favicon': None,
8
+ 'company': 'AdminFlow',
9
+ 'sidebar_style': 'rounded', # rounded, flat, sleek
10
+ 'theme': 'system', # system, light, dark
11
+ 'density': 'comfortable', # compact, comfortable
12
+ 'layout': 'default',
13
+ 'bg_color': '#f8f9ff',
14
+ 'sidebar_color': '#0f172a',
15
+ 'sidebar_text_color': '#94a3b8',
16
+ 'sidebar_active_text_color': '#ffffff',
17
+ 'navbar_color': '#ffffff',
18
+ 'primary_color': '#2563eb',
19
+ 'secondary_color': '#64748b',
20
+ 'success_color': '#22c55e',
21
+ 'warning_color': '#f59e0b',
22
+ 'danger_color': '#ef4444',
23
+ 'info_color': '#06b6d4',
24
+ 'border_radius': '16px',
25
+ 'font': 'Inter',
26
+ 'card_shadow': 'medium',
27
+ 'dark_mode': True,
28
+ 'compact_tables': True,
29
+ 'enable_glass_effect': False,
30
+ 'sidebar_collapsible': True,
31
+ 'sidebar_width': 280,
32
+ 'topbar_height': 64,
33
+ 'animation_speed': 'normal',
34
+ 'icon_pack': 'lucide',
35
+ 'table_density': 'comfortable',
36
+ 'date_format': 'MMM dd, yyyy',
37
+ 'time_format': '12h',
38
+ 'language': 'en',
39
+ 'rtl': False,
40
+ 'enable_command_palette': True,
41
+ 'enable_notifications': True,
42
+ 'enable_activity_log': True,
43
+ 'enable_audit_logs': True,
44
+ 'enable_global_search': True,
45
+ 'enable_quick_actions': True,
46
+ 'enable_multi_tab': True,
47
+ 'enable_theme_builder': True,
48
+ 'login_title': 'Master Your Enterprise Flow',
49
+ 'login_subtitle': 'A unified administrative engine built for scale, security, and developer productivity within the Django ecosystem.',
50
+ 'django_otp': False,
51
+ }
52
+
53
+ def adminflow_settings(request):
54
+ context = {}
55
+ for key, val in DEFAULTS.items():
56
+ setting_name = f"ADMINFLOW_{key.upper()}"
57
+ context[f"adminflow_{key}"] = getattr(settings, setting_name, val)
58
+
59
+ # Automatically resolve logo URL:
60
+ # 1) If user specified ADMINFLOW_LOGO:
61
+ # - If relative (e.g. 'logo.svg'), resolve against MEDIA_URL ('/media/logo.svg')
62
+ # - If absolute ('/static/...' or 'https://...'), use directly
63
+ # 2) If ADMINFLOW_LOGO is not set (None), fall back to default package logo at static('adminflow/img/logo.svg')
64
+ logo = context.get('adminflow_logo')
65
+ if logo:
66
+ if not (logo.startswith('/') or logo.startswith('http://') or logo.startswith('https://')):
67
+ media_url = getattr(settings, 'MEDIA_URL', '/media/')
68
+ context['adminflow_logo'] = f"{media_url.rstrip('/')}/{logo.lstrip('/')}"
69
+ else:
70
+ context['adminflow_logo'] = static('adminflow/img/logo.png')
71
+
72
+ return context
adminflow/forms.py ADDED
@@ -0,0 +1,89 @@
1
+ from django import forms
2
+ from django.contrib.auth.forms import AuthenticationForm
3
+ from django.contrib.auth import authenticate, get_user_model
4
+ from django.core.exceptions import ValidationError
5
+ from django.utils.translation import gettext_lazy as _
6
+
7
+ User = get_user_model()
8
+
9
+ class AdminFlowLoginForm(AuthenticationForm):
10
+ username = forms.CharField(
11
+ label=_("Username or Email"),
12
+ widget=forms.TextInput(attrs={'autofocus': True, 'placeholder': 'Username or email address'})
13
+ )
14
+
15
+ def clean(self):
16
+ username_input = self.cleaned_data.get('username', '').strip()
17
+ password = self.cleaned_data.get('password')
18
+
19
+ if username_input and password:
20
+ # 1. Try exact username match
21
+ self.user_cache = authenticate(self.request, username=username_input, password=password)
22
+
23
+ # 2. Try case-insensitive username match
24
+ if self.user_cache is None:
25
+ user_by_username = User.objects.filter(username__iexact=username_input).first()
26
+ if user_by_username:
27
+ self.user_cache = authenticate(self.request, username=user_by_username.get_username(), password=password)
28
+
29
+ # 3. Try case-insensitive email match
30
+ if self.user_cache is None:
31
+ users_by_email = User.objects.filter(email__iexact=username_input)
32
+ for u in users_by_email:
33
+ cached = authenticate(self.request, username=u.get_username(), password=password)
34
+ if cached:
35
+ self.user_cache = cached
36
+ break
37
+
38
+ # 4. Detailed error check if user_cache is still None
39
+ if self.user_cache is None:
40
+ user_candidate = (
41
+ User.objects.filter(username__iexact=username_input).first() or
42
+ User.objects.filter(email__iexact=username_input).first()
43
+ )
44
+ if user_candidate and user_candidate.check_password(password):
45
+ if not user_candidate.is_active:
46
+ raise ValidationError(
47
+ _("This account is currently inactive. Please contact an administrator."),
48
+ code='inactive',
49
+ )
50
+ raise self.get_invalid_login_error()
51
+ else:
52
+ self.confirm_login_allowed(self.user_cache)
53
+
54
+ return self.cleaned_data
55
+
56
+ class AdminFlowOTPAuthenticationForm(AuthenticationForm):
57
+ otp_token = forms.CharField(
58
+ required=False,
59
+ widget=forms.TextInput(attrs={'autocomplete': 'off', 'placeholder': '123456 or Backup Code'})
60
+ )
61
+
62
+ def clean(self):
63
+ cleaned_data = super().clean()
64
+ user = self.get_user()
65
+
66
+ from django.conf import settings
67
+ if getattr(settings, 'ADMINFLOW_DJANGO_OTP', False) and user:
68
+ try:
69
+ import django_otp
70
+ from django_otp import match_token
71
+
72
+ if django_otp.user_has_device(user, confirmed=True):
73
+ otp_token = self.data.get('otp_token', '').strip()
74
+ if not otp_token:
75
+ raise ValidationError(
76
+ _("2FA Verification Required! Please enter your mobile app code, email code, or emergency backup code."),
77
+ code='otp_required'
78
+ )
79
+ device = match_token(user, otp_token)
80
+ if not device:
81
+ raise ValidationError(
82
+ _("Invalid 2FA Code or Emergency Backup Code. Please check your mobile authenticator app, email code, or 10 recovery codes."),
83
+ code='otp_invalid'
84
+ )
85
+ self.otp_device = device
86
+ except ImportError:
87
+ pass
88
+
89
+ return cleaned_data
@@ -0,0 +1,224 @@
1
+ """
2
+ adminflow.import_export
3
+ =======================
4
+ Plug-and-play multi-sheet export/import mixin for Django admin.
5
+
6
+ Usage::
7
+
8
+ from adminflow.import_export import MultiSheetExportImportMixin
9
+ from import_export.admin import ImportExportModelAdmin
10
+
11
+ class CustomerAdmin(MultiSheetExportImportMixin, ImportExportModelAdmin, ...):
12
+ combined_sheets = [
13
+ ('General Details', CustomerResource, None),
14
+ ('Orders', OrderResource, lambda qs: Order.objects.filter(customer__in=qs)),
15
+ ]
16
+
17
+ combined_sheets format:
18
+ [(sheet_title, ResourceClass, queryset_lambda_or_None), ...]
19
+
20
+ Export behaviour:
21
+ - XLSX → one .xlsx file with one sheet per entry (requires openpyxl)
22
+ - Other → ZIP archive containing one file per entry (e.g. Customers.csv + Orders.csv)
23
+
24
+ Import behaviour:
25
+ - Multi-sheet .xlsx → each sheet processed by its paired resource
26
+ - Single-sheet / other → falls back to default single-resource import
27
+ """
28
+
29
+ try:
30
+ import tablib
31
+ HAS_TABLIB = True
32
+ except ImportError:
33
+ HAS_TABLIB = False
34
+
35
+
36
+ class MultiSheetExportImportMixin:
37
+ """
38
+ Adds multi-resource export/import to any ImportExportModelAdmin subclass.
39
+ """
40
+
41
+ combined_sheets = [] # [(title, ResourceClass, qs_lambda_or_None), ...]
42
+
43
+ # Formats shown in the export form. Override in subclass if needed.
44
+ # Defaults to XLSX + JSON when combined_sheets is set; all formats otherwise.
45
+ export_formats_override = None # set to a list of format classes to force specific formats
46
+
47
+ # ── Restrict available export formats ────────────────────────────────────
48
+ def get_export_formats(self):
49
+ from import_export.formats.base_formats import XLSX, JSON
50
+ if self.export_formats_override is not None:
51
+ return [f for f in self.export_formats_override if f().is_available()]
52
+ if self.combined_sheets:
53
+ return [f for f in [XLSX, JSON] if f().is_available()]
54
+ return super().get_export_formats()
55
+
56
+ # ── Export page context ───────────────────────────────────────────────────
57
+ def init_request_context_data(self, request, form):
58
+ context = super().init_request_context_data(request, form)
59
+ if not self.combined_sheets:
60
+ return context
61
+
62
+ sub_sheets = []
63
+ for title, resource_cls, _ in self.combined_sheets[1:]:
64
+ try:
65
+ resource_kwargs = self.get_export_resource_kwargs(request)
66
+ except Exception:
67
+ resource_kwargs = {}
68
+ instance = resource_cls(**resource_kwargs)
69
+ col_names = [f.column_name for f in instance.get_user_visible_fields()]
70
+ sub_sheets.append({'title': title, 'fields': col_names})
71
+
72
+ context['sub_sheets'] = sub_sheets
73
+ context['primary_sheet_title'] = self.combined_sheets[0][0]
74
+ return context
75
+
76
+ # ── Override _do_file_export to produce XLSX or ZIP ──────────────────────
77
+ def _do_file_export(self, file_format, request, queryset, export_form=None):
78
+ if not self.combined_sheets or not HAS_TABLIB:
79
+ return super()._do_file_export(file_format, request, queryset, export_form)
80
+
81
+ from django.http import HttpResponse
82
+ from import_export.signals import post_export
83
+
84
+ try:
85
+ ext = file_format.get_extension().lower()
86
+ except Exception:
87
+ ext = ''
88
+
89
+ model_name = self.model._meta.model_name
90
+
91
+ if ext == 'xlsx':
92
+ # ── Multi-sheet XLSX ──────────────────────────────────────────
93
+ book = tablib.Databook()
94
+ for title, resource_cls, qs_func in self.combined_sheets:
95
+ qs = qs_func(queryset) if qs_func is not None else queryset
96
+ dataset = resource_cls().export(qs)
97
+ dataset.title = title
98
+ book.add_sheet(dataset)
99
+
100
+ data = book.xlsx # bytes
101
+ response = HttpResponse(
102
+ data,
103
+ content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
104
+ )
105
+ response['Content-Disposition'] = (
106
+ f'attachment; filename="{model_name}_export.xlsx"'
107
+ )
108
+
109
+ else:
110
+ # ── Truly nested JSON ─────────────────────────────────────────
111
+ # Output structure (children embedded inside each parent):
112
+ # [
113
+ # {
114
+ # "id": "1", "name": "Rooney", ..., ← parent fields
115
+ # "orders": [ ← nest_key from sub-sheet title
116
+ # {"id": "1", "customer_email": ..., ...}
117
+ # ]
118
+ # },
119
+ # ...
120
+ # ]
121
+ import json
122
+
123
+ primary_title, primary_cls, _ = self.combined_sheets[0]
124
+ sub_sheets = self.combined_sheets[1:] # [(title, cls, qs_func), ...]
125
+
126
+ all_records = []
127
+ for obj in queryset:
128
+ # Export this single parent
129
+ single_qs = queryset.model.objects.filter(pk=obj.pk)
130
+ parent_ds = primary_cls().export(single_qs)
131
+ if not parent_ds or len(parent_ds) == 0:
132
+ continue
133
+ parent_row = dict(zip(parent_ds.headers, parent_ds[0]))
134
+
135
+ # Nest each sub-sheet under the parent using its title as key
136
+ for sub_title, sub_cls, sub_qs_func in sub_sheets:
137
+ nest_key = sub_title.lower().replace(' ', '_')
138
+ if sub_qs_func is not None:
139
+ child_qs = sub_qs_func(single_qs)
140
+ child_ds = sub_cls().export(child_qs)
141
+ children = [
142
+ dict(zip(child_ds.headers, row))
143
+ for row in child_ds
144
+ ]
145
+ else:
146
+ children = []
147
+ parent_row[nest_key] = children
148
+
149
+ all_records.append(parent_row)
150
+
151
+ raw_json = json.dumps(all_records, indent=2, default=str)
152
+ response = HttpResponse(raw_json, content_type='application/json')
153
+ response['Content-Disposition'] = (
154
+ f'attachment; filename="{model_name}_export.json"'
155
+ )
156
+
157
+ post_export.send(sender=None, model=self.model)
158
+ return response
159
+
160
+
161
+ # ── Import: detect multi-sheet XLSX ──────────────────────────────────────
162
+ def import_action(self, request, *args, **kwargs):
163
+ if not self.combined_sheets or not HAS_TABLIB:
164
+ return super().import_action(request, *args, **kwargs)
165
+
166
+ if request.method != 'POST' or 'confirm' not in request.POST:
167
+ return super().import_action(request, *args, **kwargs)
168
+
169
+ upload = request.FILES.get('import_file') or request.FILES.get('original_file_name')
170
+ if upload is None:
171
+ return super().import_action(request, *args, **kwargs)
172
+
173
+ if not getattr(upload, 'name', '').lower().endswith('.xlsx'):
174
+ return super().import_action(request, *args, **kwargs)
175
+
176
+ try:
177
+ raw = upload.read()
178
+ book = tablib.Databook()
179
+ book.load(raw, 'xlsx')
180
+ sheets = book.sheets()
181
+ except Exception:
182
+ return super().import_action(request, *args, **kwargs)
183
+
184
+ if len(sheets) < 2:
185
+ return super().import_action(request, *args, **kwargs)
186
+
187
+ from django.contrib import messages
188
+
189
+ total_new = total_updated = total_error = 0
190
+ for i, (title, resource_cls, _) in enumerate(self.combined_sheets):
191
+ if i >= len(sheets):
192
+ break
193
+ try:
194
+ result = resource_cls().import_data(
195
+ sheets[i],
196
+ dry_run=False,
197
+ raise_errors=False,
198
+ user=request.user,
199
+ )
200
+ total_new += result.totals.get('new', 0)
201
+ total_updated += result.totals.get('update', 0)
202
+ total_error += result.totals.get('error', 0)
203
+ except Exception as e:
204
+ messages.error(request, f"Error importing sheet '{title}': {e}")
205
+
206
+ if total_error:
207
+ messages.warning(
208
+ request,
209
+ f"Import finished — {total_new} new, {total_updated} updated, "
210
+ f"{total_error} errors across all sheets.",
211
+ )
212
+ else:
213
+ messages.success(
214
+ request,
215
+ f"Successfully imported all sheets — "
216
+ f"{total_new} new records, {total_updated} updated.",
217
+ )
218
+
219
+ from django.http import HttpResponseRedirect
220
+ from django.urls import reverse
221
+ opts = self.model._meta
222
+ return HttpResponseRedirect(
223
+ reverse(f'admin:{opts.app_label}_{opts.model_name}_changelist')
224
+ )
Binary file
@@ -0,0 +1,79 @@
1
+ {% load i18n %}
2
+ <div class="flex flex-wrap items-center gap-3 bg-surface-low border border-outline-variant p-3 rounded-xl">
3
+ {% block actions %}
4
+ <div class="flex items-center gap-2">
5
+ <span class="text-xs font-bold text-on-surface-variant">{% translate "Actions:" %}</span>
6
+ {% block actions-form %}
7
+ {% for field in action_form %}
8
+ <div class="inline-block relative">
9
+ {{ field }}
10
+ </div>
11
+ {% endfor %}
12
+ {% endblock %}
13
+ </div>
14
+
15
+ {% block actions-submit %}
16
+ <button type="submit" class="px-4 py-1.5 bg-primary text-white text-xs font-bold rounded-lg hover:bg-primary/90 transition-all shadow-sm"
17
+ title="{% translate "Run the selected action" %}" name="index" value="{{ action_index|default:0 }}">
18
+ {% translate "Go" %}
19
+ </button>
20
+ {% endblock %}
21
+
22
+ {% block actions-counter %}
23
+ {% if actions_selection_counter %}
24
+ <span class="text-xs text-on-surface-variant font-medium ml-2 action-counter" data-actions-icnt="{{ cl.result_list|length }}">
25
+ {{ selection_note }}
26
+ </span>
27
+ {% if cl.show_full_result_count %}
28
+ <span class="hidden text-xs text-primary hover:underline cursor-pointer ml-2 clear-selection">
29
+ {% translate "Clear selection" %}
30
+ </span>
31
+ {% endif %}
32
+ {% endif %}
33
+ {% endblock %}
34
+ {% endblock %}
35
+ </div>
36
+
37
+ <script>
38
+ document.addEventListener("DOMContentLoaded", function() {
39
+ // Style the action select dropdown
40
+ const select = document.querySelector('select[name="action"]');
41
+ if (select) {
42
+ select.className = "pl-3 pr-8 py-1.5 bg-surface-lowest border border-outline-variant rounded-lg text-xs appearance-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all outline-none";
43
+ }
44
+
45
+ // Live Action Counter update script
46
+ function updateActionCounter() {
47
+ const counter = document.querySelector('span.action-counter');
48
+ if (!counter) return;
49
+ const total = counter.dataset.actionsIcnt || document.querySelectorAll('input[type="checkbox"].action-select').length;
50
+ const selected = document.querySelectorAll('input[type="checkbox"].action-select:checked').length;
51
+ counter.textContent = selected + " of " + total + " selected";
52
+ }
53
+
54
+ // Handle #action-toggle select all / unselect all
55
+ const actionToggle = document.getElementById("action-toggle");
56
+ if (actionToggle) {
57
+ actionToggle.addEventListener("click", function() {
58
+ const isChecked = this.checked;
59
+ const rowCheckboxes = document.querySelectorAll('input[type="checkbox"].action-select');
60
+ rowCheckboxes.forEach(cb => {
61
+ cb.checked = isChecked;
62
+ const tr = cb.closest('tr');
63
+ if (tr) tr.classList.toggle('selected', isChecked);
64
+ });
65
+ setTimeout(updateActionCounter, 10);
66
+ });
67
+ }
68
+
69
+ // Listen for any individual checkbox click/change
70
+ document.addEventListener('change', function(e) {
71
+ if (e.target && e.target.type === 'checkbox') {
72
+ setTimeout(updateActionCounter, 10);
73
+ }
74
+ });
75
+
76
+ // Sync counter on load
77
+ updateActionCounter();
78
+ });
79
+ </script>