CrispyNotes — Django Templates + Crispy Forms¶
Цей туторіал проводить тебе через еволюцію Django-форм: HTML вручну → widget attrs → FormHelper + Layout.
Проєкт — менеджер нотаток з SaaS Dashboard: та сама модель
Noteщо вnotes_project, але рендеринг форм переведений наdjango-crispy-forms. Три рівні рендерингу форм — від{{ form.as_p }}до{% crispy form %}— показують чому crispy.
Зміст¶
Архітектурний фундамент (читати перед кодом) - 01 · TEMPLATE SOUP — проблема дублювання HTML - 02 · TEMPLATE INHERITANCE — 3-рівнева ієрархія - 03 · FORMS EVOLUTION — 3 рівні рендерингу - 04 · FORMHELPER + LAYOUT — архітектура crispy - 05 · DASHBOARD ARCHITECTURE — sidebar, topbar, context
Покрокова реалізація 1. Крок 0 — Запуск проєкту 2. Крок 1 — Settings: підключення crispy 3. Крок 2 — Рівень 1: base.html (HTML-оболонка) 4. Крок 3 — Рівень 2: layouts/dashboard.html (Sidebar + Topbar) 5. Крок 4 — Рівень 3: Сторінки (note_list, note_form...) 6. Крок 5 — Forms Tier 1: form.as_p (Raw Django) 7. Крок 6 — Forms Tier 2: Manual Bootstrap HTML 8. Крок 7 — Forms Tier 3: FormHelper + Layout (Crispy) 9. Крок 8 — Sidebar Context Processor 10. Крок 9 — Components (pagination, empty_state, modal) 11. Структура файлів проєкту
01 · TEMPLATE SOUP¶
"Template Soup" — коли шаблони містять повторювані блоки HTML:
<nav>,<header>, Bootstrap CDN — скопійовані в кожному файлі. При зміні навігації → правиш 20 файлів.
Проблема: дублювання на рівні шаблонів¶
<!-- notes_form.html — 80 рядків для 5 полів -->
<div class="mb-3">
<label for="id_title" class="form-label fw-semibold">
Заголовок
<span class="text-danger">*</span>
</label>
<input type="text"
name="title"
id="id_title"
class="form-control {% if form.title.errors %}is-invalid{% endif %}"
autofocus>
{% if form.title.errors %}
{% for error in form.title.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
<!-- ... повторити для кожного поля ... -->
Це Tier 2 — ручний Bootstrap HTML. Проблеми:
| Проблема | Приклад |
|---|---|
| Дублювання | 15 рядків на поле × 6 полів = 90 рядків тільки для форми |
| Зміна класу | Хочеш form-control-lg → правиш кожен <input> у кожному шаблоні |
| Синхронізація | Додав поле у models.py → не забудь оновити шаблон |
| Немає системи | Кожен розробник пише "трошки по-своєму" |
Рішення: розподіл відповідальності¶
БУЛО (Tier 2):
forms.py: поля + widget attrs + validation
template: label + input + error + help_text ← 80 рядків HTML
СТАЛО (Tier 3):
forms.py: поля + FormHelper + Layout ← опис структури Python-кодом
template: {% crispy form %} ← 1 рядок
Ключова ідея: Форма — це Python об'єкт. Її структура описується Python-кодом (Layout), а не HTML у шаблоні. Шаблон стає тонким — тільки вказує де рендерити.
02 · TEMPLATE INHERITANCE¶
"Не повторюй HTML — успадковуй." Django дозволяє будувати шаблони як дерево: батько визначає структуру, дитина — контент.
3-рівнева ієрархія (SaaS Dashboard паттерн)¶
base.html ← Рівень 1: HTML-оболонка
│ Bootstrap CDN, meta, <body>
│ {% block body %}
│
└── layouts/dashboard.html ← Рівень 2: Макет застосунку
Sidebar + Topbar
Django Messages
{% block content %}
{% block topbar_title %}
│
├── hello_app/note_list.html ← Рівень 3: Контент сторінки
├── hello_app/note_form.html
├── hello_app/notebook_list.html
└── hello_app/tag_form.html
Як це виглядає в коді¶
base.html — тільки HTML-скелет:
<!DOCTYPE html>
<html>
<head>
<!-- Bootstrap CDN, CSS -->
</head>
<body>
{% block body %}{% endblock %} ← весь застосунок тут
</body>
</html>
layouts/dashboard.html — макет (extends base):
{% extends 'base.html' %}
{% block body %}
<div class="d-flex vh-100">
<nav><!-- Sidebar --></nav>
<div class="flex-grow-1">
<header>{% block topbar_title %}{% endblock %}</header>
<main>{% block content %}{% endblock %}</main>
</div>
</div>
{% endblock %}
note_list.html — сторінка (extends dashboard):
{% extends 'layouts/dashboard.html' %}
{% block topbar_title %}Мої нотатки{% endblock %}
{% block content %}
<!-- тільки контент, sidebar/topbar вже є з батька -->
{% endblock %}
Потік наслідування¶
Запит /notes/
│
▼
views.note_list()
└─► render('hello_app/note_list.html', context)
│
▼
Django Template Engine
│
note_list.html extends dashboard.html
│
dashboard.html extends base.html
│
base.html — кореневий шаблон
│
▼
Зборка: base → dashboard → note_list → HTML
│
▼
200 OK · повна HTML сторінка
Переваги: - Зміна навігації → тільки
dashboard.html, всі сторінки оновились - Нова сторінка → тільки пишеш{% block content %}, решта безкоштовно - Немає дублювання Bootstrap CDN у кожному файлі
Порівняння: з наслідуванням vs без¶
| Без наслідування | З 3-рівневою ієрархією | |
|---|---|---|
| Нова сторінка | Скопіювати весь HTML | 5 рядків {% extends %} + content |
| Зміна навігації | Правити всі файли | Тільки dashboard.html |
| Bootstrap CDN | Кожен файл | Тільки base.html |
| Sidebar | Копіювати у кожен файл | Один раз у dashboard.html |
03 · FORMS EVOLUTION¶
Три рівні еволюції форм — від "просто працює" до "красиво і без дублювання".
Таблиця порівняння¶
| Tier | Де дивитись | Метод | HTML у шаблоні | Де Bootstrap-класи |
|---|---|---|---|---|
| 1 — Raw | notes_project/forms.py |
{{ form.as_p }} |
~5 рядків | Ніде (немає стилів) |
| 2 — Manual | django_bootstrap_project/ |
Ручний HTML | ~80 рядків | У шаблоні вручну |
| 3 — Crispy | цей проєкт /notes/new/ |
{% crispy form %} |
1 рядок | У forms.py через Layout |
Всі три підходи передають ту саму NoteForm. Різниця — тільки спосіб рендерингу.
Tier 1: Raw Django — {{ form.as_p }}¶
# views.py — нічого особливого
def note_create_raw(request):
form = NoteForm(user=request.user)
return render(request, 'hello_app/note_form_raw.html', {'form': form})
<!-- note_form_raw.html -->
<form method="post">
{% csrf_token %}
{{ form.as_p }} ← Django генерує <p><label><input></p> без класів
<button type="submit">Зберегти</button>
</form>
Що генерується:
<p>
<label for="id_title">Заголовок:</label>
<input type="text" name="title" id="id_title" required>
</p>
Жодного class="form-control", жодних Bootstrap-стилів.
Валідація працює, але виглядає погано.
Коли використовувати: тільки для debug. Ніколи в production.
Tier 2: Manual Bootstrap HTML¶
# forms.py — вказуємо Bootstrap-класи через widget attrs
class NoteFormManual(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['title'].widget.attrs.update({'class': 'form-control'})
self.fields['content'].widget.attrs.update({'class': 'form-control', 'rows': 8})
self.fields['priority'].widget.attrs.update({'class': 'form-select'})
# ... повторити для кожного поля
<!-- note_form_manual.html — ~80 рядків HTML для 5 полів -->
<form method="post">
{% csrf_token %}
<!-- Поле: title -->
<div class="mb-3">
<label for="{{ form.title.id_for_label }}" class="form-label fw-semibold">
{{ form.title.label }}
{% if form.title.field.required %}<span class="text-danger">*</span>{% endif %}
</label>
<input type="text"
name="{{ form.title.html_name }}"
id="{{ form.title.id_for_label }}"
value="{{ form.title.value|default:'' }}"
class="form-control {% if form.title.errors %}is-invalid{% endif %}"
autofocus>
{% if form.title.errors %}
{% for error in form.title.errors %}<div class="invalid-feedback">{{ error }}</div>{% endfor %}
{% endif %}
</div>
<!-- ... ще ~60 рядків для priority, notebook, content, tags, is_pinned ... -->
</form>
Проблеми Tier 2:
- 80 рядків HTML тільки для форми
- При додаванні поля в models.py → вручну дописати у шаблон
- При зміні Bootstrap-версії → правити кожен шаблон
- 10 форм у застосунку = 800 рядків дублювання
Де цей підхід: у
notes_project(попередній урок) — саме так там написані форми. Це нормальний підхід для невеликих проєктів. Crispy — це upgrade для scale.
Tier 3: Crispy Forms — {% crispy form %}¶
# forms.py — ВСЯ Bootstrap-розмітка описана тут, Python-кодом
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Row, Column, Fieldset, Submit, Field
class NoteForm(forms.ModelForm):
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.layout = Layout(
Fieldset('Основна інформація',
Field('title', placeholder='Назва нотатки...', autofocus=True),
Row(Column('priority', css_class='col-md-4'),
Column('notebook', css_class='col-md-8')),
),
Fieldset('Зміст нотатки',
Field('content', rows=8, placeholder='Текст нотатки...'),
),
Submit('submit', 'Зберегти нотатку', css_class='btn btn-primary me-2'),
)
Що генерується автоматично:
- <form method="post"> + CSRF токен
- <div class="mb-3"> для кожного поля
- <label class="form-label"> з required-зірочкою
- <input class="form-control"> або <select class="form-select">
- <div class="invalid-feedback"> для помилок
- <div class="form-text"> для help_text
- <fieldset> з <legend> для Fieldset
- <div class="row"> для Row, <div class="col-md-4"> для Column
04 · FORMHELPER + LAYOUT¶
FormHelper — об'єкт, прикріплений до форми, що описує: - Атрибути
<form>тегу (method, action, id, class) - Поведінку рендерингу - Layout — структуру та порядок полів
Архітектура FormHelper¶
NoteForm
│
├── fields (Django стандарт)
│ ├── title CharField
│ ├── content TextField
│ └── ...
│
└── helper = FormHelper() ← crispy додає сюди
├── form_method = 'post' ← <form method="post">
├── form_id = 'note-form' ← <form id="note-form">
├── form_tag = True ← рендерити <form>...</form>
└── layout = Layout(...) ← структура полів
├── Fieldset(...) ← <fieldset><legend>
│ ├── Field('title') ← окреме поле з attrs
│ └── Row(...) ← <div class="row">
│ ├── Column(...) ← <div class="col-md-4">
│ └── Column(...) ← <div class="col-md-8">
├── Submit(...) ← <button type="submit">
└── HTML('<hr>') ← довільний HTML
Конвеєр рендерингу¶
{% crispy form %}
│
▼
crispy_forms templatetag
│ Знаходить form.helper
│ Знаходить form.helper.layout
▼
Layout.render(form, context)
│
├── Fieldset.render()
│ ├── Field('title').render() → field.html template
│ └── Row.render()
│ └── Column.render()
├── Submit.render() → baseinput.html template
└── HTML.render() → direct string
│
▼
Bootstrap 5 HTML
(form-control, mb-3, row, col-md-4, invalid-feedback...)
Атрибути Layout об'єктів¶
# kwargs → HTML атрибути
Field('title', placeholder='Підказка') # placeholder="Підказка"
Field('title', autofocus=True) # autofocus
Field('title', data_custom="val") # data-custom="val" (підкреслення → дефіс)
Field('title', css_class="extra") # додатковий CSS клас
Field('title', type="hidden") # прихований input
# Обгортаючий div
Field('title', wrapper_class="mt-3") # клас для <div class="mb-3 mt-3">
Де crispy vs де ручний HTML — правило¶
| Ситуація | Підхід |
|---|---|
| Форма займає цілу сторінку | {% crispy form %} — повний контроль через Layout |
| Швидкий прототип | {{ form\|crispy }} — без FormHelper, просто Bootstrap-стилі |
| Одне поле вбудоване в сторінку | {{ form.title\|as_crispy_field }} |
| Форма без Bootstrap (API, email) | {{ form.as_p }} або form.cleaned_data напряму |
05 · DASHBOARD ARCHITECTURE¶
SaaS Dashboard — UI-паттерн для застосунків: фіксований sidebar зліва, topbar зверху, scrollable контент по центру. Gmail, Notion, Linear — всі побудовані на цьому паттерні.
Структура Dashboard¶
┌──────────┬───────────────────────────────────────────────────┐
│ │ Topbar: заголовок / пошук / юзер-dropdown │
│ Sidebar ├───────────────────────────────────────────────────┤
│ │ │
│ Бренд │ ← {% block content %} │
│ ───── │ │
│ НОТАТКИ │ Нотатки / Форма / Записники / Задачі... │
│ Записн. │ │
│ Всі │ │
│ + Нова │ │
│ ЗАДАЧІ │ │
│ Справи │ │
│ Покупки │ │
│ ───── │ │
│ [Запис.] │ (якщо є) │
│ ТЕГИ │ │
│ + Новий │ │
└──────────┴───────────────────────────────────────────────────┘
Sidebar Context Processor¶
Sidebar показує записники і теги — вони потрібні на КОЖНІЙ сторінці. Але передавати їх з кожного view вручну — це дублювання:
# ❌ Погано — дублювання у кожному view
def note_list(request):
notes = selectors.get_user_notes(request.user)
notebooks = selectors.get_user_notebooks(request.user) # дублювання
tags = selectors.get_user_tags(request.user) # дублювання
return render(request, '...', {'notes': notes, 'notebooks': notebooks, 'tags': tags})
def note_create(request):
notebooks = selectors.get_user_notebooks(request.user) # дублювання знову
tags = selectors.get_user_tags(request.user) # дублювання знову
...
Рішення — context_processors.py:
# ✅ Добре — один раз, автоматично для всіх views
def sidebar_context(request):
if not request.user.is_authenticated:
return {}
return {
'sidebar_notebooks': selectors.get_user_notebooks(request.user),
'sidebar_tags': selectors.get_user_tags(request.user),
}
# settings.py — реєструємо процесор
TEMPLATES = [{
'OPTIONS': {
'context_processors': [
...
'hello_app.context_processors.sidebar_context', # ← тут
]
}
}]
Тепер {{ sidebar_notebooks }} і {{ sidebar_tags }} доступні в кожному шаблоні автоматично.
Active State у навігації¶
<!-- Підсвічуємо активний пункт меню через request.resolver_match -->
<a href="{% url 'hello_app:note_list' %}"
class="nav-link text-white {% if request.resolver_match.url_name == 'note_list' %}active{% endif %}">
Всі нотатки
</a>
request.resolver_match.url_name повертає ім'я поточного URL — без додаткових змінних у View.
Крок 0 — Запуск¶
# 1. Переходимо до папки
cd module_5/lesson_HTML_CSS_Bootstrap/crispy_notes_project
# 2. Встановлюємо залежності
pip install -r requirements.txt
# 3. Застосовуємо міграції
python manage.py migrate
# 4. Створюємо суперюзера
python manage.py createsuperuser
# 5. Запускаємо сервер
python manage.py runserver
Що дивитись у браузері¶
| URL | Що показує |
|---|---|
http://127.0.0.1:8000/notes/ |
Dashboard: список нотаток + sidebar |
http://127.0.0.1:8000/notes/new/ |
Tier 3: {% crispy form %} — Bootstrap форма з Layout |
http://127.0.0.1:8000/notebooks/ |
Список записників |
http://127.0.0.1:8000/todo/ |
Списки справ (ЗАДАЧІ секція) |
http://127.0.0.1:8000/shopping/ |
Списки покупок |
http://127.0.0.1:8000/register/ |
Реєстрація нового користувача |
http://127.0.0.1:8000/admin/ |
Django Admin |
Tier 1 і Tier 2 як код для порівняння (не як живі URL): - Tier 1 (
form.as_p):notes_project/hello_app/forms.py- Tier 2 (manual Bootstrap):django_bootstrap_project/
Крок 1 — Settings¶
# hello_project/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
...
'crispy_forms', # ← 1. базовий пакет
'crispy_bootstrap5', # ← 2. template pack для Bootstrap 5
'debug_toolbar',
'hello_app',
]
# Налаштовуємо crispy
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
# Де шукати шаблони — критично для 3-рівневої ієрархії
TEMPLATES = [{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'], # ← глобальні шаблони (base.html, layouts/)
'APP_DIRS': True, # ← шаблони у hello_app/templates/hello_app/
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request', # ← для request.user, request.resolver_match
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'hello_app.context_processors.sidebar_context', # ← sidebar notebooks/tags
],
},
}]
# Django Messages → Bootstrap alert класи
from django.contrib.messages import constants as messages_constants
MESSAGE_TAGS = {
messages_constants.DEBUG: 'secondary',
messages_constants.INFO: 'info',
messages_constants.SUCCESS: 'success',
messages_constants.WARNING: 'warning',
messages_constants.ERROR: 'danger', # ← Bootstrap: alert-danger (не alert-error!)
}
LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/notes/'
Чому
'DIRS': [BASE_DIR / 'templates']?base.htmlіlayouts/dashboard.html— глобальні шаблони, не прив'язані до app. Django шукає шаблони у двох місцях:DIRS(глобальні) іAPP_DIRS(у кожному app).
Крок 2 — base.html¶
Місце: crispy_notes_project/templates/base.html
base.html — HTML-скелет. Тут ТІЛЬКИ:
- DOCTYPE, <head>, <body>
- Bootstrap 5 CDN
- Bootstrap Icons CDN
- Глобальний CSS
- Один {% block body %} — весь застосунок йде сюди
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CrispyNotes</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
</head>
<body>
{% block body %}{% endblock %} ← весь застосунок тут
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
Правило: base.html не знає нічого про sidebar, навігацію або контент. Він відповідає тільки за: "я правильний HTML5 документ з Bootstrap".
Крок 3 — layouts/dashboard.html¶
Місце: crispy_notes_project/templates/layouts/dashboard.html
Рівень 2 ієрархії. Він:
- Extends base.html
- Заповнює {% block body %} повною Dashboard-розміткою
- Надає два слоти для дочірніх шаблонів:
- {% block topbar_title %} — заголовок у topbar
- {% block content %} — основний контент сторінки
{% extends 'base.html' %}
{% load static %}
{% block body %}
<div class="d-flex vh-100 overflow-hidden">
<!-- ════════════════════ SIDEBAR ════════════════════ -->
<nav id="sidebar" class="sidebar d-flex flex-column p-3 text-bg-dark" style="width: 260px;">
<!-- Бренд -->
<a href="{% url 'hello_app:note_list' %}" class="d-flex align-items-center text-white mb-3">
<i class="bi bi-journal-text fs-4 me-2"></i>
<span class="fs-5 fw-semibold">CrispyNotes</span>
</a>
<hr>
<!-- НОТАТКИ section -->
<p class="sidebar-label text-uppercase fw-semibold mb-1 px-2">Нотатки</p>
<ul class="nav nav-pills flex-column mb-3">
<li>
<a href="{% url 'hello_app:notebook_list' %}"
class="nav-link {% if 'notebook' in request.resolver_match.url_name %}active{% endif %}">
<i class="bi bi-collection me-2"></i>Записники
</a>
</li>
<li>
<a href="{% url 'hello_app:note_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'note_list' %}active{% endif %}">
<i class="bi bi-journal-text me-2"></i>Всі нотатки
</a>
</li>
<li>
<a href="{% url 'hello_app:note_create' %}"
class="nav-link {% if request.resolver_match.url_name == 'note_create' %}active{% endif %}">
<i class="bi bi-plus-circle me-2"></i>Нова нотатка
</a>
</li>
</ul>
<!-- ЗАДАЧІ section -->
<p class="sidebar-label text-uppercase fw-semibold mb-1 px-2">Задачі</p>
<ul class="nav nav-pills flex-column mb-3">
<li>
<a href="{% url 'hello_app:todo_list' %}"
class="nav-link d-flex justify-content-between {% if 'todo' in request.resolver_match.url_name %}active{% endif %}">
<span><i class="bi bi-check2-square me-2"></i>Список справ</span>
{% if sidebar_todo_count %}
<span class="badge bg-primary rounded-pill">{{ sidebar_todo_count }}</span>
{% endif %}
</a>
</li>
<li>
<a href="{% url 'hello_app:shopping_list' %}"
class="nav-link d-flex justify-content-between {% if 'shopping' in request.resolver_match.url_name %}active{% endif %}">
<span><i class="bi bi-cart me-2"></i>Покупки</span>
{% if sidebar_shopping_count %}
<span class="badge bg-secondary rounded-pill">{{ sidebar_shopping_count }}</span>
{% endif %}
</a>
</li>
</ul>
<hr>
<!-- Записники (список, з sidebar_context — context processor!) -->
{% if sidebar_notebooks %}
<p class="sidebar-label text-uppercase fw-semibold mb-1 px-2">Записники</p>
<ul class="nav nav-pills flex-column mb-3">
{% for nb in sidebar_notebooks %}
<li>
<a href="{% url 'hello_app:note_list' %}?notebook={{ nb.pk }}"
class="nav-link py-1 d-flex justify-content-between">
<span><span style="color: {{ nb.color }};">●</span> {{ nb.title|truncatechars:18 }}</span>
<span class="badge bg-secondary">{{ nb.note_count }}</span>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<!-- ТЕГИ section (завжди видимий header) -->
<p class="sidebar-label text-uppercase fw-semibold mb-1 px-2">Теги</p>
{% if sidebar_tags %}
<div class="px-2 mb-2 d-flex flex-wrap gap-1">
{% for tag in sidebar_tags %}
<a href="{% url 'hello_app:note_list' %}?tag={{ tag.pk }}"
class="badge text-decoration-none"
style="background-color: {{ tag.color }}; color: white;">
#{{ tag.name }}
</a>
{% endfor %}
</div>
{% endif %}
<ul class="nav nav-pills flex-column mb-3">
<li>
<a href="{% url 'hello_app:tag_create' %}" class="nav-link">
<i class="bi bi-tags me-2"></i>Новий тег
</a>
</li>
</ul>
</nav>
<!-- /SIDEBAR -->
<!-- ════════════════════ MAIN ════════════════════ -->
<div class="d-flex flex-column flex-grow-1 overflow-auto">
<!-- TOPBAR: заголовок | пошук | юзер-dropdown -->
<header class="topbar d-flex align-items-center px-4 py-2">
<h6 class="mb-0 me-auto fw-semibold">
{% block topbar_title %}Нотатки{% endblock %} ← дочірні шаблони підставляють заголовок
</h6>
<form class="d-flex me-3" method="get" action="{% url 'hello_app:note_list' %}">
<input type="search" name="q" class="form-control form-control-sm" placeholder="Пошук нотаток...">
<button class="btn btn-sm btn-outline-secondary ms-1" type="submit">
<i class="bi bi-search"></i>
</button>
</form>
<!-- Профіль / Вихід (dropdown праворуч) -->
<div class="dropdown">
<a href="#" class="d-flex align-items-center text-decoration-none dropdown-toggle"
data-bs-toggle="dropdown">
<i class="bi bi-person-circle fs-5 me-1"></i>
<span class="small fw-semibold">{{ request.user.username }}</span>
</a>
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end shadow">
<li><a class="dropdown-item" href="{% url 'hello_app:notebook_list' %}">Мої записники</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" class="dropdown-item text-danger">Вийти</button>
</form>
</li>
</ul>
</div>
</header>
<!-- Django Messages → Bootstrap Alerts -->
{% if messages %}
<div class="px-4 pt-3">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
<!-- PAGE CONTENT -->
<main class="flex-grow-1 p-4">
{% block content %}{% endblock %} ← дочірні шаблони підставляють контент
</main>
</div>
<!-- /MAIN -->
</div>
{% endblock %}
Крок 4 — Сторінки¶
Місце: hello_app/templates/hello_app/
Дочірні шаблони (рівень 3) пишуть тільки контент. Sidebar, topbar, Bootstrap — безкоштовно.
Приклад note_list.html:
{% extends 'layouts/dashboard.html' %}
{% block topbar_title %}Мої нотатки{% endblock %} ← тільки заголовок
{% block content %}
{# Тільки контент сторінки — sidebar/topbar вже є #}
{% if notes %}
<div class="row row-cols-1 row-cols-md-2 g-3">
{% for note in notes %}
<div class="col">
<div class="card h-100">
...
</div>
</div>
{% endfor %}
</div>
{% else %}
{% include 'components/empty_state.html' with icon="bi-journal" message="Ще немає нотаток" %}
{% endif %}
{% endblock %}
Крок 5 — Tier 1: Raw¶
Мета: показати що є у Django "з коробки", без жодних стилів.
Tier 1 реалізовано у
notes_project— там форми без crispy. Код нижче — для порівняння концепцій.
# views.py
@login_required
def note_create_raw(request):
if request.method == 'POST':
form = NoteForm(request.POST, user=request.user)
if form.is_valid():
# той самий service що і в Tier 3
note = services.create_note(
user=request.user,
**form.cleaned_data_for_service(),
)
return redirect('hello_app:note_detail', pk=note.pk)
else:
form = NoteForm(user=request.user)
return render(request, 'hello_app/note_form_raw.html', {
'form': form,
'title': 'Нова нотатка (Raw)',
'current_tier': 'raw',
})
<!-- note_form_raw.html -->
{% extends 'layouts/dashboard.html' %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form.as_p }} ← Django генерує <p><label><input></p> для кожного поля
<button type="submit" class="btn btn-warning">Зберегти</button>
</form>
{% endblock %}
Що as_p генерує для title:
<p>
<label for="id_title">Заголовок:</label>
<input type="text" name="title" maxlength="200" required id="id_title">
</p>
Немає class="form-control". Немає Bootstrap. Виглядає погано, але функціонує коректно.
Крок 6 — Tier 2: Manual¶
Мета: показати повний Bootstrap HTML — щоб зрозуміти від чого рятує Tier 3.
Tier 2 реалізовано у
django_bootstrap_project— там форми з ручним Bootstrap HTML. Код нижче — для порівняння концепцій.
<!-- note_form_manual.html — дивись скільки коду для ОДНОГО поля -->
<form method="post">
{% csrf_token %}
<!-- Поле: title ← 14 рядків HTML тільки для одного поля -->
<div class="mb-3">
<label for="{{ form.title.id_for_label }}" class="form-label fw-semibold">
{{ form.title.label }}
{% if form.title.field.required %}<span class="text-danger">*</span>{% endif %}
</label>
<input type="text"
name="{{ form.title.html_name }}"
id="{{ form.title.id_for_label }}"
value="{{ form.title.value|default:'' }}"
placeholder="Назва нотатки..."
class="form-control {% if form.title.errors %}is-invalid{% endif %}"
autofocus>
{% if form.title.errors %}
{% for error in form.title.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
<!-- ... ще ~65 рядків для priority, notebook, content, tags, is_pinned ... -->
<button type="submit" class="btn btn-secondary">Зберегти</button>
</form>
Підрахунок рядків:
title: 14 рядків
priority: 12 рядків (select)
notebook: 14 рядків (select + queryset)
content: 12 рядків (textarea)
tags: 14 рядків (multiple select)
is_pinned: 10 рядків (checkbox)
buttons: 3 рядки
─────────────────
ВСЬОГО: ~80 рядків HTML тільки для форми
Помнож на 5 форм у застосунку → 400 рядків дублювання. Змінив Bootstrap-версію → правиш 400 рядків.
Крок 7 — Tier 3: Crispy¶
Мета: замінити 80 рядків HTML на 10 рядків Python.
forms.py — повний код NoteForm¶
# hello_app/forms.py
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
Layout, Submit, Row, Column,
Fieldset, HTML, Div, Field
)
from .models import Note, Notebook, Tag
class NoteForm(forms.ModelForm):
class Meta:
model = Note
fields = ['title', 'content', 'priority', 'notebook', 'tags', 'is_pinned']
# ↑ НІ ЖОДНИХ widget attrs — crispy додає Bootstrap-класи автоматично!
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
# Security: фільтруємо FK по поточному user
if user is not None:
self.fields['notebook'].queryset = Notebook.objects.filter(user=user)
self.fields['tags'].queryset = Tag.objects.filter(user=user)
# FormHelper — описуємо форму Python-кодом
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_id = 'note-form'
# Layout — структура полів
self.helper.layout = Layout(
# Секція 1: основна інформація
Fieldset('Основна інформація',
Field('title', placeholder='Назва нотатки...', autofocus=True),
Row(
Column('priority', css_class='col-md-4'),
Column('notebook', css_class='col-md-8'),
),
),
# Секція 2: зміст
Fieldset('Зміст нотатки',
Field('content', rows=8, placeholder='Текст нотатки...'),
),
# Секція 3: теги та опції
Fieldset('Теги та параметри',
'tags',
Div(Field('is_pinned'), css_class='form-check mt-2'),
),
# Розділювач
HTML('<hr class="my-4">'),
# Кнопки
Submit('submit', 'Зберегти нотатку', css_class='btn btn-primary me-2'),
HTML('<a href="javascript:history.back()" class="btn btn-outline-secondary">Скасувати</a>'),
)
note_form.html — весь шаблон¶
{% extends 'layouts/dashboard.html' %}
{% load crispy_forms_tags %}
{% block topbar_title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h6 class="mb-0"><i class="bi bi-journal-plus me-2"></i>{{ title }}</h6>
</div>
<div class="card-body">
{% crispy form %}
{# ↑ Один тег замість 80 рядків HTML.
FormHelper + Layout у forms.py описують ВСЮ Bootstrap-розмітку. #}
</div>
</div>
</div>
</div>
{% endblock %}
Що робить {% crispy form %} покроково¶
1. {% load crispy_forms_tags %} — реєструє тег
2. {% crispy form %} — викликає do_uni_form() в crispy_forms_tags.py
3. Знаходить form.helper — наш FormHelper з Layout
4. Рендерить form.helper.layout:
├── Fieldset('Основна інформація', ...) →
│ <fieldset>
│ <legend>Основна інформація</legend>
│ ...поля...
│ </fieldset>
│
├── Field('title', placeholder='...') →
│ <div class="mb-3">
│ <label for="id_title" class="form-label">Заголовок *</label>
│ <input type="text" name="title" id="id_title"
│ class="form-control" placeholder="Назва нотатки..."
│ autofocus>
│ </div>
│
├── Row(Column('priority', css_class='col-md-4'), ...) →
│ <div class="row">
│ <div class="col-md-4">...priority field...</div>
│ <div class="col-md-8">...notebook field...</div>
│ </div>
│
└── Submit('submit', 'Зберегти') →
<input type="submit" value="Зберегти" class="btn btn-primary me-2">
5. Додає <form method="post"> та {% csrf_token %} автоматично
Порівняння views для Tier 3¶
# views.py — view для Tier 3 нічим особливим не відрізняється від Tier 2!
# Crispy — це виключно рівень PRESENTATION, не бізнес-логіки.
@login_required
def note_create(request):
if request.method == 'POST':
form = NoteForm(request.POST, user=request.user)
if form.is_valid():
note = services.create_note(
user=request.user,
title=form.cleaned_data['title'],
content=form.cleaned_data['content'],
priority=form.cleaned_data['priority'],
notebook=form.cleaned_data.get('notebook'),
tag_ids=[t.pk for t in form.cleaned_data.get('tags', [])],
is_pinned=form.cleaned_data.get('is_pinned', False),
)
messages.success(request, f'Нотатку "{note.title}" створено!')
return redirect('hello_app:note_detail', pk=note.pk)
else:
form = NoteForm(user=request.user)
return render(request, 'hello_app/note_form.html', {
'form': form,
'title': 'Нова нотатка',
'current_tier': 'crispy',
})
Висновок:
services.create_note()— та сама функція зnotes_project. Crispy не змінює бізнес-логіку. Він змінює тільки те як форма виглядає.
Патерн: form_tag = False — вбудовані форми¶
Іноді форма є частиною більшої сторінки, а не окремою сторінкою. Наприклад: список завдань + inline-форма для додавання нового завдання на тій самій сторінці.
Проблема: якщо у шаблоні вже є <form action="/todo/5/items/add/">, а crispy теж рендерить
<form> — виходить nested <form> — невалідний HTML, браузери ігнорують внутрішній <form>.
Рішення: self.helper.form_tag = False — crispy рендерить тільки поля та кнопки,
але не <form>...</form> обгортку. Шаблон сам надає тег форми.
# forms.py — TodoItemForm: вбудовується всередині <form> що є в шаблоні
class TodoItemForm(forms.Form):
text = forms.CharField(max_length=500, label='Завдання')
due_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'type': 'date'}))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False # ← не рендерити <form>...</form>
self.helper.form_show_labels = False # ← без label (компактний вигляд)
self.helper.layout = Layout(
Row(
Column(Field('text', placeholder='Нове завдання...'), css_class='col-md-7'),
Column(Field('due_date'), css_class='col-md-3'),
Column(Submit('submit', '+ Додати', css_class='btn btn-primary w-100'),
css_class='col-md-2'),
),
)
<!-- todo_detail.html — шаблон надає <form> тег, crispy — тільки поля -->
<form method="post" action="{% url 'hello_app:todo_item_add' list.pk %}">
{% csrf_token %}
{% crispy item_form %} ← рендерить Row з полями та кнопкою, але БЕЗ <form>
</form>
| Сценарій | form_tag |
|---|---|
| Форма займає окрему сторінку (note_create, note_edit) | True (за замовчуванням) |
| Форма вбудована у сторінку (inline add item) | False |
Крок 8 — Context Processor¶
# hello_app/context_processors.py
from django.db import OperationalError
from django.db.models import Q
from .models import TodoList, ShoppingList
from . import selectors
def sidebar_context(request):
"""
Автоматично додає sidebar_notebooks, sidebar_tags і лічильники задач у кожен шаблон.
Виконується при кожному запиті для автентифікованих користувачів.
"""
if not request.user.is_authenticated:
return {
'sidebar_notebooks': [], 'sidebar_tags': [],
'sidebar_todo_count': 0, 'sidebar_shopping_count': 0,
}
user = request.user
# try/except — захист якщо міграція ще не застосована (нові M2M таблиці)
try:
todo_count = TodoList.objects.filter(
Q(user=user) | Q(shared_with=user), is_completed=False
).distinct().count()
shopping_count = ShoppingList.objects.filter(
Q(user=user) | Q(shared_with=user)
).distinct().count()
except OperationalError:
todo_count = 0
shopping_count = 0
return {
'sidebar_notebooks': selectors.get_user_notebooks(user),
'sidebar_tags': selectors.get_user_tags(user),
'sidebar_todo_count': todo_count, # ← badge у "Список справ"
'sidebar_shopping_count': shopping_count, # ← badge у "Покупки"
}
Навіщо
try/except OperationalError? Context processor виконується при кожному запиті. Якщо міграція для нової таблиці ще не застосована (наприклад, під час розробки післяgit pull), QuerySet впаде зOperationalError: no such table. Guard дозволяє серверу продовжити роботу — лічильники просто повернуть 0 замість 500 помилки.
Реєстрація у settings.py:
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'hello_app.context_processors.sidebar_context', # ← наш
],
Використання у будь-якому шаблоні (без передачі з View):
Важливо: Context processor виконується при кожному HTTP запиті. Тому selector повинен бути ефективним —
select_related,annotate, не N+1.
Крок 9 — Components¶
Місце: crispy_notes_project/templates/components/
Компоненти — повторювані блоки UI, що використовуються через {% include %}.
components/empty_state.html¶
{# Порожній стан — "у вас ще немає нотаток" #}
<div class="text-center py-5">
<i class="bi {{ icon|default:'bi-inbox' }} display-1 text-muted"></i>
<p class="text-muted mt-3">{{ message }}</p>
{% if action_url %}
<a href="{{ action_url }}" class="btn btn-primary">{{ action_label }}</a>
{% endif %}
</div>
Використання:
{% if not notes %}
{% include 'components/empty_state.html' with
icon="bi-journal"
message="Ще немає нотаток"
action_url=note_create_url
action_label="Створити першу" %}
{% endif %}
components/pagination.html¶
{# Bootstrap Pagination #}
{% if page_obj.has_other_pages %}
<nav>
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">←</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
<li class="page-item {% if page_obj.number == num %}active{% endif %}">
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
</li>
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">→</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
components/confirm_modal.html¶
{# Bootstrap Modal для підтвердження видалення #}
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title">Підтвердити видалення</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
Ви впевнені що хочете видалити <strong id="deleteItemName"></strong>?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Скасувати</button>
<form id="deleteForm" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Видалити</button>
</form>
</div>
</div>
</div>
</div>
Структура файлів¶
crispy_notes_project/
│
├── requirements.txt ← django-crispy-forms, crispy-bootstrap5, debug-toolbar
│
├── hello_project/
│ ├── settings.py ← CRISPY_TEMPLATE_PACK, context_processors, MESSAGE_TAGS
│ └── urls.py ← accounts/ (login/logout) + hello_app/ + debug_toolbar
│
├── hello_app/
│ ├── models.py ← Note, Notebook, Tag, TodoList, TodoItem,
│ │ ShoppingList, ShopItem, Reminder
│ ├── services.py ← create/update/delete для всіх моделей
│ ├── selectors.py ← get_user_notes, get_user_notebooks, get_user_tags,
│ │ get_todo_lists, get_shopping_lists...
│ ├── context_processors.py ← sidebar_context → notebooks, tags,
│ │ todo_count, shopping_count (+ OperationalError guard)
│ ├── forms.py ← ★ NoteForm, NotebookForm, TagForm,
│ │ TodoListForm, TodoItemForm (form_tag=False),
│ │ ShoppingListForm, ShopItemForm (form_tag=False),
│ │ ReminderForm, ShareForm
│ ├── views.py ← note CRUD + todo CRUD + shopping CRUD + reminders + register
│ ├── urls.py
│ ├── migrations/
│ │ ├── 0001_initial.py
│ │ └── 0002_shoppinglist_shared_with_todolist_shared_with.py ← M2M shared_with
│ └── templates/hello_app/
│ ├── note_list.html ← extends layouts/dashboard.html
│ ├── note_detail.html ← з inline ReminderForm (form_action у view)
│ ├── note_form.html ← Tier 3: {% crispy form %} ★
│ ├── note_confirm_delete.html
│ ├── notebook_list.html
│ ├── notebook_form.html ← {% crispy form %}
│ ├── tag_form.html ← {% crispy form %}
│ ├── todo_list.html ← мої + спільні списки справ
│ ├── todo_detail.html ← inline TodoItemForm (form_tag=False)
│ ├── todo_form.html ← {% crispy form %}
│ ├── todo_confirm_delete.html
│ ├── shopping_list.html
│ ├── shopping_detail.html ← inline ShopItemForm (form_tag=False)
│ ├── shopping_form.html ← {% crispy form %}
│ ├── shopping_confirm_delete.html
│ └── share_form.html ← ShareForm (додати юзера до shared_with)
│
└── templates/ ← ГЛОБАЛЬНІ шаблони (DIRS в settings)
├── base.html ← Рівень 1: HTML5 + Bootstrap CDN
├── layouts/
│ └── dashboard.html ← Рівень 2: Sidebar (НОТАТКИ/ЗАДАЧІ/ТЕГИ) + Topbar
├── registration/
│ ├── login.html ← Bootstrap card, посилання на register
│ └── register.html ← реєстрація нового користувача
└── components/
├── pagination.html ← Bootstrap pagination
├── empty_state.html ← "немає даних" placeholder
└── confirm_modal.html ← Bootstrap modal для delete
Підсумок: що і де вчити¶
| Концепція | Де дивитись у коді |
|---|---|
| Template Inheritance | base.html → layouts/dashboard.html → note_list.html |
| FormHelper + Layout | hello_app/forms.py — NoteForm.__init__ |
| 3 рівні форм | notes_project (Tier 1) · django_bootstrap_project (Tier 2) · /notes/new/ (Tier 3) |
| form_tag = False | forms.py — TodoItemForm, ShopItemForm (inline forms) |
| Context Processor | hello_app/context_processors.py + settings.py TEMPLATES |
| OperationalError guard | context_processors.py — try/except для нових таблиць |
| Active nav state | dashboard.html — request.resolver_match.url_name |
| Sidebar секції | dashboard.html — НОТАТКИ / ЗАДАЧІ / ТЕГИ з sidebar-label |
| M2M shared_with | models.py TodoList.shared_with + міграція 0002 |
| Django Messages | dashboard.html блок {% if messages %} + settings.py MESSAGE_TAGS |
| Components | templates/components/ + {% include %} у сторінках |
| Services/Selectors | services.py / selectors.py (детально у notes_project/README.md) |
Документація¶
| Файл | Опис |
|---|---|
../DESIGN_FOUNDATIONS.md |
UX, CRAP, Bootstrap, ієрархія шаблонів, Crispy — теорія |
../CRISPY_FORMS.md |
Повний довідник: FormHelper, Layout, Bootstrap компоненти, Dynamic API |
../ADVANCED_TEMPLATES.md §3 |
FormHelper + Layout — деталі |
../ADVANCED_TEMPLATES.md §5 |
SaaS Dashboard Architecture |
../../lesson_Django_ORM_Database/notes_project/README.md |
ORM, Services/Selectors, міграції — фундамент цього проєкту |