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.
- adminflow/__init__.py +1 -0
- adminflow/apps.py +22 -0
- adminflow/context_processors.py +72 -0
- adminflow/forms.py +89 -0
- adminflow/import_export.py +224 -0
- adminflow/static/adminflow/img/logo.png +0 -0
- adminflow/templates/admin/actions.html +79 -0
- adminflow/templates/admin/base.html +1263 -0
- adminflow/templates/admin/base_site.html +5 -0
- adminflow/templates/admin/change_form.html +315 -0
- adminflow/templates/admin/change_list.html +216 -0
- adminflow/templates/admin/change_list_results.html +211 -0
- adminflow/templates/admin/delete_confirmation.html +55 -0
- adminflow/templates/admin/delete_selected_confirmation.html +118 -0
- adminflow/templates/admin/edit_inline/stacked.html +85 -0
- adminflow/templates/admin/edit_inline/tabular.html +81 -0
- adminflow/templates/admin/filter.html +17 -0
- adminflow/templates/admin/import_export/change_list_export_item.html +8 -0
- adminflow/templates/admin/import_export/change_list_import_item.html +8 -0
- adminflow/templates/admin/import_export/export.html +199 -0
- adminflow/templates/admin/includes/fieldset.html +73 -0
- adminflow/templates/admin/includes/toast.html +102 -0
- adminflow/templates/admin/index.html +227 -0
- adminflow/templates/admin/logged_out.html +38 -0
- adminflow/templates/admin/login.html +144 -0
- adminflow/templates/admin/object_history.html +60 -0
- adminflow/templates/admin/pagination.html +30 -0
- adminflow/templates/admin/search_form.html +28 -0
- adminflow/templates/admin/submit_line.html +48 -0
- adminflow/templates/admin/verify_2fa.html +280 -0
- adminflow/templates/otp/email/token.html +64 -0
- adminflow/templates/otp/email/token.txt +11 -0
- adminflow/templates/registration/logged_out.html +38 -0
- adminflow/templates/registration/password_change_form.html +272 -0
- adminflow/templates/simple_history/object_history.html +186 -0
- adminflow/templates/simple_history/object_history_form.html +103 -0
- adminflow/templates/simple_history/submit_line.html +17 -0
- adminflow/templatetags/__init__.py +1 -0
- adminflow/templatetags/__pycache__/__init__.cpython-314.pyc +0 -0
- adminflow/templatetags/__pycache__/adminflow_tags.cpython-314.pyc +0 -0
- adminflow/templatetags/adminflow_tags.py +233 -0
- adminflow/user_admin.py +662 -0
- adminflow/views.py +207 -0
- django_adminflow-1.0.0.dist-info/METADATA +398 -0
- django_adminflow-1.0.0.dist-info/RECORD +47 -0
- django_adminflow-1.0.0.dist-info/WHEEL +5 -0
- 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>
|