Django Template Language, теги, фильтры, наследование, custom tags
Шаблон — это слой представления в архитектуре MTV. Его задача — превратить данные, подготовленные во view, в HTML (или текст, XML, CSV). DTL намеренно сделан ограниченным: писать произвольный Python в нём нельзя. Ограничение тут осознанное — оно удерживает бизнес-логику во view и не даёт шаблону превратиться в свалку из вычислений и запросов.
💡 Правило: Шаблон отвечает на вопрос «как показать», view — «что показать». Если в шаблоне появляется расчёт, фильтрация или обращение к внешнему сервису — это сигнал, что логику пора вынести во view, менеджер модели или template tag.
Прежде чем писать {{ }}, важно понять, что происходит «под капотом»:
render(request, 'blog/post_list.html', context).TEMPLATES['DIRS'] и по папкам templates/ каждого приложения из INSTALLED_APPS (если APP_DIRS=True).# settings.py
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'], # глобальные шаблоны проекта
'APP_DIRS': True, # искать templates/ внутри приложений
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]⚠️ Частая ошибка: два приложения с файлом
templates/index.html— выигрывает то, что выше вINSTALLED_APPS. Поэтому шаблоны приложения принято класть в неймспейс-папку:myapp/templates/myapp/index.html.
{{ переменная }} подставляет значение из контекста. Точка . — это универсальный оператор поиска, и порядок резолва важен:
{{ post.title }}Django пробует по очереди:
| Шаг | Что пробует | Пример |
|---|---|---|
| 1 | Поиск по ключу словаря | post['title'] |
| 2 | Поиск атрибута/метода | post.title или post.title() |
| 3 | Индекс списка | post.0 |
Ключевое отличие от Python: метод вызывается автоматически и без скобок, но только если он не требует аргументов.
{{ post.get_absolute_url }} {# вызовет метод, скобки НЕ нужны #}
{{ posts.count }} {# queryset.count() — лишний запрос в БД! #}⚠️ Если переменной нет в контексте, DTL подставит пустую строку, а не ошибку (управляется
string_if_invalid). Это удобно, но маскирует опечатки — в названии переменной легко ошибиться и не заметить.
Теги {% %} дают логику. Важнейшие:
{# Условия #}
{% if post.is_published %}
<span class="badge">Опубликовано</span>
{% elif post.is_draft %}
<span class="badge badge-muted">Черновик</span>
{% else %}
<span class="badge badge-warning">В архиве</span>
{% endif %}
{# Циклы #}
{% for post in posts %}
<article>{{ post.title }}</article>
{% empty %}
<p>Постов пока нет.</p> {# выполнится, если posts пуст #}
{% endfor %}Внутри {% for %} доступен объект forloop — это часто экономит логику:
| Переменная | Значение |
|---|---|
forloop.counter | Номер итерации с 1 |
forloop.counter0 | Номер итерации с 0 |
forloop.first | True на первой итерации |
forloop.last | True на последней |
forloop.parentloop | forloop внешнего цикла (для вложенных) |
{% for post in posts %}
<tr class="{% if forloop.first %}highlight{% endif %}">
<td>{{ forloop.counter }}</td>
<td>{{ post.title }}</td>
</tr>
{% endfor %}💡
{% url 'post_detail' slug=post.slug %}строит URL по имени маршрута, а не хардкодит путь. Меняетеurls.py— шаблоны не ломаются. Никогда не пишите<a href="/posts/{{ post.slug }}/">.
Фильтр через | меняет отображение значения, не трогая данные. Их можно соединять в цепочку:
{{ name|lower }}
{{ post.content|truncatewords:30 }}
{{ post.created_at|date:"d.m.Y H:i" }}
{{ price|floatformat:2 }}
{{ post.title|default:"Без названия" }}
{{ tags|join:", " }}
{{ comment.text|truncatewords:20|capfirst }}| Фильтр | Назначение |
|---|---|
date:"d.m.Y" | Форматирование даты |
truncatewords:N / truncatechars:N | Обрезка по словам / символам |
default:"—" | Значение, если переменная пуста/falsy |
default_if_none:"—" | Значение только если None |
pluralize | Окончание множественного числа |
linebreaks / linebreaksbr | Переводы строк → <p> / <br> |
length | Длина списка/строки |
yesno:"да,нет,—" | True/False/None → текст |
DTL по умолчанию экранирует HTML — это встроенная защита от XSS:
{{ user_input }} {# <script> станет <script> — безопасно #}
{{ trusted_html|safe }} {# отключает экранирование — только для доверенного! #}⚠️
|safeи{% autoescape off %}— самая частая дыра XSS в Django-проектах. Применяйте|safeтолько к HTML, который сгенерировали вы сами (например, отрендеренный markdown после санитизации черезbleach), и никогда — к данным от пользователя.
Наследование — главный механизм переиспользования. Базовый шаблон задаёт «скелет» с дырами-block, дочерние их заполняют. Это устраняет копипасту шапки/подвала по всем страницам.
{# base.html — каркас сайта #}
<!DOCTYPE html>
<html lang="ru">
<head>
<title>{% block title %}Мой блог{% endblock %}</title>
{% block extra_head %}{% endblock %}
</head>
<body>
{% include "partials/navbar.html" %}
<main>
{% block content %}{% endblock %}
</main>
{% include "partials/footer.html" %}
{% block scripts %}{% endblock %}
</body>
</html>{# post_list.html — наследник #}
{% extends "base.html" %}
{% block title %}Все посты — {{ block.super }}{% endblock %}
{% block content %}
{% for post in posts %}
<article>
<h2><a href="{% url 'post_detail' post.slug %}">{{ post.title }}</a></h2>
<p>{{ post.excerpt|default:post.content|truncatewords:40 }}</p>
</article>
{% endfor %}
{% endblock %}| Тег | Что делает |
|---|---|
{% extends "base.html" %} | Наследует каркас. Должен быть первой строкой шаблона |
{% block name %}...{% endblock %} | Переопределяемая область |
{{ block.super }} | Вставляет содержимое блока родителя |
{% include "x.html" %} | Вставляет другой шаблон (переиспользуемые куски) |
{% include "x.html" with title="Hi" only %} | Передаёт явный контекст, only изолирует от внешнего |
💡
includevsextends:extends— «я наследник этого каркаса» (один на шаблон, сверху вниз).include— «вставь сюда этот фрагмент» (сколько угодно, для карточек, виджетов, форм).
Когда стандартных фильтров не хватает, пишут свои. Структура обязательна: пакет templatetags/ с __init__.py внутри приложения.
blog/
├── templatetags/
│ ├── __init__.py
│ └── blog_extras.py
# blog/templatetags/blog_extras.py
from django import template
from django.utils import timezone
register = template.Library()
@register.filter
def time_ago(value):
"""{{ post.created_at|time_ago }} → '3 часа назад'."""
delta = timezone.now() - value
hours = delta.total_seconds() // 3600
if hours < 1:
return 'только что'
if hours < 24:
return f'{int(hours)} ч назад'
return f'{delta.days} дн назад'
@register.simple_tag
def active_link(request, url_name):
"""Подсветка активного пункта меню: class="{% active_link request 'home' %}"."""
from django.urls import resolve
return 'active' if resolve(request.path_info).url_name == url_name else ''
@register.inclusion_tag('blog/_latest_posts.html')
def latest_posts(count=5):
"""Рендерит виджет последних постов из своего шаблона."""
from blog.models import Post
return {'posts': Post.objects.published()[:count]}{% load blog_extras %}
{{ post.created_at|time_ago }}
<a class="{% active_link request 'home' %}" href="/">Главная</a>
{% latest_posts 3 %}| Тип | Когда использовать |
|---|---|
@register.filter | Преобразование одного значения: `value |
@register.simple_tag | Логика, возвращающая строку/значение |
@register.inclusion_tag | Тег рендерит собственный шаблон-фрагмент |
⚠️ После создания нового тега перезапустите runserver —
templatetagsподгружаются при старте, автоперезагрузка их не всегда подхватывает.
{% cache %}:{% load cache %}
{% cache 600 sidebar request.user.id %}
{# дорогой блок: рендерится раз в 10 минут на пользователя #}
{% latest_posts 10 %}
{% endcache %}{{ posts.count }} или {{ post.comments.all }} внутри цикла — это N+1. Готовьте данные во view через annotate, select_related, prefetch_related.TEMPLATES[0]['OPTIONS']['loaders'] = [
('django.template.loaders.cached.Loader', [
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
]),
]
# Несовместимо с APP_DIRS=True — уберите APP_DIRS при ручном указании loaders.{# templates/base.html #}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>{% block title %}Блог{% endblock %}</title>
</head>
<body>
{% include "partials/messages.html" %}
<div class="layout">
<main>{% block content %}{% endblock %}</main>
<aside>{% block sidebar %}{% latest_posts 5 %}{% endblock %}</aside>
</div>
</body>
</html>{# templates/partials/messages.html #}
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li class="msg msg-{{ message.tags }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}{# templates/blog/post_detail.html #}
{% extends "base.html" %}
{% load blog_extras %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
<article>
<h1>{{ post.title }}</h1>
<p class="meta">
{{ post.author.get_full_name|default:post.author.username }},
{{ post.created_at|time_ago }}
</p>
<div class="body">{{ post.content_html|safe }}</div>
<section class="comments">
<h3>{{ comments|length }} коммент{{ comments|length|pluralize:"арий,ария,ариев" }}</h3>
{% for comment in comments %}
<div class="comment">
<strong>{{ comment.author }}</strong>: {{ comment.text|linebreaksbr }}
</div>
{% empty %}
<p>Будьте первым, кто оставит комментарий.</p>
{% endfor %}
</section>
</article>
{% endblock %}post.author.name) и что метод вызывается без скобок{% url %}, а не хардкодить путиextends + block) вместо копипастыinclude и extends|safefilter, simple_tag, inclusion_tagВопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.
Далее: Формы и Виджеты