Кастомизация 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 для настройки форм.