Кастомизация Django админки
Django admin можно адаптировать под любые потребности проекта. Правильная настройка админки значительно упрощает работу администраторов и улучшает пользовательский опыт.
Базовая регистрация модели
1from django.contrib import admin
2from django.utils.html import format_html
3from django.urls import reverse
4from django.utils.safestring import mark_safe
5from django.contrib.admin import SimpleListFilter
6from django.db.models import Count, Sum, Avg
7from .models import Book, Author, Category, Review, Order
8import csv
9from django.http import HttpResponse
10
11@admin.register(Book)
12class BookAdmin(admin.ModelAdmin):
13 # Основные настройки отображения
14 list_display = [
15 'title', 'author', 'published_date', 'price',
16 'get_categories', 'review_count', 'total_rating', 'status'
17 ]
18
19 # Фильтры в правой панели
20 list_filter = [
21 'published_date', 'author', 'categories', 'status',
22 'created_at', 'is_featured'
23 ]
24
25 # Поля для поиска
26 search_fields = ['title', 'author__name', 'description', 'isbn']
27
28 # Фильтры по датам
29 date_hierarchy = 'published_date'
30
31 # Количество объектов на странице
32 list_per_page = 25
33
34 # Поля для редактирования в списке
35 list_editable = ['price', 'status', 'is_featured']
36
37 # Поля только для чтения
38 readonly_fields = ['created_at', 'updated_at', 'view_count']
39
40 # Группировка полей в форме
41 fieldsets = (
42 ('Основная информация', {
43 'fields': ('title', 'author', 'description', 'isbn')
44 }),
45 ('Детали', {
46 'fields': ('price', 'published_date', 'categories', 'status')
47 }),
48 ('Метаданные', {
49 'fields': ('is_featured', 'created_at', 'updated_at'),
50 'classes': ('collapse',)
51 }),
52 )
53
54 # Поля для создания/редактирования
55 add_fieldsets = (
56 (None, {
57 'classes': ('wide',),
58 'fields': ('title', 'author', 'description', 'price'),
59 }),
60 )
61
62 # Автозаполнение полей
63 autocomplete_fields = ['author', 'categories']
64
65 # Сырые ID поля для больших ForeignKey
66 raw_id_fields = ['author']
67
68 # Предзагрузка связанных объектов
69 list_select_related = ['author']
70 list_prefetch_related = ['categories']
71
72 def get_categories(self, obj):
73 """Отображение категорий в списке"""
74 return ", ".join([cat.name for cat in obj.categories.all()])
75 get_categories.short_description = 'Категории'
76 get_categories.admin_order_field = 'categories__name'
77
78 def review_count(self, obj):
79 """Количество отзывов"""
80 return obj.reviews.count()
81 review_count.short_description = 'Отзывы'
82 review_count.admin_order_field = 'reviews__count'
83
84 def total_rating(self, obj):
85 """Средний рейтинг"""
86 avg_rating = obj.reviews.aggregate(Avg('rating'))['rating__avg']
87 if avg_rating:
88 return f"{avg_rating:.1f}/5"
89 return "Нет оценок"
90 total_rating.short_description = 'Рейтинг'
91
92 def get_queryset(self, request):
93 """Оптимизация запросов"""
94 return super().get_queryset(request).annotate(
95 review_count=Count('reviews')
96 ).select_related('author').prefetch_related('categories')
97
98@admin.register(Author)
99class AuthorAdmin(admin.ModelAdmin):
100 list_display = ['name', 'email', 'book_count', 'total_books_sold', 'is_verified']
101 list_filter = ['is_verified', 'created_at', 'country']
102 search_fields = ['name', 'email', 'bio']
103
104 # Детальное отображение
105 fieldsets = (
106 ('Основная информация', {
107 'fields': ('name', 'email', 'bio', 'avatar')
108 }),
109 ('Статистика', {
110 'fields': ('total_books_sold', 'is_verified'),
111 'classes': ('collapse',)
112 }),
113 )
114
115 # Действия
116 actions = ['verify_authors', 'export_authors_csv', 'send_newsletter']
117
118 def book_count(self, obj):
119 """Количество книг автора"""
120 return obj.books.count()
121 book_count.short_description = 'Книг'
122
123 def total_books_sold(self, obj):
124 """Общее количество проданных книг"""
125 return obj.books.aggregate(Sum('copies_sold'))['copies_sold__sum'] or 0
126 total_books_sold.short_description = 'Продано книг'
127
128 def verify_authors(self, request, queryset):
129 """Верификация авторов"""
130 updated = queryset.update(is_verified=True)
131 self.message_user(
132 request,
133 f'{updated} авторов верифицировано'
134 )
135 verify_authors.short_description = "Верифицировать выбранных авторов"
136
137 def export_authors_csv(self, request, queryset):
138 """Экспорт авторов в CSV"""
139 response = HttpResponse(content_type='text/csv')
140 response['Content-Disposition'] = 'attachment; filename="authors.csv"'
141
142 writer = csv.writer(response)
143 writer.writerow(['Имя', 'Email', 'Книг', 'Верифицирован'])
144
145 for author in queryset:
146 writer.writerow([
147 author.name,
148 author.email,
149 author.books.count(),
150 'Да' if author.is_verified else 'Нет'
151 ])
152
153 return response
154 export_authors_csv.short_description = "Экспорт в CSV"
155
156 def send_newsletter(self, request, queryset):
157 """Отправка новостей авторам"""
158 # Логика отправки новостей
159 self.message_user(
160 request,
161 f'Новости отправлены {queryset.count()} авторам'
162 )
163 send_newsletter.short_description = "Отправить новости"
Кастомные фильтры
Фильтр по диапазону цен
1class PriceRangeFilter(SimpleListFilter):
2 """Фильтр по диапазону цен"""
3 title = 'Диапазон цен'
4 parameter_name = 'price_range'
5
6 def lookups(self, request, model_admin):
7 return (
8 ('low', 'До 500₽'),
9 ('medium', '500₽ - 1000₽'),
10 ('high', '1000₽ - 2000₽'),
11 ('premium', 'Более 2000₽'),
12 )
13
14 def queryset(self, request, queryset):
15 if self.value() == 'low':
16 return queryset.filter(price__lt=500)
17 elif self.value() == 'medium':
18 return queryset.filter(price__gte=500, price__lt=1000)
19 elif self.value() == 'high':
20 return queryset.filter(price__gte=1000, price__lt=2000)
21 elif self.value() == 'premium':
22 return queryset.filter(price__gte=2000)
23 return queryset
24
25# Фильтр по датам
26class DateRangeFilter(SimpleListFilter):
27 """Фильтр по диапазону дат"""
28 title = 'Период публикации'
29 parameter_name = 'date_range'
30
31 def lookups(self, request, model_admin):
32 return (
33 ('this_year', 'Этот год'),
34 ('last_year', 'Прошлый год'),
35 ('this_month', 'Этот месяц'),
36 ('last_month', 'Прошлый месяц'),
37 ('this_week', 'Эта неделя'),
38 )
39
40 def queryset(self, request, queryset):
41 from django.utils import timezone
42 from datetime import timedelta
43
44 now = timezone.now()
45
46 if self.value() == 'this_year':
47 return queryset.filter(published_date__year=now.year)
48 elif self.value() == 'last_year':
49 return queryset.filter(published_date__year=now.year - 1)
50 elif self.value() == 'this_month':
51 return queryset.filter(
52 published_date__year=now.year,
53 published_date__month=now.month
54 )
55 elif self.value() == 'last_month':
56 last_month = now - timedelta(days=30)
57 return queryset.filter(
58 published_date__year=last_month.year,
59 published_date__month=last_month.month
60 )
61 elif self.value() == 'this_week':
62 week_start = now - timedelta(days=now.weekday())
63 return queryset.filter(published_date__gte=week_start)
64
65 return queryset
66
67# Фильтр по связанным объектам
68class AuthorBookCountFilter(SimpleListFilter):
69 """Фильтр по количеству книг автора"""
70 title = 'Количество книг'
71 parameter_name = 'book_count'
72
73 def lookups(self, request, model_admin):
74 return (
75 ('0', 'Нет книг'),
76 ('1-5', '1-5 книг'),
77 ('6-10', '6-10 книг'),
78 ('11+', 'Более 10 книг'),
79 )
80
81 def queryset(self, request, queryset):
82 if self.value() == '0':
83 return queryset.annotate(
84 book_count=Count('books')
85 ).filter(book_count=0)
86 elif self.value() == '1-5':
87 return queryset.annotate(
88 book_count=Count('books')
89 ).filter(book_count__range=(1, 5))
90 elif self.value() == '6-10':
91 return queryset.annotate(
92 book_count=Count('books')
93 ).filter(book_count__range=(6, 10))
94 elif self.value() == '11+':
95 return queryset.annotate(
96 book_count=Count('books')
97 ).filter(book_count__gt=10)
98
99 return queryset
100
101# Применение фильтров
102@admin.register(Book)
103class BookAdmin(admin.ModelAdmin):
104 list_filter = [
105 PriceRangeFilter,
106 DateRangeFilter,
107 'author',
108 'categories',
109 'status'
110 ]
Кастомные действия
Массовые действия с подтверждением
1from django.contrib import messages
2from django.shortcuts import redirect
3from django.template.response import TemplateResponse
4
5class BookAdmin(admin.ModelAdmin):
6 actions = [
7 'bulk_publish', 'bulk_unpublish', 'bulk_change_category',
8 'export_selected', 'send_notifications'
9 ]
10
11 def bulk_publish(self, request, queryset):
12 """Массовая публикация книг"""
13 if 'apply' in request.POST:
14 # Подтверждение действия
15 updated = queryset.update(status='published')
16 self.message_user(
17 request,
18 f'{updated} книг опубликовано'
19 )
20 return None
21
22 # Показываем страницу подтверждения
23 context = {
24 'title': 'Подтверждение публикации',
25 'queryset': queryset,
26 'opts': self.model._meta,
27 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
28 }
29 return TemplateResponse(
30 request,
31 'admin/books/book/bulk_publish_confirmation.html',
32 context
33 )
34 bulk_publish.short_description = "Опубликовать выбранные книги"
35
36 def bulk_change_category(self, request, queryset):
37 """Массовое изменение категории"""
38 if 'apply' in request.POST:
39 category_id = request.POST.get('category')
40 if category_id:
41 category = Category.objects.get(id=category_id)
42 updated = queryset.update(categories=[category])
43 self.message_user(
44 request,
45 f'{updated} книг перемещено в категорию "{category.name}"'
46 )
47 return None
48
49 # Показываем форму выбора категории
50 categories = Category.objects.all()
51 context = {
52 'title': 'Выбор категории',
53 'queryset': queryset,
54 'categories': categories,
55 'opts': self.model._meta,
56 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
57 }
58 return TemplateResponse(
59 request,
60 'admin/books/book/bulk_change_category.html',
61 context
62 )
63 bulk_change_category.short_description = "Изменить категорию"
64
65 def export_selected(self, request, queryset):
66 """Экспорт выбранных книг"""
67 import json
68
69 response = HttpResponse(
70 content_type='application/json',
71 headers={'Content-Disposition': 'attachment; filename="books.json"'}
72 )
73
74 books_data = []
75 for book in queryset.select_related('author').prefetch_related('categories'):
76 books_data.append({
77 'title': book.title,
78 'author': book.author.name,
79 'price': str(book.price),
80 'categories': [cat.name for cat in book.categories.all()],
81 'published_date': book.published_date.isoformat() if book.published_date else None,
82 'status': book.status,
83 })
84
85 json.dump(books_data, response, ensure_ascii=False, indent=2)
86 return response
87 export_selected.short_description = "Экспорт в JSON"
88
89 def send_notifications(self, request, queryset):
90 """Отправка уведомлений о книгах"""
91 if 'apply' in request.POST:
92 notification_type = request.POST.get('notification_type')
93 message = request.POST.get('message', '')
94
95 # Логика отправки уведомлений
96 for book in queryset:
97 # Отправляем уведомления подписчикам
98 subscribers = book.author.subscribers.all()
99 for subscriber in subscribers:
100 # Здесь должна быть логика отправки email/SMS
101 pass
102
103 self.message_user(
104 request,
105 f'Уведомления отправлены для {queryset.count()} книг'
106 )
107 return None
108
109 # Показываем форму настройки уведомлений
110 context = {
111 'title': 'Настройка уведомлений',
112 'queryset': queryset,
113 'opts': self.model._meta,
114 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
115 }
116 return TemplateResponse(
117 request,
118 'admin/books/book/send_notifications.html',
119 context
120 )
121 send_notifications.short_description = "Отправить уведомления"
Кастомные поля и виджеты
Кастомное поле для отображения изображения
1class AdminImageWidget(admin.widgets.AdminFileWidget):
2 """Кастомный виджет для изображений в админке"""
3
4 def render(self, name, value, attrs=None, renderer=None):
5 output = []
6 if value and hasattr(value, "url"):
7 output.append(
8 f'<div class="admin-image-preview">'
9 f'<img src="{value.url}" style="max-width: 200px; max-height: 200px;" />'
10 f'</div>'
11 )
12 output.append(super().render(name, value, attrs, renderer))
13 return mark_safe(''.join(output))
14
15class AdminImageField(forms.ImageField):
16 """Кастомное поле для изображений"""
17 widget = AdminImageWidget
18
19# Использование в админке
20class BookAdmin(admin.ModelAdmin):
21 formfield_overrides = {
22 models.ImageField: {'widget': AdminImageWidget},
23 }
24
25# Кастомное поле для отображения связанных объектов
26class RelatedObjectsField:
27 """Кастомное поле для отображения связанных объектов"""
28
29 def __init__(self, field_name, model_admin, short_description):
30 self.field_name = field_name
31 self.model_admin = model_admin
32 self.short_description = short_description
33
34 def __call__(self, obj):
35 related_objects = getattr(obj, self.field_name).all()
36 if not related_objects:
37 return "Нет связанных объектов"
38
39 links = []
40 for related_obj in related_objects[:5]: # Показываем первые 5
41 url = reverse(
42 f'admin:{related_obj._meta.app_label}_{related_obj._meta.model_name}_change',
43 args=[related_obj.pk]
44 )
45 links.append(f'<a href="{url}">{related_obj}</a>')
46
47 if len(related_objects) > 5:
48 links.append(f'... и еще {len(related_objects) - 5}')
49
50 return mark_safe(', '.join(links))
51
52# Применение кастомного поля
53class BookAdmin(admin.ModelAdmin):
54 def get_list_display(self, request):
55 list_display = list(super().get_list_display(request))
56 list_display.append(RelatedObjectsField('reviews', self, 'Отзывы'))
57 return list_display
Кастомизация интерфейса
Кастомные CSS стили
1class BookAdmin(admin.ModelAdmin):
2 class Media:
3 css = {
4 'all': ('admin/css/book_admin.css',)
5 }
6 js = ('admin/js/book_admin.js',)
7
8# CSS файл: static/admin/css/book_admin.css
9.admin-image-preview {
10 margin-bottom: 10px;
11 padding: 10px;
12 border: 1px solid #ddd;
13 border-radius: 4px;
14 background-color: #f9f9f9;
15}
16
17.admin-image-preview img {
18 border-radius: 4px;
19 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
20}
21
22.field-status .status-published {
23 color: #28a745;
24 font-weight: bold;
25}
26
27.field-status .status-draft {
28 color: #6c757d;
29}
30
31.field-status .status-archived {
32 color: #dc3545;
33}
34
35.book-stats {
36 background: #f8f9fa;
37 padding: 15px;
38 border-radius: 4px;
39 margin: 10px 0;
40}
41
42.book-stats h3 {
43 margin-top: 0;
44 color: #495057;
45}
46
47.stat-item {
48 display: inline-block;
49 margin-right: 20px;
50 text-align: center;
51}
52
53.stat-value {
54 font-size: 24px;
55 font-weight: bold;
56 color: #007bff;
57}
58
59.stat-label {
60 font-size: 12px;
61 color: #6c757d;
62 text-transform: uppercase;
63}
64
65# JavaScript файл: static/admin/js/book_admin.js
66document.addEventListener('DOMContentLoaded', function() {
67 // Добавляем статистику в форму
68 const form = document.querySelector('#book_form');
69 if (form) {
70 const titleField = form.querySelector('#id_title');
71 if (titleField) {
72 const statsDiv = document.createElement('div');
73 statsDiv.className = 'book-stats';
74 statsDiv.innerHTML = `
75 <h3>Статистика книги</h3>
76 <div class="stat-item">
77 <div class="stat-value" id="char-count">0</div>
78 <div class="stat-label">Символов</div>
79 </div>
80 <div class="stat-item">
81 <div class="stat-value" id="word-count">0</div>
82 <div class="stat-label">Слов</div>
83 </div>
84 `;
85
86 titleField.parentNode.insertBefore(statsDiv, titleField.nextSibling);
87
88 // Обновляем статистику при вводе
89 titleField.addEventListener('input', function() {
90 const text = this.value;
91 document.getElementById('char-count').textContent = text.length;
92 document.getElementById('word-count').textContent = text.split(/\s+/).filter(word => word.length > 0).length;
93 });
94 }
95 }
96
97 // Кастомизация фильтров
98 const filters = document.querySelectorAll('.filter');
99 filters.forEach(filter => {
100 const filterTitle = filter.querySelector('h3');
101 if (filterTitle && filterTitle.textContent.includes('Статус')) {
102 const statusOptions = filter.querySelectorAll('input[type="checkbox"]');
103 statusOptions.forEach(option => {
104 const label = option.parentNode;
105 const statusClass = option.value;
106 label.classList.add(`status-${statusClass}`);
107 });
108 }
109 });
110});
Кастомные шаблоны админки
1<!-- Шаблон для подтверждения массовых действий -->
2<!-- templates/admin/books/book/bulk_publish_confirmation.html -->
3{% extends "admin/base_site.html" %}
4{% load i18n admin_urls static admin_list %}
5
6{% block content %}
7<div id="content-main">
8 <form action="" method="post">
9 {% csrf_token %}
10
11 <div class="module aligned">
12 <h2>Подтверждение публикации</h2>
13 <p>Вы собираетесь опубликовать следующие книги:</p>
14
15 <ul>
16 {% for book in queryset %}
17 <li>{{ book.title }} - {{ book.author.name }}</li>
18 {% endfor %}
19 </ul>
20
21 <p><strong>Всего книг: {{ queryset.count }}</strong></p>
22
23 <div class="submit-row">
24 <input type="submit" name="apply" value="Подтвердить публикацию" class="default" />
25 <a href="{% url 'admin:books_book_changelist' %}" class="button cancel-link">Отмена</a>
26 </div>
27 </div>
28 </form>
29</div>
30{% endblock %}
31
32<!-- Шаблон для изменения категории -->
33<!-- templates/admin/books/book/bulk_change_category.html -->
34{% extends "admin/base_site.html" %}
35{% load i18n admin_urls static admin_list %}
36
37{% block content %}
38<div id="content-main">
39 <form action="" method="post">
40 {% csrf_token %}
41
42 <div class="module aligned">
43 <h2>Изменение категории</h2>
44 <p>Выберите новую категорию для следующих книг:</p>
45
46 <ul>
47 {% for book in queryset %}
48 <li>{{ book.title }} - {{ book.author.name }}</li>
49 {% endfor %}
50 </ul>
51
52 <div class="form-row">
53 <label for="category">Новая категория:</label>
54 <select name="category" id="category" required>
55 <option value="">Выберите категорию</option>
56 {% for category in categories %}
57 <option value="{{ category.id }}">{{ category.name }}</option>
58 {% endfor %}
59 </select>
60 </div>
61
62 <div class="submit-row">
63 <input type="submit" name="apply" value="Изменить категорию" class="default" />
64 <a href="{% url 'admin:books_book_changelist' %}" class="button cancel-link">Отмена</a>
65 </div>
66 </div>
67 </form>
68</div>
69{% endblock %}
Оптимизация производительности
Оптимизация запросов
1class BookAdmin(admin.ModelAdmin):
2 # Предзагрузка связанных объектов
3 list_select_related = ['author']
4 list_prefetch_related = ['categories', 'reviews']
5
6 # Ограничение количества объектов на странице
7 list_per_page = 50
8
9 # Сырые ID поля для больших ForeignKey
10 raw_id_fields = ['author']
11
12 # Автозаполнение полей
13 autocomplete_fields = ['categories']
14
15 def get_queryset(self, request):
16 """Оптимизация запросов с аннотациями"""
17 return super().get_queryset(request).annotate(
18 review_count=Count('reviews'),
19 total_rating=Avg('reviews__rating'),
20 copies_sold=Sum('order_items__quantity')
21 ).select_related('author').prefetch_related(
22 'categories', 'reviews__user'
23 )
24
25# Кастомный admin site для оптимизации
26class OptimizedAdminSite(admin.AdminSite):
27 """Оптимизированный admin site"""
28
29 def get_app_list(self, request):
30 """Кэширование списка приложений"""
31 from django.core.cache import cache
32
33 cache_key = f'admin_app_list_{request.user.id}'
34 app_list = cache.get(cache_key)
35
36 if app_list is None:
37 app_list = super().get_app_list(request)
38 cache.set(cache_key, app_list, 300) # Кэш на 5 минут
39
40 return app_list
41
42# Регистрация оптимизированного admin site
43admin_site = OptimizedAdminSite(name='optimized_admin')
44admin_site.register(Book, BookAdmin)
45admin_site.register(Author, AuthorAdmin)
Лучшие практики
- Используй list_select_related и list_prefetch_related - для оптимизации запросов
- Создавай кастомные фильтры - для сложной фильтрации данных
- Используй массовые действия - для операций над множеством объектов
- Добавляй кастомные поля - для отображения вычисляемых значений
- Оптимизируй запросы - используй аннотации и агрегации
- Создавай понятные сообщения - для пользователей админки
- Используй кастомные шаблоны - для сложных форм и подтверждений
- Добавляй CSS и JavaScript - для улучшения интерфейса
Частые ошибки и их решения
Ошибка: "FieldError: Cannot resolve keyword"
1# Проблема: неправильное использование list_display
2# Неправильно
3list_display = ['title', 'author__name'] # Ошибка!
4
5# Правильно
6list_display = ['title', 'get_author_name']
7
8def get_author_name(self, obj):
9 return obj.author.name
10get_author_name.short_description = 'Автор'
11get_author_name.admin_order_field = 'author__name'
Ошибка: "N+1 queries"
1# Проблема: множественные запросы к базе данных
2# Решение: используй select_related и prefetch_related
3
4class BookAdmin(admin.ModelAdmin):
5 list_select_related = ['author'] # Для ForeignKey
6 list_prefetch_related = ['categories'] # Для ManyToMany
7
8 def get_queryset(self, request):
9 return super().get_queryset(request).select_related('author')
FAQ
Q: Как добавить кастомные CSS стили?
A: Создай static/admin/css/custom.css и добавь через Media класс в ModelAdmin.
Q: Можно ли создать кастомные действия с подтверждением?
A: Да, создай кастомный шаблон и используй TemplateResponse для показа страницы подтверждения.
Q: Как оптимизировать админку для больших объемов данных?
A: Используй list_select_related, prefetch_related, raw_id_fields и ограничивай количество объектов на странице.
Q: Можно ли добавить кастомные поля в список?
A: Да, создай методы в ModelAdmin и добавь их в list_display.
Q: Как создать кастомные фильтры?
A: Наследуйся от SimpleListFilter и реализуй методы lookups и queryset.
Q: Можно ли кастомизировать формы создания/редактирования?
A: Да, используй fieldsets, add_fieldsets и formfield_overrides для настройки форм.