Туторіал 04 — Templates, Crispy Forms і SaaS Dashboard¶
Цей туторіал будує 3-рівневу Template Inheritance, переходить від ручного Bootstrap HTML до
{% crispy form %}, реалізує SaaS Dashboard із Sidebar через Context Processor.Результат:
Зміст¶
Теорія (читати перед кодом) - 01 · TEMPLATE SOUP — проблема яку вирішуємо - 02 · 3-РІВНЕВА ІЄРАРХІЯ — base → dashboard → page - 03 · CRISPY FORMS — три рівні рендерингу форм - 04 · CONTEXT PROCESSORS — дані для всіх шаблонів - 05 · STATIC FILES — власні CSS/JS - 06 · TEMPLATE TAGS і FILTERS — довідник
Покрокова реалізація 1. Крок 1 — Встановити Crispy Forms 2. Крок 2 — Рівень 1: base.html 3. Крок 3 — Рівень 2: layouts/dashboard.html 4. Крок 4 — Рівень 3: сторінки контенту 5. Крок 5 — NoteForm з FormHelper і Layout 6. Крок 6 — Context Processor для Sidebar 7. Крок 7 — Active state у навігації 8. Крок 8 — Реєстрація в settings.py
01 · Template Soup¶
Що таке Template Soup і чому це проблема?
Без Template Inheritance¶
<!-- note_list.html — 150 рядків! -->
<!DOCTYPE html>
<html>
<head>
<title>Нотатки</title>
<link rel="stylesheet" href="...bootstrap.css"> ← COPY-PASTE
<link rel="stylesheet" href="...bootstrap-icons.css">
</head>
<body>
<nav class="navbar navbar-dark bg-dark"> ← COPY-PASTE
<a class="navbar-brand" href="/">CrispyNotes</a>
...20 рядків навігації...
</nav>
<main class="container my-4">
<!-- 30 рядків реального контенту -->
</main>
<footer>CrispyNotes © 2026</footer> ← COPY-PASTE
<script src="...bootstrap.bundle.js"></script> ← COPY-PASTE
</body>
</html>
<!-- note_form.html — ще 150 рядків! -->
<!DOCTYPE html>
<html> ← Точна копія структури!
...
Проблема: Зміна navbar → правити 20 файлів. Одна помилка (наприклад, забув </nav>) → ламає всі 20 сторінок.
З Template Inheritance¶
<!-- note_list.html — 10 рядків! -->
{% extends 'layouts/dashboard.html' %}
{% block title %}Нотатки{% endblock %}
{% block topbar_title %}Мої нотатки{% endblock %}
{% block content %}
<!-- тільки реальний контент -->
{% endblock %}
Перевага: Зміна navbar — тільки layouts/dashboard.html. Всі 20 сторінок оновились автоматично.
02 · 3-рівнева ієрархія¶
base.html ← Рівень 1: HTML скелет + Bootstrap CDN
│ {% block body %}
│
└── layouts/dashboard.html ← Рівень 2: Sidebar + Topbar + Messages
{% extends 'base.html' %}
{% block body %}
<div class="d-flex">
<nav>Sidebar</nav>
<main>{% block content %}{% endblock %}</main>
</div>
{% endblock %}
│
├── note_list.html ← Рівень 3: Контент (5-10 рядків)
├── note_form.html ← {% crispy form %} — 1 рядок!
├── note_detail.html
└── notebook_list.html
Що кожен рівень відповідає¶
| Рівень | Файл | Відповідальність | Змінюється при... |
|---|---|---|---|
| 1 | base.html |
HTML5 структура, Bootstrap CDN, Bootstrap JS | Зміна CDN версії, додавання глобального CSS |
| 2 | layouts/dashboard.html |
Sidebar, Topbar, Messages, SaaS layout | Зміна навігації, нові пункти меню, новий sidebar |
| 3 | notes_app/*.html |
Специфічний контент сторінки | Логіка конкретної фічі |
Правило: batch.super¶
{# Якщо дочірній хоче РОЗШИРИТИ блок (не перезаписати): #}
{% block extra_css %}
{{ block.super }} {# ← вставляє вміст батьківського блоку #}
<link rel="stylesheet" href="{% static 'css/notes-editor.css' %}">
{% endblock %}
{# Результат: CSS з layouts/dashboard.html + CSS цієї сторінки #}
{# Без block.super: CSS layouts/dashboard.html буде ЗАМІНЕНИЙ #}
03 · Crispy Forms¶
Три рівні рендерингу Django форм: від примітивного до elegant.
Tier 1 — Raw Django (для debug тільки)¶
<!-- Що генерується: без Bootstrap класів, виглядає погано -->
<p><label for="id_title">Заголовок:</label>
<input type="text" name="title" id="id_title" maxlength="200" required>
</p>
<p><label for="id_content">Зміст:</label>
<textarea name="content" id="id_content" rows="10"></textarea>
</p>
Tier 2 — Ручний Bootstrap¶
# forms.py — додаємо Bootstrap класи вручну
class NoteFormTier2(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'})
self.fields['is_pinned'].widget.attrs['class'] = 'form-check-input'
<!-- note_form.html — 15+ рядків на КОЖНЕ поле -->
<div class="mb-3">
<label for="{{ form.title.id_for_label }}" class="form-label fw-semibold">
{{ form.title.label }}<span class="text-danger">*</span>
</label>
{{ form.title }}
{% if form.title.help_text %}
<div class="form-text">{{ form.title.help_text }}</div>
{% endif %}
{% for error in form.title.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
</div>
<!-- × 6 полів = 90+ рядків тільки для форми -->
Tier 3 — Crispy Forms FormHelper + Layout¶
# forms.py — вся структура форми у Python-коді
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
Layout, Fieldset, Row, Column, Field, Submit, HTML, ButtonHolder
)
class NoteForm(forms.ModelForm):
class Meta:
model = Note
fields = ['title', 'content', 'priority', 'notebook', 'tags', 'is_pinned']
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
# Обмежити queryset notebook і tags тільки цим юзером:
if user:
self.fields['notebook'].queryset = Notebook.objects.filter(user=user)
self.fields['tags'].queryset = Tag.objects.filter(user=user)
# FormHelper описує <form> атрибути і layout
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_id = 'note-form'
self.helper.layout = Layout(
# Fieldset → <fieldset><legend>Основна інформація</legend>...</fieldset>
Fieldset('Основна інформація',
Field('title',
placeholder='Назва нотатки...',
autofocus=True,
css_class='form-control-lg', # більший input
),
Row(
Column('priority', css_class='col-md-4'),
# → <div class="col-md-4">...</div>
Column('notebook', css_class='col-md-8'),
),
),
# Другий Fieldset — зміст і теги
Fieldset('Зміст та організація',
Field('content', rows=8, placeholder='Текст нотатки...'),
Field('tags'),
Field('is_pinned'),
),
# HTML — довільний HTML у layout
HTML('<hr class="my-3">'),
# Кнопки
ButtonHolder(
Submit('submit', 'Зберегти нотатку', css_class='btn btn-primary me-2'),
HTML('<a href="{{ cancel_url }}" class="btn btn-outline-secondary">Скасувати</a>'),
),
)
{# note_form.html — 1 рядок замість 90! #}
{% extends 'layouts/dashboard.html' %}
{% load crispy_forms_tags %}
{% block title %}{{ title }}{% endblock %}
{% block topbar_title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-10 col-lg-8">
<div class="card shadow-sm">
<div class="card-body p-4">
{% crispy form %}
{#
crispy генерує автоматично:
<form method="post" id="note-form">
{% csrf_token %}
<fieldset>
<legend>Основна інформація</legend>
<div class="mb-3">
<label class="form-label">Назва *</label>
<input class="form-control form-control-lg" ...>
</div>
<div class="row">
<div class="col-md-4">...</div>
<div class="col-md-8">...</div>
</div>
</fieldset>
...
<button class="btn btn-primary">Зберегти нотатку</button>
</form>
#}
</div>
</div>
</div>
</div>
{% endblock %}
Layout Elements — довідник¶
from crispy_forms.layout import *
# Базові елементи:
Field('field_name') # Поле з label і errors
Field('field_name', css_class='custom') # + кастомний клас
Field('field_name', placeholder='...') # + HTML атрибут
Field('field_name', rows=8) # Textarea rows
# Структурні:
Row(Column('f1', css_class='col-6'), Column('f2', css_class='col-6'))
# → <div class="row"><div class="col-6">f1</div><div class="col-6">f2</div></div>
Fieldset('Заголовок', Field('f1'), Field('f2'))
# → <fieldset><legend>Заголовок</legend>f1 f2</fieldset>
Div(Field('f1'), css_class='custom-div')
# → <div class="custom-div">f1</div>
HTML('<hr><p>Довільний HTML</p>')
# Кнопки:
Submit('submit', 'Зберегти')
# → <input type="submit" class="btn btn-primary" value="Зберегти">
Submit('submit', 'Зберегти', css_class='btn btn-success')
# → кастомний клас
ButtonHolder(Submit('s', 'OK'), HTML('<a href="#">Cancel</a>'))
# → обгортка для кнопок
04 · Context Processors¶
Проблема: Sidebar показує список записників і тегів на КОЖНІЙ сторінці. Якщо передавати їх з кожного view — дублювання коду у 20 місцях.
Як Context Processors працюють¶
HTTP Request
↓
Middleware pipeline
↓
View функція (view_func)
↓
render(request, 'template.html', {'notes': notes}) ← view context
↓
Django Template Engine:
1. Збирає context з view: {'notes': notes}
2. АВТОМАТИЧНО виконує кожен context_processor(request) із settings.py
3. Merged context = view context + processor contexts
4. Рендерить шаблон з merged context
Всі context_processors:
django.template.context_processors.debug → {{ debug }}
django.template.context_processors.request → {{ request }}
django.contrib.auth.context_processors.auth → {{ user }}, {{ perms }}
django.contrib.messages.context_processors.messages → {{ messages }}
hello_app.context_processors.sidebar_context → {{ sidebar_notebooks }}, {{ sidebar_tags }}
Написати Context Processor¶
hello_app/context_processors.py:
from . import selectors
def sidebar_context(request):
"""
Автоматично додає до КОЖНОГО шаблону:
sidebar_notebooks → QuerySet записників юзера
sidebar_tags → QuerySet тегів юзера
Умова: тільки для залогінених юзерів.
Для AnonymousUser повертаємо {} (порожній словник).
"""
if not request.user.is_authenticated:
return {} # ← AnonymousUser не має записників/тегів
return {
'sidebar_notebooks': selectors.get_user_notebooks(request.user),
'sidebar_tags': selectors.get_user_tags(request.user),
}
Коли context_processors НЕ підходять¶
# Не використовуй context_processor для:
# 1. Тяжких SQL запитів (виконуються на КОЖНІЙ сторінці)
# 2. Змінних специфічних для однієї сторінки (pass in render context)
# 3. Даних що потребують параметрів URL (url kwargs недоступні)
# Для важкого sidebar: кешування
from django.core.cache import cache
def sidebar_context(request):
if not request.user.is_authenticated:
return {}
cache_key = f'sidebar_notebooks_{request.user.id}'
notebooks = cache.get(cache_key)
if notebooks is None:
notebooks = list(selectors.get_user_notebooks(request.user))
cache.set(cache_key, notebooks, timeout=300) # 5 хвилин
return {'sidebar_notebooks': notebooks}
05 · Static Files¶
Власні CSS/JS/зображення — статичні файли Django.
Де Django шукає статику¶
1. APP_DIRS (STATICFILES_FINDERS):
hello_app/static/hello_app/style.css
→ URL: /static/hello_app/style.css
2. STATICFILES_DIRS у settings.py:
BASE_DIR / 'static'
→ Глобальна папка static/
3. collectstatic (production):
python manage.py collectstatic
→ Копіює в STATIC_ROOT → nginx serve
Підключення власного CSS¶
# settings.py:
STATIC_URL = '/static/'
STATICFILES_DIRS = [BASE_DIR / 'static'] # Глобальна папка для розробки
STATIC_ROOT = BASE_DIR / 'staticfiles' # Production: collectstatic
{# У шаблоні: #}
{% load static %}
<link rel="stylesheet" href="{% static 'hello_app/css/custom.css' %}">
<img src="{% static 'hello_app/images/logo.png' %}" alt="Logo">
<script src="{% static 'hello_app/js/notes.js' %}"></script>
{# {% static 'path' %} генерує: /static/path #}
{# В production (з CDN): може генерувати https://cdn.example.com/static/path #}
06 · Template Tags і Filters — довідник¶
Built-in Filters¶
{# Рядки: #}
{{ title|truncatechars:50 }} → Перші 50 символів + "..."
{{ title|truncatewords:10 }} → Перші 10 слів + "..."
{{ text|linebreaks }} → \n → <br><p>
{{ text|linebreaksbr }} → \n → <br>
{{ text|striptags }} → Прибрати HTML теги
{{ html|safe }} → Не екранувати (⚠️ XSS якщо user input!)
{{ value|escape }} → Екранувати HTML (за замовчуванням)
{{ email|urlize }} → URL/email → <a href>
{# Числа: #}
{{ count|floatformat:2 }} → 3.14159 → "3.14"
{{ count|filesizeformat }} → 1024 → "1.0 KB"
{{ price|intcomma }} → 1000000 → "1,000,000"
{# Дати: #}
{{ date|date:"d.m.Y" }} → 13.06.2026
{{ date|date:"D, d M Y H:i" }} → Fri, 13 Jun 2026 10:00
{{ date|time:"H:i" }} → 10:00
{{ date|timesince }} → "5 годин тому" / "2 дні тому"
{{ date|timeuntil }} → "3 дні" (до майбутнього)
{{ date|naturalday }} → "сьогодні" / "вчора" (humanize)
{# Списки: #}
{{ items|length }} → 42
{{ items|first }} → перший елемент
{{ items|last }} → останній елемент
{{ items|join:", " }} → "one, two, three"
{{ items|slice:":5" }} → перші 5 елементів
{{ items|dictsort:"name" }} → відсортувати за name
{# Логіка: #}
{{ value|default:"Не вказано" }} → якщо False/None/empty
{{ value|default_if_none:"—" }} → тільки якщо None
{{ value|yesno:"так,ні,може" }} → True→так, False→ні, None→може
{# URL: #}
{% load static %}
{% static 'hello_app/logo.png' %}
{% url 'hello_app:note_list' %}
{% url 'hello_app:note_detail' pk=note.pk %}
Template Tags¶
{# Умовні: #}
{% if condition %}
{% elif other %}
{% else %}
{% endif %}
{% if user.is_authenticated %}
{% if note.priority == 3 %}
{% if note.is_pinned and not note.is_archived %}
{% if tag in note.tags.all %}
{# Цикли: #}
{% for item in items %}
{{ forloop.counter }} ← 1-based
{{ forloop.counter0 }} ← 0-based
{{ forloop.revcounter }} ← зворотній
{{ forloop.first }} ← True тільки перший
{{ forloop.last }} ← True тільки останній
{{ forloop.parentloop }} ← вкладені цикли
{% empty %}
<p>Немає елементів</p>
{% endfor %}
{# Шаблони: #}
{% extends 'base.html' %}
{% block name %}...{% endblock %}
{% block name %}{{ block.super }}...{% endblock %}
{% include 'hello_app/_card.html' with note=note only %}
{# only: передати тільки вказані змінні, не весь context #}
{# Завантаження бібліотек: #}
{% load static %}
{% load django_bootstrap5 %}
{% load crispy_forms_tags %}
{% load humanize %}
{# Потрібно для naturalday, intcomma тощо #}
{# CSRF: #}
{% csrf_token %}
{# Генерує: <input type="hidden" name="csrfmiddlewaretoken" value="...abc..."> #}
{# URL: #}
{% url 'hello_app:note_detail' pk=note.pk %}
{% url 'hello_app:note_list' %}
{# Коментарі (не потрапляють у HTML): #}
{# Однорядковий коментар #}
{% comment %}
Багаторядковий коментар
Не рендериться!
{% endcomment %}
Крок 1 — Встановити Crispy Forms¶
hello_project/settings.py:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'crispy_forms', # ← core пакет
'crispy_bootstrap5', # ← Bootstrap 5 template pack
'hello_app',
]
CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
CRISPY_TEMPLATE_PACK = 'bootstrap5'
# ↑ Вказує crispy_forms який template pack використовувати
# bootstrap5 → генерує Bootstrap 5 HTML (mb-3, form-control, form-label...)
Крок 2 — Рівень 1: base.html¶
templates/base.html — тільки HTML скелет і Bootstrap CDN. Нічого про Sidebar.
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}CrispyNotes{% endblock %}</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
crossorigin="anonymous">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
{% block extra_css %}{% endblock %}
{# Дочірній може додати специфічний CSS для сторінки #}
</head>
<body>
{% block body %}{% endblock %}
{# Весь body = один великий блок → dashboard.html заповнить Sidebar+Topbar+Content #}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous" defer></script>
{% block extra_js %}{% endblock %}
{# Дочірній може додати специфічний JS (Leaflet, Chart.js тощо) #}
</body>
</html>
base.html не знає нічого про Sidebar або навігацію — тільки Bootstrap CDN і блоки.
Крок 3 — Рівень 2: layouts/dashboard.html¶
templates/layouts/dashboard.html — SaaS-layout з фіксованим Sidebar.
{% extends 'base.html' %}
{% block body %}
<!--
d-flex: body = flex рядок (sidebar | main)
vh-100: займає 100% висоти вікна
overflow-hidden: контролюємо scroll всередині .flex-grow-1
-->
<div class="d-flex vh-100 overflow-hidden">
<!-- ══════════════════ SIDEBAR ══════════════════ -->
<nav id="sidebar"
class="bg-dark text-white d-flex flex-column p-3 flex-shrink-0"
style="width: 260px; min-width: 260px;">
<!-- Brand -->
<a href="{% url 'hello_app:note_list' %}"
class="text-white text-decoration-none fw-bold fs-5 mb-4 d-block">
<i class="bi bi-journal-text me-2 text-primary"></i>CrispyNotes
</a>
<!-- Навігація: нотатки -->
<div class="mb-3">
<small class="text-uppercase text-secondary d-block mb-1 px-2" style="font-size:.7rem">
Нотатки
</small>
<ul class="nav flex-column">
<li class="nav-item">
<a href="{% url 'hello_app:note_list' %}"
class="nav-link text-white py-1
{% if request.resolver_match.url_name == 'note_list' %}
active bg-primary rounded
{% endif %}">
<i class="bi bi-list-ul me-2"></i>Всі нотатки
</a>
{#
request.resolver_match.url_name:
Повертає name= параметр поточного URL з urls.py.
'note_list' → підсвічує пункт коли юзер на /notes/
#}
</li>
<li class="nav-item">
<a href="{% url 'hello_app:note_create' %}"
class="nav-link text-white py-1">
<i class="bi bi-plus-circle me-2"></i>Нова нотатка
</a>
</li>
</ul>
</div>
<!-- Записники (з Context Processor) -->
{% if sidebar_notebooks %}
<div class="mb-3">
<small class="text-uppercase text-secondary d-block mb-1 px-2" style="font-size:.7rem">
Записники
</small>
<ul class="nav flex-column">
{% for nb in sidebar_notebooks %}
<li class="nav-item">
<a href="{% url 'hello_app:note_list' %}?notebook={{ nb.pk }}"
class="nav-link text-white-50 py-1 small">
<i class="bi bi-journal me-2"></i>
{{ nb.title|truncatechars:18 }}
{% if nb.note_count %}
<span class="badge bg-secondary ms-1 float-end">{{ nb.note_count }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Теги (з Context Processor) -->
{% if sidebar_tags %}
<div class="mb-auto">
<small class="text-uppercase text-secondary d-block mb-2 px-2" style="font-size:.7rem">
Теги
</small>
<div class="d-flex flex-wrap gap-1 px-2">
{% 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 }}; font-size: .75rem;">
#{{ tag.name }}
{% if tag.note_count %}
<span class="ms-1">({{ tag.note_count }})</span>
{% endif %}
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Юзер знизу sidebar -->
<div class="mt-auto pt-3 border-top border-secondary">
{% if request.user.is_authenticated %}
<div class="d-flex align-items-center gap-2">
<div class="rounded-circle bg-primary d-flex align-items-center justify-content-center"
style="width:32px; height:32px; flex-shrink:0;">
<span class="text-white small fw-bold">
{{ request.user.username|first|upper }}
</span>
</div>
<div class="text-truncate">
<div class="text-white small fw-semibold">{{ request.user.username }}</div>
<div class="text-white-50" style="font-size:.7rem;">{{ request.user.email }}</div>
</div>
</div>
<form method="post" action="{% url 'logout' %}" class="mt-2">
{% csrf_token %}
<button type="submit" class="btn btn-outline-secondary btn-sm w-100">
<i class="bi bi-box-arrow-right me-1"></i>Вийти
</button>
</form>
{% endif %}
</div>
</nav>
<!-- ══════════════════ END SIDEBAR ══════════════════ -->
<!-- ══════════════════ MAIN AREA ══════════════════ -->
<div class="flex-grow-1 d-flex flex-column overflow-hidden">
<!-- Topbar -->
<header class="border-bottom bg-white px-4 py-2 d-flex align-items-center justify-content-between flex-shrink-0">
<h1 class="h5 mb-0 fw-semibold">
{% block topbar_title %}CrispyNotes{% endblock %}
</h1>
<div class="d-flex align-items-center gap-2">
<!-- Пошук (опціонально) -->
{% block topbar_actions %}{% endblock %}
</div>
</header>
<!-- Django Messages -->
{% if messages %}
<div class="px-4 pt-2">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mb-2" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Контент сторінки -->
<main class="flex-grow-1 overflow-auto p-4">
{% block content %}{% endblock %}
</main>
</div>
<!-- ══════════════════ END MAIN AREA ══════════════════ -->
</div>
{% endblock %}
Крок 4 — Рівень 3: сторінки¶
Нова сторінка = 5 рядків. Sidebar і Topbar з'являються безкоштовно.
{# hello_app/templates/hello_app/note_list.html #}
{% extends 'layouts/dashboard.html' %}
{% block title %}Мої нотатки{% endblock %}
{% block topbar_title %}Нотатки{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0">
Нотатки <span class="badge bg-secondary">{{ notes|length }}</span>
</h2>
<a href="{% url 'hello_app:note_create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>Нова
</a>
</div>
{% if notes %}
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-3">
{% for note in notes %}
<div class="col">
<div class="card h-100 shadow-sm border-0">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<h5 class="card-title mb-0 small fw-semibold">
<a href="{% url 'hello_app:note_detail' pk=note.pk %}"
class="text-decoration-none text-dark">
{% if note.is_pinned %}<i class="bi bi-pin-fill text-warning me-1"></i>{% endif %}
{{ note.title|truncatechars:45 }}
</a>
</h5>
{% if note.priority == 3 %}
<span class="badge bg-danger-subtle text-danger ms-1 flex-shrink-0">Важливо</span>
{% elif note.priority == 2 %}
<span class="badge bg-warning-subtle text-warning ms-1 flex-shrink-0">Середнє</span>
{% endif %}
</div>
<p class="card-text text-muted small mt-1">
{{ note.content|truncatechars:80 }}
</p>
{% if note.tags.all %}
<div class="mt-2">
{% for tag in note.tags.all %}
<a href="?tag={{ tag.pk }}"
class="badge text-decoration-none me-1"
style="background-color: {{ tag.color }};">
#{{ tag.name }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
<div class="card-footer bg-transparent border-0 pt-0 pb-2 px-3">
<small class="text-muted">
<i class="bi bi-clock me-1"></i>{{ note.updated_at|timesince }} тому
</small>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-journal-x display-1 text-muted d-block mb-3"></i>
<h4 class="text-muted">Ще немає нотаток</h4>
<a href="{% url 'hello_app:note_create' %}" class="btn btn-primary mt-2">Створити першу</a>
</div>
{% endif %}
{% endblock %}
Крок 5 — NoteForm з FormHelper¶
hello_app/forms.py:
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Column, Field, Fieldset, HTML, Layout, Row, Submit
from django import forms
from .models import Note, Notebook, Tag
class NoteForm(forms.ModelForm):
class Meta:
model = Note
fields = ['title', 'content', 'priority', 'notebook', 'tags', 'is_pinned']
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
# Обмежуємо queryset тільки даним юзером (IDOR захист)
if user is not None:
self.fields['notebook'].queryset = Notebook.objects.filter(user=user).order_by('title')
self.fields['tags'].queryset = Tag.objects.filter(user=user).order_by('name')
self.fields['notebook'].empty_label = '— без записника —'
self.helper = FormHelper()
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'),
),
Field('content', rows=8, placeholder='Текст нотатки...'),
),
Fieldset('Організація',
Field('tags'),
Field('is_pinned'),
),
HTML('<hr class="my-3">'),
Submit('submit', 'Зберегти нотатку', css_class='btn btn-primary me-2'),
HTML('<a href="{% url \'hello_app:note_list\' %}" class="btn btn-outline-secondary">Скасувати</a>'),
)
Крок 6 — Context Processor¶
hello_app/context_processors.py:
from . import selectors
def sidebar_context(request):
"""
Sidebar дані для КОЖНОГО шаблону.
Виконується автоматично перед кожним render.
"""
if not request.user.is_authenticated:
return {} # AnonymousUser → порожній dict
return {
'sidebar_notebooks': selectors.get_user_notebooks(request.user),
'sidebar_tags': selectors.get_user_tags(request.user),
}
Крок 7 — Active state у навігації¶
{# Підсвітити поточний пункт меню: #}
<a href="{% url 'hello_app:note_list' %}"
class="nav-link text-white
{% if request.resolver_match.url_name == 'note_list' %}
active bg-primary rounded
{% endif %}">
Всі нотатки
</a>
{# request.resolver_match.url_name = 'note_list' коли URL = /notes/ #}
{# = 'note_create' коли /notes/new/ #}
{# = 'note_detail' коли /notes/42/ #}
{# Для namespace aware перевірки: #}
{% if request.resolver_match.view_name == 'hello_app:note_list' %}
{# Включає namespace, більш точно #}
{# Для групи URL (наприклад, всі note_* URLs): #}
{% if 'note' in request.resolver_match.url_name %}
{# note_list, note_detail, note_edit → всі підсвічені #}
Крок 8 — Реєстрація в settings.py¶
# hello_project/settings.py
INSTALLED_APPS = [
...
'crispy_forms',
'crispy_bootstrap5',
'hello_app', # context_processors.sidebar_context тут
]
CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
CRISPY_TEMPLATE_PACK = 'bootstrap5'
TEMPLATES = [{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'], # Глобальна templates/ (для base.html, layouts/)
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'hello_app.context_processors.sidebar_context', # ← наш processor
# Тепер sidebar_notebooks і sidebar_tags доступні у ВСІХ шаблонах!
],
},
}]
Розташування файлів¶
hello_project/ ← Корінь проєкту
├── hello_project/
│ ├── settings.py ← CRISPY_*, TEMPLATES з context_processors
│ └── urls.py
│
├── templates/ ← Глобальна папка (DIRS у settings)
│ ├── base.html ← Рівень 1: HTML + Bootstrap CDN
│ └── layouts/
│ └── dashboard.html ← Рівень 2: Sidebar + Topbar
│
└── hello_app/
├── context_processors.py ← sidebar_context()
├── forms.py ← NoteForm з FormHelper
├── selectors.py
├── services.py
├── views.py
└── templates/
└── hello_app/ ← Рівень 3: сторінки контенту
├── note_list.html
├── note_form.html ← {% crispy form %} — 1 рядок!
├── note_detail.html
└── note_confirm_delete.html
Як Django знаходить шаблон¶
render(request, 'hello_app/note_list.html', context)
↓
Django шукає у DIRS:
1. BASE_DIR/templates/hello_app/note_list.html → не знайдено
Django шукає у APP_DIRS (кожен INSTALLED_APP):
2. hello_app/templates/hello_app/note_list.html → ЗНАЙДЕНО ✓
Завантажує note_list.html:
Зустрічає {% extends 'layouts/dashboard.html' %}
Django шукає layouts/dashboard.html:
1. BASE_DIR/templates/layouts/dashboard.html → ЗНАЙДЕНО ✓
Завантажує dashboard.html:
Зустрічає {% extends 'base.html' %}
Django шукає base.html:
1. BASE_DIR/templates/base.html → ЗНАЙДЕНО ✓
Рендеринг від base.html → dashboard.html → note_list.html
Блоки заповнюються від конкретного до загального.
Практичне завдання¶
- Відкрий
hello_app/templates/hello_app/note_list.html→templates/layouts/dashboard.html→templates/base.html. Відслідкуй ланцюг{% extends %}. - Додай новий пункт у Sidebar для "Архів" (
/notes/?archived=1). - Перепиши одну форму з ручного Bootstrap HTML на Crispy FormHelper + Layout.
- Перевір active state: натискаючи різні пункти меню — підсвітка переміщається.
- Додай Context Processor що передає кількість нових нотаток за сьогодні.
Чеклист самоперевірки¶
- [ ] 3 рівні:
base.html→layouts/dashboard.html→ page template - [ ]
{% block body %}уbase.html,{% block content %}уdashboard.html - [ ] Context Processor зареєстрований у
settings.py→context_processors - [ ]
sidebar_notebooksіsidebar_tagsдоступні у шаблоні без явної передачі - [ ]
{% crispy form %}рендерить форму з Bootstrap-класами - [ ]
FormHelper.layoutописує структуру форми Python-кодом - [ ] Active state через
request.resolver_match.url_name - [ ] Logout — POST форма (не GET посилання) для захисту від CSRF
- [ ]
DIRS = [BASE_DIR / 'templates']уTEMPLATES— для глобальних шаблонів
Підсумок¶
| Концепція | Де у коді |
|---|---|
| 3-рівнева ієрархія | base.html → layouts/dashboard.html → note_list.html |
{% block %} |
{% block content %}{% endblock %} у батьку + {% block content %}...{% endblock %} у дочірньому |
{{ block.super }} |
Додає вміст батьківського блоку замість заміни |
| FormHelper | self.helper = FormHelper() у forms.py.__init__ |
| Layout | Layout(Fieldset(...), Row(Column(...)), Submit(...)) |
{% crispy form %} |
У шаблоні — 1 рядок замість 90 |
| Context Processor | hello_app/context_processors.py → sidebar_context(request) |
| Реєстрація процесора | settings.py TEMPLATES OPTIONS context_processors |
| Active nav | request.resolver_match.url_name == 'note_list' |
| Static files | {% load static %}{% static 'hello_app/css/custom.css' %} |
Далі¶
Наступний крок: 05 — Автентифікація та Безпека — Login/logout, sessions, password reset, IDOR, Groups.
Модулі документації: - Frontend and Templates - Forms and Validation - Application Architecture