Django Admin + Unfold — Архітектура внутрішніх інструментів¶
Django Admin — це вбудований адмін-інтерфейс для управління моделями. Unfold — сучасний UI-шар поверх нього на Tailwind CSS. Цей файл охоплює стандартну адмінку Django і повну систему Unfold від базового налаштування до enterprise-архітектури.
Зміст¶
- 0. Що таке Django Admin і навіщо Unfold
- 1. AdminSite — координатор інтерфейсу
- 2. ModelAdmin — CRUD-генератор
- 3. Admin Request Lifecycle — від HTTP до БД
- 4. ChangeList — рушій списків
- 5. Query Architecture — оптимізація запитів
- 6. Admin Rendering Pipeline — шаблони і TemplateResponse
- 7. Unfold — встановлення і базова конфігурація
- 8. Sidebar + Navigation Architecture
- 9. Dashboard — layouts і DASHBOARD_CALLBACK
- 10. Widget Systems — Card, Chart, Tracker, Cohort
- 11. Information Hierarchy — Fieldset tabs, Conditional fields
- 12. Enterprise Patterns — паралельний UnfoldAdminSite, InfinitePaginator
- 13. Admin Anti-Patterns
- 14. Питання для самоперевірки
- 15. Advanced Filtering — AutocompleteSelectFilter, RangeDateTimeFilter
- 16. Bulk Actions і Dialog Actions — BaseDialogForm
- 17. Component Classes — @register_component
- 18. Custom Pages — UnfoldModelAdminViewMixin
- 19. Advanced Widgets — WysiwygWidget, ArrayWidget
- 20. Performance і Caching — cached.Loader, full-text search, Redis
- 21. Datasets — пов'язані записи у ChangeForm
- 22. Production Checklist
- 🧠 Ментальна модель: Django Admin — це LEGO-конструктор для внутрішніх інструментів. Кожна модель (Note, Category, User) — це набір деталей. Django автоматично будує з них повноцінний CRUD-інтерфейс: список, форму додавання, форму редагування, видалення. Unfold — це новий набір "лего" поверх старого: ті ж деталі, але сучасний дизайн і нові компоненти.
- 📚 Чому це існує: До Django Admin, кожен проект мав самостійно будувати адмін-панель: HTML-форми, валідацію, права доступу, пагінацію. Django Admin вирішив цю проблему раз і назавжди — автоматичний CRUD для будь-якої зареєстрованої моделі, з правами, аудитом і пошуком з коробки. Unfold вирішив проблему застарілого Bootstrap 3 UI — без зміни бізнес-логіки.
- 🌐 Що відбувається під капотом: При запуску Django,
apps.pyкожного встановленого додатку виконується.admin.autodiscover()знаходить і завантажуєadmin.pyу кожному додатку. Коженadmin.site.register(Model, ModelAdmin)реєструє модель у глобальномуAdminSiteсинглтоні. При запиті до/admin/Django викликаєAdminSite.urls, який динамічно генерує URL-маршрути для всіх зареєстрованих моделей. - ❌ Типова помилка початківця: Думати, що Django Admin — це для кінцевих користувачів. Адмінка призначена ВИКЛЮЧНО для внутрішньої команди: адміністраторів, редакторів, підтримки. Зовнішні користувачі повинні мати окремий інтерфейс на звичайних Django Views + Bootstrap. Використання адмінки як "фронтенду" — анти-патерн.
Орієнтація: Як читати цей документ¶
Перш ніж занурюватись у секції — зрозумій загальну архітектурну картину:
Весь Django Admin — це три шари поверх один одного:
┌─────────────────────────────────────────────────────┐
│ Твій код │
│ ModelAdmin, actions, inlines, custom views │
│ (§2, §4, §11, §16–18) │
├─────────────────────────────────────────────────────┤
│ Unfold Layer │
│ Tailwind UI, Dashboard, Sidebar, Components │
│ (§7–12, §15–21) │
├─────────────────────────────────────────────────────┤
│ django.contrib.admin │
│ AdminSite, ModelAdmin base, ChangeList, ORM │
│ (§1–6) │
├─────────────────────────────────────────────────────┤
│ Django Core │
│ ORM, Template Engine, Middleware, Auth │
└─────────────────────────────────────────────────────┘
Порядок читання для початківців:
- §0 → загальна картина, навіщо взагалі Admin
- §3 → Request Lifecycle (розумієш як Django обробляє запит до адмінки)
- §2 → ModelAdmin (основний інструмент, 80% часу тут)
- §5 → Query Architecture (N+1 — найпоширеніша проблема)
- §7 → Unfold встановлення (після розуміння основ)
- §9 → Dashboard (перша "вау" фіча)
- §13 → Anti-Patterns (читай до написання production коду)
- §22 → Checklist (перед деплоєм)
§0. Що таке Django Admin і навіщо Unfold¶
Django Admin — вбудований CRUD¶
Django автоматично генерує повноцінний адмін-інтерфейс для кожної зареєстрованої моделі:
URL /admin/ → Список додатків + моделей
→ Список записів (ChangeList)
→ Форма редагування (ChangeForm)
→ Форма додавання
→ Видалення з підтвердженням
# notes_app/admin.py — мінімальна реєстрація
from django.contrib import admin
from .models import Note
admin.site.register(Note)
# → /admin/notes_app/note/ список нотаток
# → /admin/notes_app/note/add/ нова нотатка
# → /admin/notes_app/note/42/change/ редагування
# → /admin/notes_app/note/42/delete/ видалення
Проблема стандартної адмінки¶
Стандартна Django Admin Unfold Admin
───────────────────────── ─────────────
Базовий Bootstrap 3 стиль → Tailwind CSS з темною темою
Плоский список моделей → Sidebar з ієрархією і іконками
Немає дашборду → Кастомна сторінка з Chart.js
Немає command palette → Глобальний пошук (Ctrl+K)
Прості текстові фільтри → Dropdown + date range фільтри
Немає WYSIWYG → TipTap / CKEditor 5 інтеграція
Немає умовних полів → Alpine.js conditional fields
Ключова філософія Unfold¶
Unfold не замінює django.contrib.admin — він розширює його.
Всі стандартні API (ModelAdmin, list_display, inlines) працюють без змін.
Unfold додає сучасний UI поверх існуючої архітектури.
django.contrib.admin (архітектура, ORM, permissions)
↑
Unfold (Tailwind UI, компоненти, dashboard)
↑
Ваш ModelAdmin (бізнес-логіка, кастомізація)
- 🧠 Ментальна модель:
AdminSite— це "головний офіс" твоєї адмін-панелі. Всі моделі "реєструються" в ньому як "відділи". Він контролює URL-маршрутизацію (які URL доступні), брендинг (назва, логотип), і права доступу (хто може увійти). БезAdminSite— немає адмінки. Один проект може мати кількаAdminSite— наприклад, окремий для операційної команди і окремий для аналітиків. - 📚 Чому це існує:
AdminSite— це Singleton (єдиний екземпляр). Django створюєadmin.siteавтоматично при старті. Тобі не потрібно його створювати — лише налаштовувати. Але якщо потрібна ізоляція (різні команди бачать різні моделі) — можна створити додатковіAdminSite. Це enterprise-паттерн для великих команд. - 🌐 Що відбувається під капотом:
admin.site.register(Note)додає класNoteAdminу внутрішній словник_registry: {Note: NoteAdmin}. Коли Django обробляє запит/admin/notes_app/note/, він шукає Note в_registry, отримуєNoteAdmin, і делегує обробку йому.each_context()викликається при КОЖНОМУ запиті до адмінки і додає глобальні змінні (назва сайту, права, повідомлення) у контекст шаблону. - ❌ Типова помилка початківця: Намагатися змінити
site_headerчерезUNFOLDsettings і не бачити змін — бо модульunfoldне стоїть першим уINSTALLED_APPS. Або реєструвати модель двічі: один раз черезadmin.site.register()і ще раз через@admin.register()— це викличеAlreadyRegisteredвиключення.
§1. AdminSite — координатор інтерфейсу¶
Що таке AdminSite¶
AdminSite — синглтон, що управляє:
- Реєстрацією моделей (register())
- URL-маршрутизацією (/admin/*)
- Глобальним брендингом (site_header, site_title)
- Контекстом шаблонів (each_context())
# Django автоматично створює глобальний екземпляр
from django.contrib import admin
admin.site.site_header = 'Bootstrap Notes Admin'
admin.site.site_title = 'Notes Portal'
admin.site.index_title = 'Управління нотатками'
Кілька AdminSite для різних ролей¶
# notes_app/admin_sites.py
from django.contrib.admin import AdminSite
class OperationsAdminSite(AdminSite):
"""Адмін-панель для операційної команди."""
site_header = 'Operations Panel'
site_title = 'Ops'
index_title = 'Операційний дашборд'
class AnalyticsAdminSite(AdminSite):
"""Read-only аналітична панель."""
site_header = 'Analytics'
site_title = 'Analytics'
def has_permission(self, request):
# Тільки staff, але без is_superuser вимоги
return request.user.is_active and request.user.is_staff
operations_admin = OperationsAdminSite(name='operations')
analytics_admin = AnalyticsAdminSite(name='analytics')
# urls.py
from notes_app.admin_sites import operations_admin, analytics_admin
urlpatterns = [
path('admin/', admin.site.urls), # стандартний
path('ops/', operations_admin.urls), # операційний
path('analytics/', analytics_admin.urls), # аналітичний
]
# notes_app/admin.py — реєстрація на кількох сайтах
from notes_app.admin_sites import operations_admin, analytics_admin
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
list_display = ['title', 'author', 'created_at']
# Додаткова реєстрація на operations_admin
operations_admin.register(Note, NoteAdmin)
analytics_admin.register(Note, NoteAdmin)
each_context() — ін'єкція глобальних змінних¶
class CustomAdminSite(AdminSite):
def each_context(self, request):
context = super().each_context(request)
# Додаємо глобальні змінні для всіх шаблонів цього сайту
context.update({
'company_name': 'Acme Corp',
'environment': settings.ENVIRONMENT,
'unread_alerts': SystemAlert.objects.filter(read=False).count(),
})
return context
- 🧠 Ментальна модель:
ModelAdmin— це декларативний "рецепт" для автоматичного CRUD. Як у поварській книзі: ти описуєш ЩО хочеш (list_display = ['title', 'author']), а Django сам готує (генерує SQL, HTML, форми, пагінацію). Ти не пишеш жодного HTML — тільки Python-атрибути. КласModelAdminє СИНГЛТОНОМ — один екземпляр обслуговує всі запити всіх користувачів одночасно. Це критично для розуміння анти-патернів. - 📚 Чому це існує: ModelAdmin відокремлює "що показувати" від "як показувати". Модель знає про дані (поля, валідацію, зв'язки). ModelAdmin знає про адмін-UX: які поля в списку, які фільтри, які дії. Якщо завтра дизайн адмінки зміниться — ти правиш ModelAdmin, а не модель. Якщо бізнес-логіка зміниться — ти правиш модель, а не ModelAdmin.
- 🌐 Що відбувається під капотом: При запиті до
/admin/notes_app/note/Django: (1) знаходитьNoteAdminв_registry, (2) викликаєNoteAdmin.changelist_view(request), (3) цей метод викликаєget_queryset(),get_list_display(),get_list_filter(), збирає контекст, (4) повертаєTemplateResponse('admin/change_list.html', context). Кожен атрибут (list_display,search_fieldsтощо) — це "інструкція" для відповідного методу. - ❌ Типова помилка початківця: Мутувати клас-атрибути в методах:
self.readonly_fields.append('title')— змінює поле для ВСІХ запитів і ВСІХ користувачів одночасно (бо ModelAdmin — синглтон). Завжди повертай нові списки:return list(self.readonly_fields) + ['title']. Ця помилка — одна з найважчих для діагностики (баг з'являється під навантаженням або у concurrency).
§2. ModelAdmin — CRUD-генератор¶
Декларативна конфігурація¶
# notes_app/admin.py
from django.contrib import admin
from django.utils.html import format_html
from .models import Note, Category
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
# ── Список записів ────────────────────────────────────────
list_display = ['title', 'status_badge', 'author', 'category', 'created_at']
list_display_links = ['title'] # клікабельні поля
list_editable = ['category'] # редагування прямо в списку
list_filter = ['status', 'category', 'created_at']
list_per_page = 25
list_select_related = ['author', 'category'] # уникнення N+1
search_fields = ['title', 'body', 'author__username']
ordering = ['-created_at']
date_hierarchy = 'created_at' # навігація по датах
# ── Форма редагування ─────────────────────────────────────
fields = ['title', 'body', 'category', 'status', 'is_public']
# або fieldsets для групування:
fieldsets = [
('Основна інформація', {
'fields': ['title', 'category', 'status'],
}),
('Зміст', {
'fields': ['body'],
}),
('Налаштування', {
'fields': ['is_public', 'author'],
'classes': ['collapse'], # згортається за замовчуванням
}),
]
readonly_fields = ['created_at', 'updated_at', 'slug']
autocomplete_fields = ['author'] # пошук через AJAX (потрібен search_fields на UserAdmin)
raw_id_fields = []
# ── Дії ───────────────────────────────────────────────────
actions = ['publish_selected', 'archive_selected']
# ── Inline ────────────────────────────────────────────────
inlines = [] # додається нижче
# ── Кастомне поле для list_display ───────────────────────
@admin.display(description='Статус', ordering='status')
def status_badge(self, obj):
colors = {
'draft': '#6c757d',
'published': '#198754',
'archived': '#ffc107',
}
color = colors.get(obj.status, '#0d6efd')
return format_html(
'<span style="background:{};color:white;padding:2px 8px;'
'border-radius:4px;font-size:12px;">{}</span>',
color,
obj.get_status_display()
)
# ── Дії ───────────────────────────────────────────────────
@admin.action(description='Опублікувати вибрані')
def publish_selected(self, request, queryset):
updated = queryset.update(status='published')
self.message_user(request, f'Опубліковано {updated} нотаток.')
@admin.action(description='Архівувати вибрані')
def archive_selected(self, request, queryset):
updated = queryset.update(status='archived')
self.message_user(
request,
f'Архівовано {updated} нотаток.',
level='warning'
)
Динамічна конфігурація через перевизначення методів¶
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
def get_queryset(self, request):
"""Обмежуємо відображення — кожен бачить тільки свої нотатки."""
qs = super().get_queryset(request)
if request.user.is_superuser:
return qs
return qs.filter(author=request.user)
def get_readonly_fields(self, request, obj=None):
"""Поле author тільки для читання при редагуванні."""
if obj: # existing object
return self.readonly_fields + ('author',)
return self.readonly_fields
def get_form(self, request, obj=None, **kwargs):
"""Динамічна форма залежно від ролі."""
form = super().get_form(request, obj, **kwargs)
if not request.user.is_superuser:
# Ховаємо поле is_public для звичайних адмінів
form.base_fields.pop('is_public', None)
return form
def save_model(self, request, obj, form, change):
"""Автоматично встановлюємо author при створенні."""
if not change: # новий об'єкт
obj.author = request.user
super().save_model(request, obj, form, change)
def has_delete_permission(self, request, obj=None):
"""Видалення тільки для суперюзерів."""
return request.user.is_superuser
Inline — вкладені форми¶
class CommentInline(admin.TabularInline):
model = Comment
fields = ['author', 'text', 'created_at']
readonly_fields = ['created_at']
extra = 1 # кількість порожніх форм
max_num = 10
can_delete = True
show_change_link = True # посилання на повну форму Comment
class AttachmentInline(admin.StackedInline):
model = Attachment
fields = ['file', 'description']
extra = 0
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
inlines = [CommentInline, AttachmentInline]
Кастомні URL у ModelAdmin¶
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
def get_urls(self):
from django.urls import path
urls = super().get_urls()
custom_urls = [
path(
'<int:pk>/export/',
self.admin_site.admin_view(self.export_view), # ← обов'язково
name='notes_app_note_export',
),
]
return custom_urls + urls
def export_view(self, request, pk):
note = get_object_or_404(Note, pk=pk)
# ... генерація PDF або CSV
response = HttpResponse(content_type='text/plain')
response['Content-Disposition'] = f'attachment; filename=note_{pk}.txt'
response.write(note.body)
return response
{# templates/admin/notes_app/note/change_form.html #}
{% extends "admin/change_form.html" %}
{% block object-tools-items %}
{{ block.super }}
<li>
<a href="{% url 'admin:notes_app_note_export' original.pk %}"
class="historylink">Експорт у .txt</a>
</li>
{% endblock %}
- 🧠 Ментальна модель: Admin Request Lifecycle — це конвеєр. Кожен запит до
/admin/note/42/change/проходить через фіксовану послідовність "станцій": автентифікація → авторизація → отримання об'єкта → перевірка прав → рендеринг форми (GET) або валідація + збереження (POST). Якщо будь-яка станція "відмовляє" — конвеєр зупиняється і повертає відповідну відповідь (403, 404, форму з помилками). - 📚 Чому це існує: Lifecycle чітко розділяє відповідальність: middleware не знає про моделі, ModelAdmin не знає про HTTP-деталі. Ти можеш перехопити будь-яку "станцію" конвеєра:
get_form()— для зміни форми,save_model()— для зміни логіки збереження,response_change()— для зміни редиректу. Не потрібно переписувати все — тільки override потрібної станції. - 🌐 Що відбувається під капотом:
has_change_permission()виконує SQL-запит для перевірки прав (черезContentTypeфреймворк).get_form()динамічно генеруєModelFormклас на льоту.save_model()викликаєobj.full_clean()(валідація полів) а потімobj.save()(INSERT або UPDATE в БД).log_change()записує рядок в таблицюdjango_admin_log— це вбудований аудит-лог. - ❌ Типова помилка початківця: Виконувати дорогі операції (відправка email, виклик зовнішнього API) прямо в
save_model()без try/except. Якщо email-сервер недоступний — адмінка "падає" при спробі збереження. Важкі side effects кращe виконувати в Celery task, який запускається зsave_model().
§3. Admin Request Lifecycle¶
HTTP Request /admin/notes_app/note/42/change/
│
▼
Middleware stack:
SessionMiddleware → завантажує сесію
AuthenticationMiddleware → request.user
MessageMiddleware → Django messages framework
│
▼
AdminSite.urls dispatch
│
▼
ModelAdmin.change_view(request, object_id)
│
├─ has_change_permission(request, obj) → 403 якщо False
│
├─ GET:
│ get_form() → ModelForm instance
│ get_readonly_fields()
│ get_fieldsets()
│ render TemplateResponse('admin/change_form.html', context)
│
└─ POST:
get_form() з request.POST
form.is_valid()
├─ False: рендерити форму з помилками
└─ True:
save_model(request, obj, form, change)
→ obj.save()
save_related(request, form, formsets, change)
→ зберегти inlines
log_change() → LogEntry
response_change() → HttpResponseRedirect
Перехоплення збереження¶
def save_model(self, request, obj, form, change):
"""Виконується безпосередньо перед obj.save()."""
if not change:
obj.created_by = request.user
obj.last_modified_by = request.user
super().save_model(request, obj, form, change)
def save_related(self, request, form, formsets, change):
"""Виконується після save_model, зберігає M2M і inlines."""
super().save_related(request, form, formsets, change)
# Тут obj вже збережений з ID
obj = form.instance
obj.process_tags() # наприклад
def response_change(self, request, obj):
"""Контроль редиректу після успішного збереження."""
if '_continue' not in request.POST and '_addanother' not in request.POST:
# Власний редирект замість стандартного
return redirect(reverse('admin:notes_app_note_changelist'))
return super().response_change(request, obj)
- 🧠 Ментальна модель:
ChangeList— це "менеджер списків". ЯкщоModelAdmin— це рецепт, тоChangeList— це кухар, який готує список об'єктів: застосовує фільтри, сортування, пагінацію, пошук. Django автоматично створює екземплярChangeListпри кожному запиті до списку. Якщо хочеш змінити поведінку списку — створи підкласChangeListі перевизнач потрібні методи. - 📚 Чому це існує:
ChangeListвідокремлює "логіку відображення списку" від "відображення форми редагування". Це дозволяє незалежно оптимізувати кожну частину: швидкий список → агрегації і индекси; повільна форма → не проблема для списку.ShowFacets(Django 5.0+) — конкретний приклад: додаєCOUNT(*)для кожного фільтра, щоб показати скільки записів відповідає. - 🌐 Що відбувається під капотом:
ChangeList.get_queryset()будує queryset поетапно: (1) бере базовий queryset відModelAdmin.get_queryset(), (2) застосовуєsearch_fields(додаєWHERE title ILIKE '%query%'), (3) застосовуєlist_filter(додаєWHERE status='draft'), (4) застосовуєdate_hierarchy(WHERE created_at BETWEEN ...), (5) застосовує сортування (ORDER BY), (6) обрізає для пагінації (LIMIT/OFFSET). Кожен крок — окремий.filter()або.order_by()на QuerySet. - ❌ Типова помилка початківця: Додавати
annotate()уModelAdmin.get_queryset()але забути додати відповідне поле уlist_displayчерез кастомний метод з@admin.display(ordering='annotated_field'). Безorderingна колонці — клік по заголовку колонки не сортуватиме.
§4. ChangeList — рушій списків¶
from django.contrib.admin.views.main import ChangeList
class NoteChangeList(ChangeList):
"""Кастомний ChangeList з додатковою агрегацією."""
def get_queryset(self, request):
qs = super().get_queryset(request)
# Annotate для використання в list_display
from django.db.models import Count
return qs.annotate(comments_count=Count('comments'))
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
def get_changelist(self, request, **kwargs):
return NoteChangeList
@admin.display(description='Коментарі', ordering='comments_count')
def comments_count(self, obj):
return obj.comments_count
Show Facets (Django 5.0+)¶
from django.contrib.admin import ModelAdmin, ShowFacets
@admin.register(Note)
class NoteAdmin(ModelAdmin):
list_filter = ['status', 'category']
show_facets = ShowFacets.ALWAYS
# → біля кожного фільтра показує "(23)" — кількість записів
# ShowFacets.ALLOW (за запитом) | ShowFacets.ALWAYS | ShowFacets.NEVER
- 🧠 Ментальна модель: N+1 проблема в адмінці — це "ефект сніжної кулі". Для сторінки зі списком 25 нотаток Django виконує 1 запит для самих нотаток, а потім ще 25 запитів для авторів і ще 25 для категорій — разом 51 запит замість 1.
list_select_related— це "зупинити сніжну кулю": однимJOINотримати все разом. Це найлегша оптимізація з найбільшим ефектом у Django Admin. - 📚 Чому це існує: QuerySet в Django — ледачий (lazy).
note.authorне виконує SQL відразу — він очікує на перший доступ. Коли шаблон рендерить{{ note.author.username }}для кожної нотатки — виконується окремий SQL.select_relatedіprefetch_relatedкажуть ORM: "зроби JOIN заздалегідь, я знаю що буду звертатися до цих даних". - 🌐 Що відбувається під капотом:
list_select_related = ['author', 'category']→ Django додаєSELECT notes.*, users.*, categories.* FROM notes LEFT JOIN users ON ... LEFT JOIN categories ON .... Один SQL замість 1+N.prefetch_related('tags')— для M2M не можна JOIN (дублювання рядків), тому Django виконує два SQL: один для нотаток, один для тегів, і Python об'єднує їх у пам'яті. - ❌ Типова помилка початківця:
list_select_related = True(без списку) — Django робить JOIN для ВСІХ ForeignKey у всіх глибинах. Для складних моделей це може бути повільніше ніж N+1. Завжди явно вказуй які поля потрібні:list_select_related = ['author', 'category'].
§5. Query Architecture¶
N+1 у list_display¶
# ❌ N+1 — для кожної нотатки окремий запит на author і category
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
list_display = ['title', 'author_name', 'category_name']
def author_name(self, obj):
return obj.author.username # SELECT author WHERE id=...
def category_name(self, obj):
return obj.category.name # SELECT category WHERE id=...
# ✅ Виправлення через list_select_related
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
list_display = ['title', 'author_name', 'category_name']
list_select_related = ['author', 'category'] # JOIN в одному запиті
def author_name(self, obj):
return obj.author.username # вже в кеші
def category_name(self, obj):
return obj.category.name # вже в кеші
Оптимізація search_fields¶
# ❌ Небезпечний пошук — LIKE на TEXT полі + FK traversal
search_fields = ['body', 'author__profile__bio']
# → WHERE body LIKE '%query%' OR profile.bio LIKE '%query%'
# → Full scan на великих таблицях + JOIN
# ✅ Безпечний пошук
search_fields = ['^title', '=author__username']
# ^ — startswith (використовує індекс!)
# = — exact match
# @ — full-text search (тільки MySQL/PostgreSQL)
# (без префікса) — icontains (повільно, але flexible)
get_search_results() — кастомний пошук¶
def get_search_results(self, request, queryset, search_term):
queryset, use_distinct = super().get_search_results(
request, queryset, search_term
)
# Додаємо пошук по тегах
if search_term:
queryset |= self.model.objects.filter(
tags__name__icontains=search_term
)
return queryset, use_distinct
prefetch_related для M2M в list_display¶
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
list_display = ['title', 'tag_list']
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('tags')
def tag_list(self, obj):
return ', '.join(tag.name for tag in obj.tags.all())
tag_list.short_description = 'Теги'
- 🧠 Ментальна модель: Admin Rendering Pipeline — це фотозбірник.
ModelAdmin.changelist_view()збирає "фотографії" (context: queryset, фільтри, пагінацію), але ще не друкує їх.TemplateResponseзберігає ці "фотографії" у незрендерованому стані. Middleware може додати свої "фотографії" до альбому. Тільки наприкінці Django "друкує" готову книжку (компілює HTML). - 📚 Чому це існує: Ієрархія шаблонів дозволяє override на різних рівнях деталізації: хочеш змінити щось для ВСІХ моделей — правиш
templates/admin/change_list.html. Хочеш змінити для всіх моделей конкретного додатку — правишtemplates/admin/notes_app/change_list.html. Хочеш змінити тільки дляNote— правишtemplates/admin/notes_app/note/change_list.html. Django шукає від найспецифічнішого до найзагальнішого. - 🌐 Що відбувається під капотом: Django Template Engine при рендерингу адмін-шаблону запитує
TemplateEngine.select_template(['admin/notes_app/note/change_list.html', 'admin/notes_app/change_list.html', 'admin/change_list.html']). Він перевіряє файлову систему для кожного варіанту по черзі і повертає перший знайдений. Це відбувається при КОЖНОМУ запиті (або з кешу приcached.Loader). - ❌ Типова помилка початківця: Створити файл
templates/admin/change_list.htmlз власним контентом і забути написати{% extends "admin/change_list.html" %}всередині — Django прочитає ТВІЙ файл (найбільш специфічний) і не знайде жодної базової структури. Адмінка відображатиме порожню сторінку або ламатиметься. ЗАВЖДИ{% extends %}+{% block %}+{{ block.super }}.
§6. Admin Rendering Pipeline¶
TemplateResponse і пізня компіляція¶
ModelAdmin.changelist_view()
│
└─ return TemplateResponse(
request,
'admin/change_list.html',
context
)
│
│ ← Response middleware може змінити context_data тут
│
▼
TemplateResponse.render() (викликається Django)
│
└─ Template Engine → HTML string → HttpResponse
def changelist_view(self, request, extra_context=None):
# Додаємо кастомний контекст
extra_context = extra_context or {}
extra_context['total_published'] = Note.objects.filter(
status='published'
).count()
return super().changelist_view(request, extra_context=extra_context)
Ієрархія шаблонів¶
Django шукає шаблони в такому порядку:
1. templates/admin/<app>/<model>/change_list.html (найспецифічніший)
2. templates/admin/<app>/change_list.html
3. templates/admin/change_list.html (глобальний)
4. django/contrib/admin/templates/admin/change_list.html (вбудований)
Приклад для Note:
templates/
└── admin/
├── base_site.html ← глобальний брендинг
├── change_list.html ← для всіх моделей
└── notes_app/
├── change_list.html ← для всіх моделей notes_app
└── note/
├── change_list.html ← тільки для Note
└── change_form.html
{# templates/admin/base_site.html — ребрендинг адмінки #}
{% extends "admin/base.html" %}
{% block branding %}
<h1 id="site-name">
<a href="{% url 'admin:index' %}">
🗒️ {{ site_header }}
</a>
</h1>
{% endblock %}
{% block extrastyle %}
{{ block.super }}
<style>
#header { background: #1e293b; }
.module h2 { background: #334155; }
</style>
{% endblock %}
- 🧠 Ментальна модель: Встановлення Unfold — це "косметичний ремонт" Django Admin. Ти не зносиш стіни (не змінюєш ModelAdmin, ORM, права доступу) — ти лише фарбуєш стіни і міняєш меблі (CSS/JS шаблони). Весь код
ModelAdminякий ти написав — продовжує працювати. Unfold просто замінює шаблони і стилі поверх. - 📚 Чому
unfoldПЕРШИЙ уINSTALLED_APPS: Django шукає шаблони вINSTALLED_APPSпо порядку. Якщоunfoldстоїть післяdjango.contrib.admin— Django знаходить стандартні admin шаблони першими і НІКОЛИ не доходить до Unfold шаблонів.unfoldПОВИНЕН бути першим, щоб його шаблони мали пріоритет над стандартними. - 🌐 Що відбувається під капотом: Unfold замінює стандартні admin шаблони своїми (у своїй
templates/admin/директорії). Його шаблони розширюють ({% extends %}) оригінальні Django admin шаблони і перевизначають CSS/JS блоки для підключення Tailwind CSS.UnfoldModelAdmin(підкласModelAdmin) додає нові атрибути (list_fullwidth,warn_unsaved_formтощо), але не ламає стандартні. - ❌ Типова помилка початківця: Встановити Unfold але не перемкнути
ModelAdminнаUnfoldModelAdmin. Базові стилі зміняться (бо шаблони Unfold завантажаться), але нові функції (list_filter_submit,warn_unsaved_form) не працюватимуть — вони реалізовані вUnfoldModelAdmin, а не в стандартномуModelAdmin.
§7. Unfold — встановлення і базова конфігурація¶
Встановлення¶
# settings.py — Unfold ПЕРЕД django.contrib.admin
INSTALLED_APPS = [
'unfold', # ← обов'язково перший
'unfold.contrib.filters', # розширені фільтри
'unfold.contrib.forms', # стилізовані форми
'unfold.contrib.inlines', # сортовані inlines
'unfold.contrib.import_export', # якщо використовуєте django-import-export
'django.contrib.admin',
'django.contrib.auth',
...
]
Базова конфігурація UNFOLD¶
# settings.py
from django.templatetags.static import static
from django.urls import reverse_lazy
from unfold.enums import BadgeType
UNFOLD = {
# ── Брендинг ──────────────────────────────────────────────
"SITE_TITLE": "Bootstrap Notes",
"SITE_HEADER": "Notes Admin",
"SITE_URL": "/",
"SITE_ICON": lambda request: static("notes_app/img/logo.png"),
"SITE_LOGO": lambda request: static("notes_app/img/logo-full.png"),
"SITE_SYMBOL": "notes", # Material Symbol іконка
# ── Кольори (CSS змінні) ───────────────────────────────────
"COLORS": {
"primary": {
"50": "240 249 255", # RGB без #
"100": "224 242 254",
"500": "14 165 233", # --un-color-primary-500
"900": "12 74 110",
},
},
# ── Теми ──────────────────────────────────────────────────
"THEME": "dark", # "dark" | "light" | None (авто)
# ── Dashboard ─────────────────────────────────────────────
"DASHBOARD_CALLBACK": "notes_app.admin_dashboard.dashboard_callback",
# ── Sidebar навігація ─────────────────────────────────────
"SIDEBAR": {
"show_search": True,
"show_all_applications": True,
"navigation": [
{
"title": "Нотатки",
"separator": True,
"collapsible": True,
"items": [
{
"title": "Всі нотатки",
"icon": "description",
"link": reverse_lazy("admin:notes_app_note_changelist"),
"badge": "notes_app.admin_callbacks.get_draft_count",
"badge_type": BadgeType.WARNING,
},
{
"title": "Категорії",
"icon": "label",
"link": reverse_lazy("admin:notes_app_category_changelist"),
},
],
},
{
"title": "Користувачі",
"separator": True,
"items": [
{
"title": "Користувачі",
"icon": "people",
"link": reverse_lazy("admin:auth_user_changelist"),
},
],
},
],
},
# ── Глобальне dropdown-меню ───────────────────────────────
"SITE_DROPDOWN": [
{
"icon": "diamond",
"title": "Продакшн",
"link": "https://notes.example.com",
},
{
"icon": "code",
"title": "Staging",
"link": "https://staging.notes.example.com",
},
],
# ── Tabs (вкладки для форм і списків) ─────────────────────
"TABS": [
{
"models": ["notes_app.note", "notes_app.comment"],
"items": [
{
"title": "Нотатки",
"icon": "description",
"link": reverse_lazy("admin:notes_app_note_changelist"),
},
{
"title": "Коментарі",
"icon": "comment",
"link": reverse_lazy("admin:notes_app_comment_changelist"),
},
],
},
],
}
UnfoldModelAdmin — базовий клас Unfold¶
# notes_app/admin.py
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from django.contrib import admin
from .models import Note
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
# Всі стандартні атрибути Django Admin працюють
list_display = ['title', 'status', 'author', 'created_at']
list_filter = ['status', 'category']
search_fields = ['^title', '=author__username']
# Unfold-специфічні атрибути
list_fullwidth = True # список на повну ширину
warn_unsaved_form = True # попередження при виході без збереження
list_filter_submit = True # кнопка "Застосувати" у фільтрах
- 🧠 Ментальна модель: Sidebar навігація в Unfold — це "меню ресторану" яке ти пишеш вручну у
settings.py. Стандартна Django Admin автоматично генерує меню з усіх зареєстрованих моделей (як "шведський стіл" — все підряд). Unfold дозволяє вручну організувати навігацію: групи, іконки, значки-лічильники (badges), колапсовані секції. Повний контроль за ціну більшої конфігурації. - 📚 Чому
reverse_lazyа неreverse: Sidebar конфігурація уsettings.pyвиконується при завантаженні модуля — до того як Django повністю ініціалізував URL-маршрути.reverse('admin:notes_app_note_changelist')в цей момент викинеNoReverseMatch.reverse_lazy— це "відкладений reverse", який виконується тільки при першому зверненні (вже після повної ініціалізації). - 🌐 Що відбувається під капотом: Sidebar badge callback (
"notes_app.admin_callbacks.get_draft_count") — це рядковий шлях до Python-функції. Unfold динамічно імпортує і викликає цю функцію при КОЖНОМУ рендерингу сайдбару (при кожному запиті). Якщо badge callback повільний (складний SQL) — він уповільнить кожну сторінку адмінки. Кешуй результат якщо можливо. - ❌ Типова помилка початківця: Писати
"link": reverse("admin:notes_app_note_changelist")(без_lazy) уsettings.pyна рівні модуля — і отримуватиImproperlyConfigured: The included URLconf ... does not appear to have any patterns. Завждиreverse_lazyуsettings.py,UNFOLDсловнику, і будь-якому місці де URL генерується до повного завантаження Django.
§8. Sidebar + Navigation Architecture¶
Динамічний badge через callback¶
# notes_app/admin_callbacks.py
def get_draft_count(request):
"""Повертає кількість чернеток для badge у sidebar."""
from .models import Note
count = Note.objects.filter(status='draft').count()
if count == 0:
return None
return str(count)
# settings.py — UNFOLD → SIDEBAR → items
{
"title": "Нотатки",
"icon": "description",
"link": reverse_lazy("admin:notes_app_note_changelist"),
"badge": "notes_app.admin_callbacks.get_draft_count",
"badge_type": BadgeType.WARNING, # INFO, SUCCESS, WARNING, DANGER
}
Tabs — вкладки для навігації між моделями¶
Changelist tabs: Changeform tabs:
┌──────────┬────────────┐ ┌──────────┬──────────┬──────────┐
│ Нотатки │ Коментарі │ │ Основне │ SEO │ Медіа │
└──────────┴────────────┘ └──────────┴──────────┴──────────┘
[список нотаток] [форма редагування з вкладками]
# settings.py — статичні tabs
"TABS": [
{
"models": ["notes_app.note", "notes_app.comment", "notes_app.category"],
"items": [
{
"title": "Нотатки",
"icon": "description",
"link": reverse_lazy("admin:notes_app_note_changelist"),
},
{
"title": "Коментарі",
"icon": "comment",
"link": reverse_lazy("admin:notes_app_comment_changelist"),
},
{
"title": "Категорії",
"icon": "label",
"link": reverse_lazy("admin:notes_app_category_changelist"),
},
],
},
],
Динамічні вкладки (callback)¶
# notes_app/admin_callbacks.py
def note_tabs_callback(request):
"""Динамічні вкладки залежно від прав користувача."""
tabs = [
{
"title": "Мої нотатки",
"icon": "person",
"link": f"{reverse('admin:notes_app_note_changelist')}?author={request.user.pk}",
},
]
if request.user.is_superuser:
tabs.append({
"title": "Всі нотатки",
"icon": "groups",
"link": reverse("admin:notes_app_note_changelist"),
})
return tabs
# settings.py
"TABS": [
{
"models": ["notes_app.note"],
"items": "notes_app.admin_callbacks.note_tabs_callback",
},
],
- 🧠 Ментальна модель: Django Admin Dashboard — це "порожній холст". Стандартна адмінка показує простий список додатків/моделей.
DASHBOARD_CALLBACK— це твій "художник": він отримує порожній контекст і малює на ньому статистику, графіки, картки. Потім Unfold "вішає" цю картину у шаблоніadmin/index.html. - 📚 Чому callback замість context processor: Context processor виконується для КОЖНОГО запиту до всієї адмінки. Dashboard дані потрібні тільки на головній сторінці.
DASHBOARD_CALLBACKвикликається тільки при відкритті/admin/— економія ресурсів. Також це явна ін'єкція даних: ти точно знаєш що і коли виконується. - 🌐 Що відбувається під капотом: При запиті до
/admin/Unfold викликає функцію зDASHBOARD_CALLBACK. Ця функція отримує(request, context)— деcontextвже містить стандартні Django admin дані. Твоя функція МУТУЄcontextсловник (context['stats'] = {...}) і повертає його. Потім Unfold рендеритьadmin/index.htmlз цим збагаченим контекстом. Шаблонindex.htmlуtemplates/admin/замінює стандартний. - ❌ Типова помилка початківця: Забути кешування в
DASHBOARD_CALLBACK. Якщо функція виконує 5 складних SQL-запитів, вона буде їх виконувати при КОЖНОМУ завантаженні дашборду (кожен раз коли хтось відкриває/admin/). Завжди використовуйcache.get/setдля агрегованих статистик із TTL 60-300 секунд.
§9. Dashboard — layouts і DASHBOARD_CALLBACK¶
Кастомний index.html¶
# settings.py — вказуємо callback
"DASHBOARD_CALLBACK": "notes_app.admin_dashboard.dashboard_callback",
# notes_app/admin_dashboard.py
from django.db.models import Count
from django.db.models.functions import TruncDate
from .models import Note
def dashboard_callback(request, context):
"""
Ін'єкція даних у шаблон admin/index.html.
Виконується при кожному завантаженні дашборду.
"""
# Статистика
context['stats'] = {
'total_notes': Note.objects.count(),
'published': Note.objects.filter(status='published').count(),
'drafts': Note.objects.filter(status='draft').count(),
'today': Note.objects.filter(
created_at__date=timezone.now().date()
).count(),
}
# Дані для графіка (останні 14 днів)
two_weeks_ago = timezone.now() - timedelta(days=14)
daily_counts = (
Note.objects
.filter(created_at__gte=two_weeks_ago)
.annotate(date=TruncDate('created_at'))
.values('date')
.annotate(count=Count('id'))
.order_by('date')
)
context['chart_data'] = {
'labels': [str(row['date']) for row in daily_counts],
'datasets': [{
'label': 'Нових нотаток',
'data': [row['count'] for row in daily_counts],
}],
}
return context
Шаблон дашборду¶
{# templates/admin/index.html #}
{% extends "unfold/layouts/base_simple.html" %}
{% load i18n unfold %}
{% block content %}
<div class="flex flex-col gap-6">
{# ── Stats Cards ──────────────────────────── #}
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
{% component "unfold/components/card.html" with
title="Всього нотаток"
value=stats.total_notes
icon="description"
footer="За весь час"
%}{% endcomponent %}
{% component "unfold/components/card.html" with
title="Опубліковано"
value=stats.published
icon="check_circle"
class="bg-green-50"
%}{% endcomponent %}
{% component "unfold/components/card.html" with
title="Чернетки"
value=stats.drafts
icon="edit_note"
class="bg-yellow-50"
%}{% endcomponent %}
{% component "unfold/components/card.html" with
title="Сьогодні"
value=stats.today
icon="today"
%}{% endcomponent %}
</div>
{# ── Chart ────────────────────────────────── #}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{% component "unfold/components/chart/line.html" with
title="Нові нотатки (14 днів)"
data=chart_data
%}{% endcomponent %}
{# Tracker — активність #}
{% component "unfold/components/tracker.html" with
title="Активність за рік"
data=tracker_data
%}{% endcomponent %}
</div>
</div>
{% endblock %}
- 🧠 Ментальна модель: Unfold Widgets — це "набір меблів преміум-класу" для дашборду. Card — це вітрина (одне число + контекст). Chart — це графік (Chart.js під капотом). Tracker — це теплова карта GitHub-активності. Cohort — це таблиця утримання (retention analysis). Кожен виджет — це Unfold компонент, що приймає Python-dict і рендерить відповідний HTML+JS.
- 📚 Чому компоненти замість прямого HTML: Unfold
{% component %}тег гарантує консистентний стиль (темна тема, кольори, відступи) без дублювання CSS. Ти передаєш дані черезwith key=value, компонент сам знає як їх відобразити. При оновленні Unfold — стиль компонентів оновлюється автоматично. - 🌐 Що відбувається під капотом:
{% component "unfold/components/chart/line.html" with data=chart_data %}— це Django template tag{% include %}з додатковою логікою. Unfold рендерить частковий шаблон і ін'єктує в нього Python-словникchart_data. Шаблон Chart генерує<canvas>тег і<script>зnew Chart(ctx, {{ data|json_script }}). Chart.js (JavaScript бібліотека) підхоплює<canvas>і малює графік у браузері. - ❌ Типова помилка початківця: Передавати в
dataChart компонента Python QuerySet напряму. Unfold очікує Python dict з ключамиlabelsіdatasets. QuerySet потрібно попередньо перетворити в словник уdashboard_callback. Якщо цього не зробити — шаблон кинеTypeErrorабо відобразить порожній графік.
§10. Widget Systems¶
Card — базовий контейнер¶
# context для card
{
"title": "Нові реєстрації",
"metric": "1,284",
"footer": "+12% цього тижня",
"icon": "person_add",
"label": "Зростання",
"label_value": "+12%",
}
{% component "unfold/components/card.html" with
title="Нові реєстрації"
metric="1,284"
icon="person_add"
footer="+12% цього тижня"
%}{% endcomponent %}
Chart — Chart.js інтеграція¶
# Лінійний графік
chart_data = {
"labels": ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Нд"],
"datasets": [
{
"label": "Нотатки",
"data": [12, 19, 8, 15, 22, 7, 11],
"borderColor": "rgb(14, 165, 233)",
"backgroundColor": "rgba(14, 165, 233, 0.1)",
"fill": True,
},
{
"label": "Коментарі",
"data": [5, 8, 3, 7, 10, 2, 6],
"borderColor": "rgb(168, 85, 247)",
},
],
}
{# Line chart #}
{% component "unfold/components/chart/line.html" with
title="Активність за тиждень"
data=chart_data
height=300
%}{% endcomponent %}
{# Bar chart #}
{% component "unfold/components/chart/bar.html" with
title="Нотатки по категоріях"
data=bar_chart_data
%}{% endcomponent %}
Tracker — теплова карта активності¶
# Генерація даних для Tracker (схоже на GitHub contributions)
from datetime import date, timedelta
import random
def get_tracker_data():
today = date.today()
data = []
for i in range(365):
day = today - timedelta(days=i)
count = Note.objects.filter(created_at__date=day).count()
data.append({
"date": str(day),
"level": min(count, 4), # 0-4 рівні інтенсивності
"tooltip": f"{count} нотаток {day.strftime('%d.%m.%Y')}",
})
return list(reversed(data))
{% component "unfold/components/tracker.html" with
title="Активність за рік"
data=tracker_data
%}{% endcomponent %}
Cohort — таблиця утримання¶
# Когортний аналіз — наприклад, утримання користувачів
cohort_data = {
"columns": ["Тиждень 1", "Тиждень 2", "Тиждень 3", "Тиждень 4"],
"rows": [
{"label": "Лютий 2025", "values": [100, 72, 58, 45]},
{"label": "Березень 2025", "values": [100, 68, 51, None]},
{"label": "Квітень 2025", "values": [100, 71, None, None]},
],
}
{% component "unfold/components/cohort.html" with
title="Утримання користувачів"
data=cohort_data
%}{% endcomponent %}
- 🧠 Ментальна модель: Information Hierarchy — це архітектура уваги. Коли форма редагування має 20+ полів — користувач губиться. Fieldset Tabs розбивають поля на логічні групи (вкладки), показуючи тільки одну групу за раз. Conditional Fields ховають поля, що не стосуються поточного стану (наприклад,
visible_untilпоказується тільки якщо status='scheduled'). Sortable Inlines дозволяють перетягувати рядки мишею — без додаткового JavaScript коду. - 📚 Чому Alpine.js для conditional fields: Alpine.js — це "мінімалістичний React" (14KB), вбудований в Unfold. Він дозволяє реактивно показувати/ховати DOM-елементи через
x-show="condition"без написання власного JavaScript.x-dataвизначає reactive state,x-on:changeоновлює state при події. Браузер обробляє показ/приховування миттєво без запиту на сервер. - 🌐 Що відбувається під капотом: Fieldset Tabs в Unfold реалізовані через
'tab': Trueу fieldsets. Unfold рендерить вкладки як<nav>з JavaScript перемикачем. При кліку на вкладку — Alpine.js показує відповідний fieldset і ховає інші. Дані всіх вкладок надсилаються разом при POST-збереженні (не тільки активної вкладки). Sortable Inlines використовують SortableJS (JavaScript) для drag-and-drop, зберігаючи порядок у hidden input. - ❌ Типова помилка початківця: Додавати
'tab': Trueне до всіх fieldsets — деякі fields опиняться поза вкладками і будуть відображатися внизу сторінки в "сирому" вигляді. Або забути встановитиfrom unfold.admin import ModelAdmin as UnfoldModelAdmin— fieldset tabs не будуть рендеруватися стандартнимModelAdmin.
§11. Information Hierarchy¶
Fieldset Tabs — вкладки у формі¶
from unfold.admin import ModelAdmin as UnfoldModelAdmin
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
fieldsets = [
(
'Основна інформація',
{
'tab': True, # ← перша вкладка
'fields': ['title', 'body', 'category'],
}
),
(
'SEO',
{
'tab': True, # ← друга вкладка
'fields': ['slug', 'meta_title', 'meta_description'],
}
),
(
'Публікація',
{
'tab': True, # ← третя вкладка
'fields': ['status', 'published_at', 'is_public'],
}
),
]
Conditional Fields — Alpine.js¶
# Поле visible_until показується тільки якщо status == 'scheduled'
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
fieldsets = [
(
None,
{
'fields': [
'title',
'status',
'visible_until', # умовне поле
],
}
),
]
{# templates/admin/notes_app/note/change_form.html #}
{% extends "admin/change_form.html" %}
{% block field_sets %}
<div x-data="{ status: '{{ adminform.form.initial.status }}' }">
{# status field — при зміні оновлює x-data #}
<div x-on:change="status = $event.target.value">
{{ block.super }}
</div>
{# visible_until — показується тільки для scheduled #}
<div x-show="status === 'scheduled'" x-cloak>
{# поле форми #}
</div>
</div>
{% endblock %}
Sortable Inlines¶
from unfold.contrib.inlines.admin import SortableStackedInline
class SortableAttachmentInline(SortableStackedInline):
model = Attachment
fields = ['file', 'description', 'order']
sortable_field_name = 'order' # поле для сортування (IntegerField)
extra = 0
Expandable Rows у ChangeList¶
from unfold.admin import ModelAdmin as UnfoldModelAdmin
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
list_display = ['title', 'status', 'comments_count']
# Expandable row — розгорнутий рядок з preview
list_expandable_fields = True
def get_expandable_data(self, obj):
"""Дані для розгорнутого рядка."""
return {
'preview': obj.body[:200] + '...' if len(obj.body) > 200 else obj.body,
'comments': obj.comments.values_list('text', flat=True)[:3],
}
- 🧠 Ментальна модель: Enterprise Patterns — це "поповерховий офіс". Різні команди (операційна, аналітична, технічна підтримка) потребують різних "поверхів" з різним видом і доступом.
UnfoldAdminSite+LegacyAdminSiteдозволяють мати кілька адмін-панелей на одному Django-сервері: сучасна Unfold для нових фіч, стандартна для legacy workflows.InfinitePaginator— для таблиць з мільйонами рядків деCOUNT(*)займає секунди. - 📚 Чому
InfinitePaginatorважливий: PostgreSQLCOUNT(*)на таблиці з 10M рядків без умов може займати 2-5 секунд (full table scan або index scan). Стандартна Django пагінація виконуєCOUNT(*)при КОЖНОМУ завантаженні списку.InfinitePaginatorвикористовуєLIMIT N+1замість COUNT: якщо отримали N+1 рядків — є ще одна сторінка; якщо менше N+1 — це остання. Без підрахунку загальної кількості. - 🌐 Що відбувається під капотом:
import/export + Unfold— Unfold замінює стандартні шаблониdjango-import-exportсвоїми Tailwind-стилізованими версіями.unfold.contrib.import_exportмістить overridden шаблони для Import/Export кнопок і модальних вікон. Django знаходить їх першими (боunfoldпершим уINSTALLED_APPS). - ❌ Типова помилка початківця: Встановити
unfold.contrib.import_exportале залишити'import_export'перед'unfold'вINSTALLED_APPS. Результат: стандартні (не Tailwind) шаблони import_export завантажуються першими — кнопки виглядають інакше всієї адмінки.
§12. Enterprise Patterns¶
Паралельний Unfold + стандартний admin¶
# notes_app/admin_sites.py
from unfold.sites import UnfoldAdminSite
from django.contrib.admin import AdminSite
# Unfold для нової команди
class ModernAdminSite(UnfoldAdminSite):
site_header = 'Bootstrap Notes Modern'
# Стандартний для legacy workflows
class LegacyAdminSite(AdminSite):
site_header = 'Bootstrap Notes Legacy'
modern_admin = ModernAdminSite(name='modern')
legacy_admin = LegacyAdminSite(name='legacy')
# urls.py
urlpatterns = [
path('admin/', admin.site.urls), # стандартний Django Admin
path('modern/', modern_admin.urls), # Unfold
path('legacy/', legacy_admin.urls), # legacy AdminSite
]
InfinitePaginator — без COUNT(*)¶
from unfold.paginator import InfinitePaginator
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
paginator = InfinitePaginator # ← без COUNT(*) запиту
list_per_page = 50
show_full_result_count = False # сховати "X results (Y total)"
Навіщо:
SELECT COUNT(*) FROM notesна таблиці з 10M рядків може займати секунди.InfinitePaginatorвикористовуєLIMIT/OFFSETбез підрахунку загальної кількості.
crispy-forms + Unfold¶
# settings.py
INSTALLED_APPS = [
'unfold',
'unfold.contrib.forms', # ← Unfold форми
'crispy_forms',
'crispy_tailwind',
...
]
CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind"
CRISPY_TEMPLATE_PACK = "tailwind"
# forms.py — crispy з Unfold стилями
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit, Row, Column
class NoteForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.layout = Layout(
'title',
Row(Column('category'), Column('status')),
'body',
Submit('submit', 'Зберегти', css_class='btn-primary'),
)
import/export + Unfold¶
# settings.py
INSTALLED_APPS = [
'unfold',
'unfold.contrib.import_export', # ← замість 'import_export'
'import_export',
...
]
from import_export.admin import ImportExportModelAdmin
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from import_export import resources
class NoteResource(resources.ModelResource):
class Meta:
model = Note
fields = ['id', 'title', 'body', 'status', 'created_at']
@admin.register(Note)
class NoteAdmin(ImportExportModelAdmin, UnfoldModelAdmin):
resource_classes = [NoteResource]
# → з'являються кнопки Import/Export у ChangeList
Command Palette (Ctrl+K)¶
# settings.py — глобальний пошук
UNFOLD = {
...
"COMMAND_PALETTE": {
"show_search": True,
# search_fields беруться з ModelAdmin.search_fields
# Увага: може бути навантаженням на БД!
# Конфігуруйте search_fields з ^ або = префіксами
},
}
Production попередження: Command palette виконує пошук по всіх зареєстрованих моделях одночасно. Перевірте що в кожного
ModelAdminєsearch_fieldsз індексованими полями.
- 🧠 Ментальна модель: Anti-Patterns — це "добре виглядає зараз, болить потім". Кожен з перерахованих анти-патернів має короткострокову перевагу ("менше коду зараз") і довгострокову ціну ("XSS вразливість", "падіння під навантаженням", "баг у concurrency"). Вивчення анти-патернів рятує від технічного боргу.
- 📚 Чому
format_htmlа неmark_safe:mark_safe(f'<b>{obj.title}</b>')— Python f-string інтерполюєobj.titleнапряму. Якщо title ='<script>alert("xss")</script>'— шаблон рендерить реальний<script>тег. XSS-атака.format_html('<b>{}</b>', obj.title)автоматично екранує другий аргумент:<script>→<script>. Безпечно, навіть якщо title є user-generated content. - 🌐 Що відбувається під капотом: Django
format_htmlвсередині викликаєconditional_escape()для кожного аргументу, який не єSafeData. Це перетворює<→<,>→>,"→",'→',&→&. Результат — рядок позначений якSafeData, який Django Template не буде екранувати повторно. - ❌ Типова помилка початківця: Думати "у мене адмінка закрита паролем — XSS не проблема". XSS в адмінці особливо небезпечний: якщо зловмисник може ввести JavaScript у title нотатки, і staff-користувач відкриє список нотаток в адмінці — JS виконається в контексті session staff-користувача з повними правами.
§13. Admin Anti-Patterns¶
❌ Адмінка як основний frontend¶
# НЕПРАВИЛЬНО: бізнес-процеси зовнішніх користувачів в адмінці
# Адмінка для ВНУТРІШНЬОГО персоналу
# Зовнішні користувачі → стандартні Django Views + Bootstrap templates
❌ State Leakage через мутацію атрибутів¶
# ❌ НЕБЕЗПЕЧНО: ModelAdmin є синглтоном!
class NoteAdmin(admin.ModelAdmin):
readonly_fields = ['created_at']
def get_readonly_fields(self, request, obj=None):
if obj:
self.readonly_fields.append('title') # мутує клас для ВСІХ
return self.readonly_fields
# ✅ Повертати новий список
def get_readonly_fields(self, request, obj=None):
fields = list(self.readonly_fields) # копія
if obj:
fields.append('title')
return fields
❌ XSS через mark_safe¶
# ❌ НЕБЕЗПЕЧНО: user-generated content без escape
def colored_title(self, obj):
return mark_safe(f'<b>{obj.title}</b>') # title може містити <script>!
# ✅ format_html екранує автоматично
from django.utils.html import format_html
def colored_title(self, obj):
return format_html('<b>{}</b>', obj.title) # безпечно
❌ Повільний search_fields без індексів¶
# ❌ Full scan на TEXT полі
search_fields = ['body'] # WHERE body LIKE '%query%' — no index!
# ✅ Пошук по індексованих полях
search_fields = ['^title'] # startswith — використовує btree index
# Або додати GIN індекс для full-text search у PostgreSQL:
# CREATE INDEX idx_note_body_gin ON notes_note USING gin(to_tsvector('ukrainian', body));
❌ Кастомні URL без admin_view¶
# ❌ Незахищений endpoint
def get_urls(self):
return [
path('export/', self.export_view), # ВІДКРИТИЙ ДЛЯ ВСІХ!
] + super().get_urls()
# ✅ Обгортка admin_view гарантує автентифікацію
def get_urls(self):
return [
path('export/',
self.admin_site.admin_view(self.export_view)), # захищено
] + super().get_urls()
❌ N+1 у list_display без list_select_related¶
# ❌ 1 + N запитів (N = кількість нотаток на сторінці)
class NoteAdmin(admin.ModelAdmin):
list_display = ['title', 'author_email']
def author_email(self, obj):
return obj.author.email # SELECT для кожного рядка
# ✅
class NoteAdmin(admin.ModelAdmin):
list_display = ['title', 'author_email']
list_select_related = ['author'] # JOIN один раз
def author_email(self, obj):
return obj.author.email # з кешу
§14. Питання для самоперевірки¶
AdminSiteє синглтоном. Що це означає для методуget_readonly_fields()? Чому мутаціяself.readonly_fieldsнебезпечна?- Для чого потрібен
self.admin_site.admin_view()при додаванні кастомного URL уget_urls()? Що станеться без нього? list_select_related = Truevslist_select_related = ['author', 'category']— яка різниця?search_fields = ['body']на таблиці з 1M рядків. Що відбувається в БД? Як виправити?TemplateResponseуchangelist_viewдозволяє middleware змінити контекст. Як це використати для A/B тестування?- Unfold
InfinitePaginatorне виконуєCOUNT(*). Які обмеження це накладає на UX? Коли це прийнятно? - Чому Command Palette може бути небезпечним у production без правильної конфігурації
search_fields? DASHBOARD_CALLBACKотримує(request, context)— чим це відрізняється від стандартного Context Processor?SortableStackedInlineвимагаєIntegerFieldу моделі. Чому? Як це поле зберігається в БД?- Можна запустити одночасно стандартний
django.contrib.admin.siteіUnfoldAdminSiteна різних URL? Яка практична перевага цього в enterprise?
- 🧠 Ментальна модель: Unfold Filters — це "розумний пошук" замість "тупого dropdown". Стандартний
list_filter = ['author']для FK з 100k користувачів завантажує ВСІХ у<select>— сторінка підвисає.AutocompleteSelectFilterзавантажує тільки 10 результатів через AJAX при введенні.RangeDateFilterпоказує "від/до" поля замість списку років.TextFilter— текстове поле з кастомною логікою. - 📚 Чому
list_filter_submit = Trueобов'язковий для деяких фільтрів: Безlist_filter_submitкожна зміна фільтра миттєво відправляє запит на сервер (стандартна поведінка). ДляRangeDateFilterтаTextFilter— поля вводу, це означало б запит після кожного натиснутого символу.list_filter_submit = Trueдодає кнопку "Застосувати" і відправляє запит тільки після явного підтвердження. - 🌐 Що відбувається під капотом:
AutocompleteSelectFilterпід капотом використовує Django вбудований autocomplete API:GET /admin/auth/user/autocomplete/?term=john&app_label=notes_app&model_name=note&field_name=author. Django Admin виконуєsearch_fieldsquery на UserAdmin і повертає JSON з результатами. Томуsearch_fieldsнаUserAdminє ОБОВ'ЯЗКОВИМ для роботиAutocompleteSelectFilter. - ❌ Типова помилка початківця: Підключити
AutocompleteSelectFilterдляauthorFK, але не додатиsearch_fieldsнаUserAdmin. Django повернеHttp404абоImproperlyConfiguredпри AJAX-запиті autocomplete. Правило: якщо FK вказує на модель X — уXAdminПОВИНЕН бутиsearch_fields.
§15. Advanced Filtering — Unfold Filters¶
Вбудовані фільтри Unfold¶
# notes_app/admin.py
from unfold.contrib.filters.admin import (
AutocompleteSelectFilter, # Select2 пошук для FK
AutocompleteSelectMultipleFilter,
RangeDateFilter, # вибір діапазону дат
RangeDateTimeFilter,
RangeNumericFilter, # числовий діапазон
TextFilter, # текстовий фільтр з полем вводу
DropdownFilter, # стандартний dropdown
RelatedDropdownFilter, # FK dropdown
ChoicesDropdownFilter, # choices field dropdown
)
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
list_filter_submit = True # ← кнопка "Застосувати" у фільтрах
list_filter = [
('status', ChoicesDropdownFilter), # dropdown для choices
('category', RelatedDropdownFilter), # FK dropdown
('author', AutocompleteSelectFilter), # Select2 AJAX
('created_at', RangeDateFilter), # від/до для дати
]
TextFilter — кастомний текстовий фільтр¶
from unfold.contrib.filters.admin import TextFilter
class TitleTextFilter(TextFilter):
title = 'Пошук по заголовку'
parameter_name = 'title_contains'
def queryset(self, request, queryset):
if self.value():
return queryset.filter(title__icontains=self.value())
return queryset
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
list_filter = [TitleTextFilter]
AutocompleteSelectFilter для великих FK¶
# ❌ Стандартний list_filter для FK з 100k записів
list_filter = ['author']
# → SELECT * FROM auth_user → завантажує ВСІ записи у <select>
# ✅ AutocompleteSelectFilter — AJAX пошук
from unfold.contrib.filters.admin import AutocompleteSelectFilter
list_filter = [('author', AutocompleteSelectFilter)]
list_filter_submit = True
# Потрібно щоб UserAdmin мав search_fields
@admin.register(User)
class UserAdmin(UserAdmin):
search_fields = ['^username', '^email'] # ← обов'язково
RangeDateTimeFilter — діапазон дат¶
from unfold.contrib.filters.admin import RangeDateTimeFilter
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
list_filter = [
('created_at', RangeDateTimeFilter), # → "Від: [дата] До: [дата]"
]
list_filter_submit = True # без submit — фільтр не спрацьовує!
- 🧠 Ментальна модель: Actions — це "масові операції". Стандартний Django "select all + delete" — це теж action. Unfold розширює систему actions трьома рівнями: (1) Bulk actions (для вибраних записів у списку), (2) Dialog actions (bulk дія з формою підтвердження), (3) Row-level actions (для кожного рядка окремо). Dialog action — це "два кроки замість одного": спочатку форма (причина, параметри), потім дія.
- 📚 Чому Dialog Actions замість простого підтвердження: Browser
confirm()— це блокуюче модальне вікно без можливості введення даних.BaseDialogForm— це повноцінна Django форма в модальному вікні: поля введення, валідація, Bootstrap стилі. Наприклад, при архівуванні нотатки — запитати причину і чи повідомляти автора. Ці дані передаються уrequest.POSTі доступні в action методі. - 🌐 Що відбувається під капотом: Bulk action flow: (1) Користувач вибирає checkbox-и в ChangeList, (2) Вибирає action з dropdown, (3) POST запит на
/admin/notes_app/note/зaction='publish_notes'і_selected_action=[1, 2, 3], (4) Django знаходить методpublish_notesвNoteAdmin.actions, (5) Викликаєpublish_notes(modeladmin, request, queryset)деquerysetвже відфільтрований по вибраних записах. - ❌ Типова помилка початківця: Виконувати дорогу операцію (наприклад, відправку email) у bulk action синхронно. Якщо обрати 1000 записів і натиснути "Архівувати і повідомити" — HTTP запит займе хвилини. Браузер може втратити з'єднання. Важкі операції у bulk actions — завжди через Celery task.
§16. Bulk Actions і Dialog Actions¶
Стандартні actions + Unfold декоратор¶
from unfold.decorators import action
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
actions = ['publish_notes', 'delete_with_confirm']
@action(
description='Опублікувати',
icon='publish',
attrs={'class': 'text-green-600 font-semibold'},
)
def publish_notes(self, request, queryset):
count = queryset.update(status='published')
self.message_user(request, f'Опубліковано {count} нотаток.')
Dialog Action — підтвердження з формою¶
from unfold.actions import BaseDialogForm, BaseActionForm
from django import forms
class ArchiveReasonForm(BaseDialogForm):
"""Форма для діалогового вікна перед архівуванням."""
reason = forms.CharField(
label='Причина архівування',
widget=forms.Textarea(attrs={'rows': 3}),
required=True,
)
notify_author = forms.BooleanField(
label='Повідомити автора',
required=False,
initial=True,
)
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
actions = ['archive_with_reason']
@action(
description='Архівувати з поясненням',
icon='archive',
form=ArchiveReasonForm, # ← відкриває модальне вікно
)
def archive_with_reason(self, request, queryset):
# Після заповнення форми — тут є request.POST
reason = request.POST.get('reason', '')
notify = request.POST.get('notify_author') == 'on'
for note in queryset:
note.status = 'archived'
note.archive_reason = reason
note.save()
if notify:
send_mail(
subject='Ваша нотатка архівована',
message=f'Причина: {reason}',
recipient_list=[note.author.email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
self.message_user(request, f'Архівовано {queryset.count()} нотаток.')
Row-level actions (дії на кожному рядку)¶
from unfold.admin import ModelAdmin as UnfoldModelAdmin
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
@action(description='Дублювати', icon='content_copy', url_path='duplicate')
def duplicate_note(self, request, pk):
"""Row-level action — для кожного рядка окремо."""
note = get_object_or_404(Note, pk=pk)
note.pk = None
note.title = f'[Копія] {note.title}'
note.status = 'draft'
note.save()
self.message_user(request, f'Нотатку продубльовано.')
return redirect(
reverse('admin:notes_app_note_changelist')
)
- 🧠 Ментальна модель: Component Classes — це "окремий відділ" для кожного дашборд-блоку. Замість одного великого
dashboard_callbackщо робить все — кожен компонент (графік активності, таблиця топ-авторів, трекер) є окремим класом з власнимget_context_data(). Це "розділення відповідальності" у чистому вигляді: тестується окремо, оновлюється окремо, кешується окремо. - 📚 Чому
apps.py → ready()для реєстрації:@register_componentреєструє компонент у глобальний реєстр при імпорті модуля. Але Python не імпортує модуль автоматично — лише якщо він явно import-ований.apps.py → ready()— це правильне місце для "побічних ефектів при старті Django": він виконується після повного завантаження всіх моделей і налаштувань.import notes_app.admin_componentsуready()ініціює реєстрацію. - 🌐 Що відбувається під капотом:
{% component "notes_app.admin_components.NoteActivityChartComponent" %}— Unfold знаходить клас у реєстрі за рядковим шляхом, створює екземпляр, викликаєget_context_data(), і рендерить вміст блоку компонента у цьому контексті. Вміст між{% component %}і{% endcomponent %}— це шаблон компонента, якому доступні дані зget_context_data(). - ❌ Типова помилка початківця: Забути
import notes_app.admin_componentsуapps.py → ready(). Компоненти не будуть зареєстровані.{% component "..." %}тихо відобразить нічого або кинеComponentNotFound. Перевірка: після зупинки та перезапуску сервера — помилкаComponentNotFound→ready()не імпортує потрібний модуль.
§17. Component Classes — @register_component¶
Навіщо Component Classes¶
❌ Dashboard callback із важкою логікою:
def dashboard_callback(request, context):
context['chart'] = {
'labels': [...heavy ORM aggregation...],
'data': [...more complex stuff...],
}
# Все змішане в одній функції
✅ Component Class — логіка ізольована:
@register_component
class NoteActivityChart:
def get_context_data(self, **kwargs):
return {'labels': [...], 'data': [...]}
Реєстрація і використання¶
# notes_app/admin_components.py
from unfold.components import BaseComponent, register_component
from django.db.models import Count
from django.db.models.functions import TruncDate
from datetime import timedelta
from django.utils import timezone
@register_component
class NoteActivityChartComponent(BaseComponent):
"""Лінійний графік активності за 30 днів."""
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
thirty_days_ago = timezone.now() - timedelta(days=30)
daily = (
Note.objects
.filter(created_at__gte=thirty_days_ago)
.annotate(date=TruncDate('created_at'))
.values('date')
.annotate(count=Count('id'))
.order_by('date')
)
context['chart_data'] = {
'labels': [str(row['date']) for row in daily],
'datasets': [{
'label': 'Нових нотаток',
'data': [row['count'] for row in daily],
'borderColor': 'rgb(14, 165, 233)',
'backgroundColor': 'rgba(14, 165, 233, 0.1)',
'fill': True,
}],
}
context['title'] = 'Активність за 30 днів'
return context
@register_component
class TopAuthorsTableComponent(BaseComponent):
"""Топ-5 авторів по кількості нотаток."""
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
top_authors = (
Note.objects
.values('author__username')
.annotate(count=Count('id'))
.order_by('-count')[:5]
)
context['authors'] = top_authors
return context
# notes_app/apps.py — щоб компоненти завантажились
class HelloAppConfig(AppConfig):
name = 'notes_app'
def ready(self):
import notes_app.admin_components # ← реєстрація компонентів
{# templates/admin/index.html #}
{% extends "unfold/layouts/base_simple.html" %}
{% load unfold %}
{% block content %}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
{# Component Class — автоматично викликає get_context_data() #}
{% component "notes_app.admin_components.NoteActivityChartComponent" %}
{% component "notes_app/components/chart/line.html" with data=chart_data title=title %}
{% endcomponent %}
{% endcomponent %}
{% component "notes_app.admin_components.TopAuthorsTableComponent" %}
<table class="w-full text-sm">
{% for author in authors %}
<tr>
<td>{{ author.author__username }}</td>
<td class="text-right font-bold">{{ author.count }}</td>
</tr>
{% endfor %}
</table>
{% endcomponent %}
</div>
{% endblock %}
- 🧠 Ментальна модель: Custom Pages — це "власний кабінет в офісі". Стандартна адмінка знає тільки CRUD (список, форма, видалення). Якщо потрібна аналітична сторінка, звіт, або кастомна форма без прив'язки до моделі —
UnfoldModelAdminViewMixin + TemplateView. Це Django Class-Based View всередині адмінки, зі стилями Unfold, sidebar, breadcrumbs і перевіркою прав — але з власним шаблоном і логікою. - 📚 Чому
admin_view()обов'язковий:self.admin_site.admin_view(view)— це decorator, який обгортає view і додає: (1) перевірку автентифікації (redirect на login якщо не авторизований), (2) перевіркуis_staff(403 якщо не staff), (3) CSRF перевірку. Без нього — твоя кастомна сторінка відкрита для всіх без авторизації. Це критична вразливість безпеки. - 🌐 Що відбувається під капотом:
NoteStatisticsView.as_view(model_admin=self)—model_adminпередається у view черезkwargs.UnfoldModelAdminViewMixinвикористовуєmodel_adminдля доступу доadmin_site.each_context()(глобальні admin контекст змінні: права, повідомлення, sidebar дані). Безmodel_admin— Unfold шаблон не зможе побудувати sidebar і navbar. - ❌ Типова помилка початківця: Реєструвати кастомну сторінку в
get_urls()але забути додати посилання уUNFOLD.SIDEBAR. Сторінка технічно доступна (якщо знаєш URL), але недосяжна через інтерфейс. Або реєструвати URL безname=— тодіreverse_lazy('admin:...')у sidebar кинеNoReverseMatch.
§18. Custom Pages — UnfoldModelAdminViewMixin¶
Кастомна сторінка всередині Unfold¶
# notes_app/admin_views.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView
from unfold.views import UnfoldModelAdminViewMixin
class NoteStatisticsView(UnfoldModelAdminViewMixin, TemplateView):
"""Власна сторінка статистики зі стилями Unfold."""
title = 'Статистика нотаток' # відображається в заголовку
permission_required = () # або ('notes_app.view_note',)
template_name = 'admin/notes_app/note_statistics.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['stats'] = {
'by_status': Note.objects.values('status').annotate(
count=Count('id')
),
'by_month': Note.objects.annotate(
month=TruncMonth('created_at')
).values('month').annotate(count=Count('id')).order_by('month'),
}
return context
# notes_app/admin.py — реєстрація URL
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
def get_urls(self):
from django.urls import path
from notes_app.admin_views import NoteStatisticsView
urls = super().get_urls()
custom = [
path(
'statistics/',
self.admin_site.admin_view( # ← захист автентифікацією
NoteStatisticsView.as_view(
model_admin=self # ← передаємо ModelAdmin
)
),
name='notes_app_note_statistics',
),
]
return custom + urls
# settings.py — додаємо сторінку до sidebar
UNFOLD = {
"SIDEBAR": {
"navigation": [
{
"title": "Аналітика",
"items": [
{
"title": "Статистика нотаток",
"icon": "bar_chart",
"link": reverse_lazy("admin:notes_app_note_statistics"),
},
],
},
],
},
}
{# templates/admin/notes_app/note_statistics.html #}
{% extends "unfold/layouts/base.html" %}
{% load i18n unfold %}
{% block breadcrumbs %}
<a href="{% url 'admin:notes_app_note_changelist' %}">Нотатки</a> /
<span>Статистика</span>
{% endblock %}
{% block content %}
<div class="flex flex-col gap-6">
<h1 class="text-2xl font-bold">Статистика нотаток</h1>
{# По статусах #}
<div class="grid grid-cols-3 gap-4">
{% for item in stats.by_status %}
{% component "unfold/components/card.html" with
title=item.status
metric=item.count
%}{% endcomponent %}
{% endfor %}
</div>
</div>
{% endblock %}
- 🧠 Ментальна модель: Advanced Widgets — це "спеціальні інструменти для особливих полів". Стандартний
<textarea>для тексту — це "молоток".WysiwygWidget— це "електродриль": WYSIWYG редактор з форматуванням (жирний, курсив, списки, посилання) прямо в адмінці.ArrayWidget— це "набір відміток": редагування PostgreSQL масиву як набору тегів-chips.autocomplete_fields— це "розумний пошук" замість "величезного select". - 📚 Чому
formfield_for_dbfieldа неwidgetsу Meta:formfield_for_dbfieldдозволяє умовну логіку: різний widget залежно від назви поля, типу поля, або навіть request (наприклад, різний widget для superuser і звичайного staff).widgetsуModelForm.Meta— статична конфігурація без умов. Для кастомних Unfold widgets — завждиformfield_for_dbfield. - 🌐 Що відбувається під капотом:
WysiwygWidgetрендерить Trix (або TipTap) JavaScript редактор. При завантаженні форми — textarea перетворюється на rich-text редактор (JS ініціалізація). При збереженні — JS серіалізує відформатований вміст у HTML рядок і записує у приховане textarea. Django отримує HTML рядок уrequest.POSTі зберігає його уTextField. При відображенні —{{ note.body|safe }}показує HTML. - ❌ Типова помилка початківця: Зберігати WYSIWYG HTML у
TextFieldі відображати через{{ note.body }}(без|safe) у публічних шаблонах — текст відобразиться як<p>...</p>. Або навпаки:{{ user_comment|safe }}для user-generated content без WYSIWYG — пряма XSS вразливість. Правило:|safeтільки для контенту, відредагованого через довірений WYSIWYG у закритій адмінці.
§19. Advanced Widgets¶
WysiwygWidget — WYSIWYG редактор (Trix)¶
from unfold.contrib.forms.widgets import WysiwygWidget
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
def formfield_for_dbfield(self, db_field, request, **kwargs):
if db_field.name == 'body':
kwargs['widget'] = WysiwygWidget
return super().formfield_for_dbfield(db_field, request, **kwargs)
ArrayWidget — PostgreSQL Array поля¶
# models.py — PostgreSQL ArrayField
from django.contrib.postgres.fields import ArrayField
class Note(models.Model):
tags = ArrayField(
models.CharField(max_length=50),
default=list,
blank=True,
)
from unfold.contrib.forms.widgets import ArrayWidget
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
def formfield_for_dbfield(self, db_field, request, **kwargs):
if db_field.name == 'tags':
kwargs['widget'] = ArrayWidget
return super().formfield_for_dbfield(db_field, request, **kwargs)
# → рендерить теги як chips з можливістю додавати/видаляти
Autocomplete FK — заміна select¶
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
# ❌ Завантажує всіх users у <select>
# raw_id_fields = ['author']
# ✅ AJAX пошук — не завантажує всіх
autocomplete_fields = ['author', 'category']
# Потрібно на UserAdmin і CategoryAdmin:
# search_fields = ['^username'] або search_fields = ['^name']
- 🧠 Ментальна модель: Admin Performance — це "фільтрація перед входом". Cache — це "вахтер з пам'яттю": перший раз він пускає тебе, перевіряє документи (виконує SQL), і записує результат. Наступний раз — просто відкриває двері без перевірки.
cache.set(key, value, timeout=300)→ 5 хвилин без SQL.cache.delete(key)при зміні даних → "вахтер забуває" → наступний запит знову перевіряє. - 📚 Чому
cached.Loaderкритичний у production: Безcached.LoaderDjango читає файл шаблону з диску і парсить його при КОЖНОМУ запиті. 1000 запитів/секунда = 1000 читань диску + 1000 парсингів.cached.Loaderпарсить один раз при першому запиті і зберігає compiled Node tree в RAM. Наступні 999 запитів — тільки рендеринг з RAM. 10-50x прискорення рендерингу шаблонів. - 🌐 Що відбувається під капотом: PostgreSQL Full-Text Search з
SearchVectorіGINіндексом:to_tsvector('ukrainian', title || ' ' || body)— перетворює текст на lexemes (нормалізовані слова).SearchQuery('django')— теж нормалізується. GIN індекс зберігає{lexeme → [doc_id, ...]}mapping. Пошук — бінарний пошук по B-tree індексу GIN замістьLIKE '%query%'full scan. - ❌ Типова помилка початківця: Увімкнути
cached.Loaderв development. При зміні шаблону — браузер показує стару версію. Потрібен перезапуск сервера щоб оновити кеш.cached.Loader— ВИКЛЮЧНО для production. В development залишAPP_DIRS: True.
§20. Performance і Caching¶
Кешування dashboard даних¶
# notes_app/admin_dashboard.py
from django.core.cache import cache
from django.db.models import Count
def dashboard_callback(request, context):
# Спроба отримати з кешу
cache_key = 'admin_dashboard_stats'
stats = cache.get(cache_key)
if stats is None:
# Важкий запит — виконується тільки якщо кеш порожній
stats = {
'total': Note.objects.count(),
'published': Note.objects.filter(status='published').count(),
'by_category': list(
Note.objects.values('category__name')
.annotate(count=Count('id'))
.order_by('-count')[:10]
),
}
cache.set(cache_key, stats, timeout=300) # 5 хвилин
context['stats'] = stats
# Інвалідація кешу — в signal або save_model
return context
# Інвалідація при збереженні нотатки
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.cache import cache
@receiver(post_save, sender=Note)
def invalidate_dashboard_cache(sender, **kwargs):
cache.delete('admin_dashboard_stats')
{% cache %} template tag для важких блоків¶
{# templates/admin/index.html #}
{% load cache %}
{# Кешування HTML фрагменту на 5 хвилин #}
{% cache 300 "dashboard_top_authors" %}
{% component "unfold/components/card.html" with
title="Топ автори"
data=top_authors
%}{% endcomponent %}
{% endcache %}
cached.Loader для шаблонів у production¶
# settings.py — production
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': False, # ← False коли використовуємо cached.Loader
'OPTIONS': {
'loaders': [
('django.template.loaders.cached.Loader', [
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
]),
],
'context_processors': [...],
},
},
]
Full-text Search у PostgreSQL¶
# ❌ LIKE на великій таблиці
search_fields = ['body'] # WHERE body LIKE '%query%' — full scan
# ✅ PostgreSQL Full-Text Search
from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
search_fields = ['title'] # fallback для simple search
def get_search_results(self, request, queryset, search_term):
if search_term and len(search_term) >= 3:
# Full-text search по title + body
search_vector = SearchVector('title', weight='A') + \
SearchVector('body', weight='B')
search_query = SearchQuery(search_term, config='ukrainian')
queryset = (
queryset
.annotate(search=search_vector)
.filter(search=search_query)
.annotate(rank=SearchRank(search_vector, search_query))
.order_by('-rank')
)
return queryset, False # False = no distinct needed
return super().get_search_results(request, queryset, search_term)
-- Міграція: GIN індекс для full-text search
-- notes_app/migrations/0002_note_search_index.py
from django.db import migrations
class Migration(migrations.Migration):
operations = [
migrations.RunSQL(
"CREATE INDEX CONCURRENTLY idx_note_fts ON notes_app_note "
"USING gin(to_tsvector('ukrainian', title || ' ' || body));",
reverse_sql="DROP INDEX IF EXISTS idx_note_fts;",
),
]
collectstatic для production¶
# settings.py
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
# Hash-файли для агресивного browser caching
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
# → main.css → main.02b59bcc5cd9.css
# → Браузер кешує на рік (immutable)
# → Після зміни файлу hash змінюється → примусове оновлення
python manage.py collectstatic --noinput
# → збирає всі static files + admin/unfold assets
# → генерує staticfiles.json з маппінгом
- 🧠 Ментальна модель: Datasets — це "таблиця всередині форми". Стандартний Inline показує 5-10 пов'язаних записів без пагінації. Якщо у нотатки 500 коментарів — Inline завантажить їх усі одразу (повільно, занадто). Dataset — це embedded ChangeList з пагінацією, фільтрами і пошуком. Це "міні-адмінка для пов'язаних записів" прямо у формі батьківського об'єкта.
- 📚 Чому partial view через URL замість Inline: Inline виконує queryset синхронно при завантаженні форми. 500 коментарів = 1 важкий SQL + рендеринг 500 рядків. Dataset view виконується окремим HTTP-запитом (через
<iframe>або HTMX), після завантаження основної форми. Форма завантажується швидко, коментарі догружаються асинхронно. - 🌐 Що відбувається під капотом:
comments_dataset_view— це стандартний Django view, зареєстрований уget_urls(). Він виконується окремим HTTP-запитом (GET/admin/notes_app/note/42/comments/?page=2).self.admin_site.each_context(request)додає стандартні admin контекст змінні (права, sidebar, повідомлення), щоб partial шаблон виглядав як частина адмінки. - ❌ Типова помилка початківця: Забути
**self.admin_site.each_context(request)у context dataset view. Шаблон Dataset не матиме доступу доuser,site_header, sidebar navigation — він відобразиться без стилів адмінки або кинеKeyError.
§21. Datasets — пов'язані записи у ChangeForm¶
Що таке Dataset¶
Datasets дозволяють вбудувати повноцінний ChangeList (список пов'язаних записів) прямо у форму редагування батьківського об'єкта.
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from unfold.contrib.inlines.admin import UnfoldStackedInline
# Замість стандартного Inline — Dataset (з пагінацією)
@admin.register(Note)
class NoteAdmin(UnfoldModelAdmin):
# Tabs для форми
fieldsets = [
('Основна інформація', {'tab': True, 'fields': ['title', 'body']}),
('Коментарі', {'tab': True, 'fields': []}), # порожній fieldset для вкладки
]
def get_urls(self):
from django.urls import path
urls = super().get_urls()
extra = [
path(
'<int:note_id>/comments/',
self.admin_site.admin_view(self.comments_dataset_view),
name='notes_app_note_comments',
),
]
return extra + urls
def comments_dataset_view(self, request, note_id):
"""Часткова сторінка з коментарями для HTMX/iframe."""
note = get_object_or_404(Note, pk=note_id)
comments = note.comments.select_related('author').order_by('-created_at')
paginator = Paginator(comments, 20)
page_obj = paginator.get_page(request.GET.get('page', 1))
return render(request, 'admin/notes_app/note/comments_dataset.html', {
'note': note,
'page_obj': page_obj,
**self.admin_site.each_context(request),
})
- 🧠 Ментальна модель: Production Checklist — це "список перед зльотом". Пілоти не покладаються на пам'ять — вони завжди проходять checklist. Так само з production Django Admin: кожен пункт закриває конкретну категорію ризику (безпека, продуктивність, UX, архітектура). Пропуск будь-якого пункту — потенційна проблема у production.
- 📚 Чому ManifestStaticFilesStorage критичний: Без ManifestStaticFilesStorage браузер кешує
main.cssагресивно (місяцями). Після деплою нової версії — користувачі бачать старі стилі, бо браузер показує кешовану версію. ManifestStaticFilesStorage додає hash до імені файлу:main.02b59bcc.css. При зміні файлу hash змінюється → нове ім'я → браузер завантажує нову версію. Стара version кешується нескінченно (cache-busting). - 🌐 Що відбувається під капотом:
collectstaticзManifestStaticFilesStorage: (1) збирає всі static файли уSTATIC_ROOT, (2) обчислює MD5 hash кожного файлу, (3) створює копії з hash у імені:main.css→main.02b59bcc.css, (4) генеруєstaticfiles.json— маппінг{'main.css': 'main.02b59bcc.css'}.{% static 'main.css' %}→ читаєstaticfiles.json→ повертаєmain.02b59bcc.css. - ❌ Типова помилка початківця: Запустити production сервер з
DEBUG=True"тимчасово".DEBUG=Trueу production означає: (1) Django показує traceback зі стеком викликів при кожній помилці (витік внутрішньої структури коду), (2) Django роздає static файли сам (повільно), (3) ALLOWED_HOSTS не перевіряється (вразливість Host header injection). НіколиDEBUG=Trueу production.
§22. Production Checklist¶
Django Admin + Unfold — Production Checklist
─────────────────────────────────────────────
БЕЗПЕКА
✅ Всі кастомні URL обгорнуті в admin_view()
✅ Перевірки прав у has_*_permission() методах
✅ format_html() замість mark_safe() для user-content
✅ search_fields не містять TEXT полів без індексів
✅ autocomplete_fields для великих FK
✅ Command Palette відключений або обмежений search_fields
ПРОДУКТИВНІСТЬ
✅ list_select_related для всіх FK у list_display
✅ prefetch_related у get_queryset() для M2M / зворотних FK
✅ InfinitePaginator для таблиць > 100k рядків
✅ search_fields з ^ або = префіксами (або get_search_results())
✅ Dashboard дані кешовані через cache.set/get (Redis)
✅ cached.Loader у TEMPLATES для production (APP_DIRS: False)
✅ ManifestStaticFilesStorage + collectstatic
АРХІТЕКТУРА
✅ Unfold встановлений ПЕРЕД django.contrib.admin в INSTALLED_APPS
✅ DASHBOARD_CALLBACK ізольований від view-логіки
✅ Component Classes для важких агрегацій на дашборді
✅ Кастомні сторінки через UnfoldModelAdminViewMixin
✅ Sidebar navigation оголошена в UNFOLD.SIDEBAR
✅ Немає HTML рядків у Python (тільки format_html())
✅ Немає прямих DB запитів у list_display методах (без select_related)
UX
✅ list_filter_submit = True для фільтрів з полями вводу
✅ warn_unsaved_form = True щоб попередити про незбережені зміни
✅ Fieldset tabs для форм з > 10 полями
✅ autocomplete_fields замість raw_id_fields (кращий UX)
✅ Unfold dark mode налаштований через THEME або CSS змінні