Массовые действия Django Admin
Bulk actions позволяют выполнять операции над несколькими объектами одновременно, что значительно ускоряет работу администраторов с большими объемами данных.
Базовые массовые действия
1# your_app/admin.py
2from django.contrib import admin
3from django.contrib import messages
4from django.http import HttpResponse, HttpResponseRedirect
5from django.urls import path, reverse
6from django.shortcuts import render
7from django.utils.html import format_html
8from django.utils.safestring import mark_safe
9from .models import Book, Author, Category, Publisher
10from .forms import BulkEditForm, ImportForm
11
12@admin.register(Book)
13class BookAdmin(admin.ModelAdmin):
14 list_display = ['title', 'author', 'category', 'published', 'price', 'created_at']
15 list_filter = ['published', 'category', 'author', 'created_at']
16 search_fields = ['title', 'author__name', 'description']
17 list_per_page = 50
18 actions = [
19 'make_published', 'make_unpublished', 'export_as_csv',
20 'export_as_excel', 'bulk_edit', 'delete_selected'
21 ]
22
23 def make_published(self, request, queryset):
24 updated = queryset.update(published=True)
25 self.message_user(
26 request,
27 f'Успешно опубликовано {updated} книг.',
28 messages.SUCCESS
29 )
30 make_published.short_description = "Опубликовать выбранные книги"
31
32 def make_unpublished(self, request, queryset):
33 updated = queryset.update(published=False)
34 self.message_user(
35 request,
36 f'Успешно снято с публикации {updated} книг.',
37 messages.SUCCESS
38 )
39 make_unpublished.short_description = "Снять с публикации выбранные книги"
40
41 def export_as_csv(self, request, queryset):
42 import csv
43 from django.http import HttpResponse
44
45 response = HttpResponse(content_type='text/csv; charset=utf-8')
46 response['Content-Disposition'] = 'attachment; filename="books_export.csv"'
47
48 # Добавляем BOM для корректного отображения кириллицы в Excel
49 response.write('\ufeff'.encode('utf8'))
50
51 writer = csv.writer(response, delimiter=';')
52 writer.writerow([
53 'ID', 'Название', 'Автор', 'Категория', 'Описание',
54 'Цена', 'Опубликовано', 'Дата создания'
55 ])
56
57 for book in queryset.select_related('author', 'category'):
58 writer.writerow([
59 book.id, book.title, book.author.name,
60 book.category.name, book.description,
61 book.price, 'Да' if book.published else 'Нет',
62 book.created_at.strftime('%d.%m.%Y %H:%M')
63 ])
64
65 return response
66 export_as_csv.short_description = "Экспорт в CSV"
67
68 def export_as_excel(self, request, queryset):
69 try:
70 import openpyxl
71 from openpyxl.styles import Font, PatternFill
72 except ImportError:
73 self.message_user(
74 request,
75 'Для экспорта в Excel установите openpyxl: pip install openpyxl',
76 messages.ERROR
77 )
78 return
79
80 from django.http import HttpResponse
81
82 wb = openpyxl.Workbook()
83 ws = wb.active
84 ws.title = "Книги"
85
86 # Заголовки
87 headers = ['ID', 'Название', 'Автор', 'Категория', 'Описание', 'Цена', 'Опубликовано', 'Дата создания']
88 for col, header in enumerate(headers, 1):
89 cell = ws.cell(row=1, column=col, value=header)
90 cell.font = Font(bold=True)
91 cell.fill = PatternFill(start_color="CCCCCC", end_color="CCCCCC", fill_type="solid")
92
93 # Данные
94 for row, book in enumerate(queryset.select_related('author', 'category'), 2):
95 ws.cell(row=row, column=1, value=book.id)
96 ws.cell(row=row, column=2, value=book.title)
97 ws.cell(row=row, column=3, value=book.author.name)
98 ws.cell(row=row, column=4, value=book.category.name)
99 ws.cell(row=row, column=5, value=book.description)
100 ws.cell(row=row, column=6, value=float(book.price))
101 ws.cell(row=row, column=7, value='Да' if book.published else 'Нет')
102 ws.cell(row=row, column=8, value=book.created_at.strftime('%d.%m.%Y %H:%M'))
103
104 # Автоподбор ширины колонок
105 for column in ws.columns:
106 max_length = 0
107 column_letter = column[0].column_letter
108 for cell in column:
109 try:
110 if len(str(cell.value)) > max_length:
111 max_length = len(str(cell.value))
112 except:
113 pass
114 adjusted_width = min(max_length + 2, 50)
115 ws.column_dimensions[column_letter].width = adjusted_width
116
117 response = HttpResponse(
118 content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
119 )
120 response['Content-Disposition'] = 'attachment; filename="books_export.xlsx"'
121
122 wb.save(response)
123 return response
124 export_as_excel.short_description = "Экспорт в Excel"
125
126 def get_urls(self):
127 urls = super().get_urls()
128 custom_urls = [
129 path('bulk-edit/', self.admin_site.admin_view(self.bulk_edit_view), name='book_bulk_edit'),
130 path('import/', self.admin_site.admin_view(self.import_view), name='book_import'),
131 ]
132 return custom_urls + urls
133
134 def bulk_edit_view(self, request):
135 if request.method == 'POST':
136 form = BulkEditForm(request.POST)
137 if form.is_valid():
138 book_ids = request.POST.getlist('_selected_action')
139 books = Book.objects.filter(id__in=book_ids)
140
141 # Применяем изменения
142 if form.cleaned_data.get('category'):
143 books.update(category=form.cleaned_data['category'])
144
145 if form.cleaned_data.get('published') is not None:
146 books.update(published=form.cleaned_data['published'])
147
148 if form.cleaned_data.get('price_discount'):
149 discount = form.cleaned_data['price_discount']
150 for book in books:
151 book.price = book.price * (1 - discount / 100)
152 book.save()
153
154 self.message_user(
155 request,
156 f'Успешно обновлено {books.count()} книг.',
157 messages.SUCCESS
158 )
159 return HttpResponseRedirect(reverse('admin:your_app_book_changelist'))
160 else:
161 form = BulkEditForm()
162
163 context = {
164 'form': form,
165 'title': 'Массовое редактирование книг',
166 'opts': self.model._meta,
167 }
168 return render(request, 'admin/bulk_edit.html', context)
169
170 def import_view(self, request):
171 if request.method == 'POST':
172 form = ImportForm(request.POST, request.FILES)
173 if form.is_valid():
174 try:
175 import csv
176 from decimal import Decimal
177
178 csv_file = request.FILES['csv_file']
179 decoded_file = csv_file.read().decode('utf-8').splitlines()
180 reader = csv.DictReader(decoded_file, delimiter=';')
181
182 created_count = 0
183 updated_count = 0
184
185 for row in reader:
186 author, created = Author.objects.get_or_create(
187 name=row['Автор'],
188 defaults={'bio': 'Импортирован из CSV'}
189 )
190
191 category, created = Category.objects.get_or_create(
192 name=row['Категория'],
193 defaults={'description': 'Импортирована из CSV'}
194 )
195
196 book_data = {
197 'title': row['Название'],
198 'author': author,
199 'category': category,
200 'description': row.get('Описание', ''),
201 'price': Decimal(row.get('Цена', '0')),
202 'published': row.get('Опубликовано', 'Нет').lower() == 'да',
203 }
204
205 book, created = Book.objects.update_or_create(
206 title=row['Название'],
207 author=author,
208 defaults=book_data
209 )
210
211 if created:
212 created_count += 1
213 else:
214 updated_count += 1
215
216 self.message_user(
217 request,
218 f'Импорт завершен. Создано: {created_count}, обновлено: {updated_count}',
219 messages.SUCCESS
220 )
221 return HttpResponseRedirect(reverse('admin:your_app_book_changelist'))
222
223 except Exception as e:
224 self.message_user(
225 request,
226 f'Ошибка при импорте: {str(e)}',
227 messages.ERROR
228 )
229 else:
230 form = ImportForm()
231
232 context = {
233 'form': form,
234 'title': 'Импорт книг из CSV',
235 'opts': self.model._meta,
236 }
237 return render(request, 'admin/import.html', context)
Формы для массовых действий
1# your_app/forms.py
2from django import forms
3from .models import Category, Author
4
5class BulkEditForm(forms.Form):
6 category = forms.ModelChoiceField(
7 queryset=Category.objects.all(),
8 required=False,
9 empty_label="Не изменять",
10 label="Категория"
11 )
12
13 published = forms.ChoiceField(
14 choices=[('', 'Не изменять'), (True, 'Опубликовать'), (False, 'Снять с публикации')],
15 required=False,
16 label="Статус публикации"
17 )
18
19 price_discount = forms.IntegerField(
20 min_value=0,
21 max_value=100,
22 required=False,
23 label="Скидка (%)",
24 help_text="Процент скидки для всех выбранных книг"
25 )
26
27 add_tags = forms.CharField(
28 required=False,
29 label="Добавить теги",
30 help_text="Теги через запятую"
31 )
32
33 remove_tags = forms.CharField(
34 required=False,
35 label="Удалить теги",
36 help_text="Теги через запятую"
37 )
38
39class ImportForm(forms.Form):
40 csv_file = forms.FileField(
41 label="CSV файл",
42 help_text="Файл должен содержать колонки: Название, Автор, Категория, Описание, Цена, Опубликовано"
43 )
44
45 update_existing = forms.BooleanField(
46 required=False,
47 initial=True,
48 label="Обновлять существующие записи",
49 help_text="Если отмечено, существующие книги будут обновлены"
50 )
51
52 create_missing_authors = forms.BooleanField(
53 required=False,
54 initial=True,
55 label="Создавать отсутствующих авторов"
56 )
57
58 create_missing_categories = forms.BooleanField(
59 required=False,
60 initial=True,
61 label="Создавать отсутствующие категории"
62 )
HTML шаблоны для админки
1# templates/admin/bulk_edit.html
2{% extends "admin/base_site.html" %}
3{% load i18n admin_urls static admin_modify %}
4
5{% block extrahead %}
6 {{ block.super }}
7 <script type="text/javascript" src="{% url 'admin:jsi18n' %}"></script>
8 {{ form.media }}
9{% endblock %}
10
11{% block breadcrumbs %}
12 <div class="breadcrumbs">
13 <a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
14 › <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
15 › <a href="{% url 'admin:your_app_book_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
16 › Массовое редактирование
17 </div>
18{% endblock %}
19
20{% block content %}
21 <div id="content-main">
22 <form method="post">
23 {% csrf_token %}
24
25 <fieldset class="module aligned">
26 <h2>Выберите поля для массового редактирования</h2>
27
28 {% for field in form %}
29 <div class="form-row">
30 {{ field.errors }}
31 {{ field.label_tag }}
32 {{ field }}
33 {% if field.help_text %}
34 <p class="help">{{ field.help_text|safe }}</p>
35 {% endif %}
36 </div>
37 {% endfor %}
38 </fieldset>
39
40 <div class="submit-row">
41 <input type="submit" value="Применить изменения" class="default" />
42 <a href="{% url 'admin:your_app_book_changelist' %}" class="closelink">Отмена</a>
43 </div>
44 </form>
45 </div>
46{% endblock %}
47
48# templates/admin/import.html
49{% extends "admin/base_site.html" %}
50{% load i18n admin_urls static admin_modify %}
51
52{% block extrahead %}
53 {{ block.super }}
54 <script type="text/javascript" src="{% url 'admin:jsi18n' %}"></script>
55 {{ form.media }}
56{% endblock %}
57
58{% block breadcrumbs %}
59 <div class="breadcrumbs">
60 <a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
61 › <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
62 › <a href="{% url 'admin:your_app_book_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
63 › Импорт
64 </div>
65{% endblock %}
66
67{% block content %}
68 <div id="content-main">
69 <form method="post" enctype="multipart/form-data">
70 {% csrf_token %}
71
72 <fieldset class="module aligned">
73 <h2>Импорт книг из CSV файла</h2>
74
75 {% for field in form %}
76 <div class="form-row">
77 {{ field.errors }}
78 {{ field.label_tag }}
79 {{ field }}
80 {% if field.help_text %}
81 <p class="help">{{ field.help_text|safe }}</p>
82 {% endif %}
83 </div>
84 {% endfor %}
85 </fieldset>
86
87 <div class="submit-row">
88 <input type="submit" value="Импортировать" class="default" />
89 <a href="{% url 'admin:your_app_book_changelist' %}" class="closelink">Отмена</a>
90 </div>
91 </form>
92
93 <div class="module">
94 <h2>Формат CSV файла</h2>
95 <p>Файл должен содержать следующие колонки:</p>
96 <ul>
97 <li><strong>Название</strong> - название книги (обязательно)</li>
98 <li><strong>Автор</strong> - имя автора (обязательно)</li>
99 <li><strong>Категория</strong> - название категории (обязательно)</li>
100 <li><strong>Описание</strong> - описание книги</li>
101 <li><strong>Цена</strong> - цена книги (число)</li>
102 <li><strong>Опубликовано</strong> - "Да" или "Нет"</li>
103 </ul>
104 <p><strong>Пример:</strong></p>
105 <pre>Название;Автор;Категория;Описание;Цена;Опубликовано
106Python для начинающих;Иван Иванов;Программирование;Книга о Python;29.99;Да
107Django в действии;Петр Петров;Веб-разработка;Руководство по Django;39.99;Нет</pre>
108 </div>
109 </div>
110{% endblock %}
Дополнительные массовые действия
1# your_app/admin.py
2@admin.register(Book)
3class BookAdmin(admin.ModelAdmin):
4 # ... существующий код ...
5
6 def send_notification(self, request, queryset):
7 """Отправка уведомлений авторам выбранных книг"""
8 from django.core.mail import send_mail
9
10 for book in queryset.select_related('author'):
11 if book.author.email:
12 send_mail(
13 subject=f'Ваша книга "{book.title}" была отмечена администратором',
14 message=f'Администратор {request.user.username} отметил вашу книгу "{book.title}" для рассмотрения.',
15 from_email='admin@example.com',
16 recipient_list=[book.author.email],
17 )
18
19 self.message_user(
20 request,
21 f'Уведомления отправлены авторам {queryset.count()} книг.',
22 messages.SUCCESS
23 )
24 send_notification.short_description = "Отправить уведомления авторам"
25
26 def duplicate_books(self, request, queryset):
27 """Дублирование выбранных книг"""
28 duplicated_count = 0
29
30 for book in queryset:
31 book.pk = None
32 book.title = f"{book.title} (копия)"
33 book.published = False
34 book.save()
35 duplicated_count += 1
36
37 self.message_user(
38 request,
39 f'Успешно создано {duplicated_count} копий книг.',
40 messages.SUCCESS
41 )
42 duplicate_books.short_description = "Создать копии выбранных книг"
43
44 def change_publisher(self, request, queryset):
45 """Изменение издателя для выбранных книг"""
46 from django.shortcuts import render
47
48 if 'apply' in request.POST:
49 publisher_id = request.POST.get('publisher')
50 if publisher_id:
51 publisher = Publisher.objects.get(id=publisher_id)
52 updated = queryset.update(publisher=publisher)
53 self.message_user(
54 request,
55 f'Издатель изменен для {updated} книг.',
56 messages.SUCCESS
57 )
58 return HttpResponseRedirect(reverse('admin:your_app_book_changelist'))
59
60 publishers = Publisher.objects.all()
61 context = {
62 'publishers': publishers,
63 'queryset': queryset,
64 'opts': self.model._meta,
65 }
66 return render(request, 'admin/change_publisher.html', context)
67 change_publisher.short_description = "Изменить издателя"
68
69 def get_actions(self, request):
70 """Кастомизация доступных действий"""
71 actions = super().get_actions(request)
72
73 # Скрываем действия для неавторизованных пользователей
74 if not request.user.is_superuser:
75 if 'delete_selected' in actions:
76 del actions['delete_selected']
77 if 'duplicate_books' in actions:
78 del actions['duplicate_books']
79
80 return actions
JavaScript для подтверждения действий
1# templates/admin/change_list.html
2{% extends "admin/change_list.html" %}
3{% load i18n admin_urls static admin_list %}
4
5{% block extrahead %}
6 {{ block.super }}
7 <script type="text/javascript">
8 (function($) {
9 $(document).ready(function() {
10 // Подтверждение для удаления
11 $('select[name="action"]').change(function() {
12 var action = $(this).val();
13 if (action === 'delete_selected') {
14 $(this).closest('form').submit(function(e) {
15 if (!confirm('Вы уверены, что хотите удалить выбранные элементы? Это действие нельзя отменить.')) {
16 e.preventDefault();
17 return false;
18 }
19 });
20 }
21 });
22
23 // Подтверждение для массового редактирования
24 if (window.location.href.indexOf('bulk-edit') > -1) {
25 $('form').submit(function(e) {
26 if (!confirm('Вы уверены, что хотите применить изменения ко всем выбранным элементам?')) {
27 e.preventDefault();
28 return false;
29 }
30 });
31 }
32
33 // Подтверждение для импорта
34 if (window.location.href.indexOf('import') > -1) {
35 $('form').submit(function(e) {
36 var fileInput = $('input[name="csv_file"]');
37 if (fileInput[0].files.length === 0) {
38 alert('Пожалуйста, выберите файл для импорта.');
39 e.preventDefault();
40 return false;
41 }
42 });
43 }
44 });
45 })(django.jQuery);
46 </script>
47{% endblock %}
Тестирование массовых действий
1# your_app/tests/test_admin.py
2from django.test import TestCase, Client
3from django.contrib.auth.models import User
4from django.urls import reverse
5from .models import Book, Author, Category
6from .forms import BulkEditForm, ImportForm
7
8class BookAdminTest(TestCase):
9 def setUp(self):
10 self.client = Client()
11 self.admin_user = User.objects.create_superuser(
12 username='admin',
13 email='admin@example.com',
14 password='adminpass123'
15 )
16 self.client.login(username='admin', password='adminpass123')
17
18 self.category = Category.objects.create(
19 name='Техническая литература',
20 slug='tech'
21 )
22 self.author = Author.objects.create(
23 name='Тестовый автор',
24 bio='Биография тестового автора'
25 )
26 self.book = Book.objects.create(
27 title='Тестовая книга',
28 author=self.author,
29 category=self.category,
30 description='Описание тестовой книги',
31 price='29.99',
32 published=False
33 )
34
35 def test_make_published_action(self):
36 url = reverse('admin:your_app_book_changelist')
37 data = {
38 'action': 'make_published',
39 '_selected_action': [self.book.id],
40 }
41 response = self.client.post(url, data, follow=True)
42
43 self.assertEqual(response.status_code, 200)
44 self.book.refresh_from_db()
45 self.assertTrue(self.book.published)
46
47 def test_export_as_csv_action(self):
48 url = reverse('admin:your_app_book_changelist')
49 data = {
50 'action': 'export_as_csv',
51 '_selected_action': [self.book.id],
52 }
53 response = self.client.post(url, data)
54
55 self.assertEqual(response.status_code, 200)
56 self.assertEqual(response['Content-Type'], 'text/csv; charset=utf-8')
57 self.assertIn('Content-Disposition', response)
58
59 def test_bulk_edit_form(self):
60 form_data = {
61 'category': self.category.id,
62 'published': 'True',
63 'price_discount': '10',
64 }
65 form = BulkEditForm(data=form_data)
66 self.assertTrue(form.is_valid())
67
68 def test_import_form(self):
69 form_data = {
70 'update_existing': True,
71 'create_missing_authors': True,
72 'create_missing_categories': True,
73 }
74 form = ImportForm(data=form_data)
75 self.assertTrue(form.is_valid())
76
77 def test_bulk_edit_view(self):
78 url = reverse('admin:book_bulk_edit')
79 response = self.client.get(url)
80 self.assertEqual(response.status_code, 200)
81 self.assertContains(response, 'Массовое редактирование книг')
82
83 def test_import_view(self):
84 url = reverse('admin:book_import')
85 response = self.client.get(url)
86 self.assertEqual(response.status_code, 200)
87 self.assertContains(response, 'Импорт книг из CSV')
Лучшие практики
- Используй select_related и prefetch_related для оптимизации запросов к БД
- Добавляй подтверждения для критических действий через JavaScript
- Логируй массовые действия для аудита и отладки
- Ограничивай доступ к опасным действиям для обычных пользователей
- Используй транзакции для сложных массовых операций
- Добавляй прогресс-бары для длительных операций
- Валидируй данные перед массовым обновлением
- Создавай резервные копии перед массовым удалением
- Используй очереди для очень больших объемов данных
- Тестируй все действия с различными наборами данных
FAQ
Q: Как добавить подтверждение для массовых действий?
A: Используй JavaScript для показа confirm диалога или создай кастомную страницу подтверждения с формой.
Q: Как ограничить доступ к определенным действиям?
A: Переопредели метод get_actions в ModelAdmin и проверяй права пользователя для скрытия опасных действий.
Q: Как обрабатывать большие объемы данных?
A: Используй пагинацию, обрабатывай данные батчами, применяй Celery для фоновых задач.
Q: Как добавить кастомные поля в форму массового редактирования?
A: Создай кастомную форму, добавь её в ModelAdmin и создай отдельный view для обработки.
Q: Как экспортировать данные в различные форматы?
A: Создай отдельные действия для каждого формата (CSV, Excel, JSON) или используй библиотеки типа django-import-export.
Q: Как добавить логирование массовых действий?
A: Используй Django signals, middleware или логируй прямо в действиях через logging модуль.