Массовые действия 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        &rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
 15        &rsaquo; <a href="{% url 'admin:your_app_book_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
 16        &rsaquo; Массовое редактирование
 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        &rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
 62        &rsaquo; <a href="{% url 'admin:your_app_book_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
 63        &rsaquo; Импорт
 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 модуль.