راهنمای کامل ایجاد قالب در جنگو: از ساختار تا ترفندهای حرفهای
چطور یک قالب جنگو را از صفر بسازیم و کدام روشها در پروژههای واقعی جواب میدهند؟
ساختار پوشهها و تنظیمات TEMPLATES
در پروژههای بزرگ ترکیب پوشه سراسری templates/ و پوشه هر اپ جواب میدهد. اگر با تگ های جنگو آشنا نیستید، اول آن را مرور کنید.
myproject/
├── config/
│ ├── settings/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── dev.py
│ │ └── prod.py
│ ├── urls.py
│ ├── asgi.py
│ └── wsgi.py
├── templates/
│ ├── base.html
│ ├── base_public.html
│ ├── base_dashboard.html
│ ├── base_email.html
│ ├── partials/
│ │ ├── header.html
│ │ ├── footer.html
│ │ ├── messages.html
│ │ ├── pagination.html
│ │ ├── sidebar.html
│ │ └── breadcrumbs.html
│ └── pages/
│ ├── home.html
│ ├── about.html
│ └── contact.html
├── blog/
│ └── templates/
│ └── blog/
│ ├── list.html
│ ├── detail.html
│ └── partials/
│ ├── card.html
│ └── meta.html
├── locale/
│ ├── fa/LC_MESSAGES/django.po
│ └── en/LC_MESSAGES/django.po
├── static/
│ ├── css/
│ ├── js/
│ └── img/
├── media/
├── manage.py
└── requirements.txt
# config/settings/base.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize",
"django.contrib.sitemaps",
"blog",
"myapp",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.template.context_processors.i18n",
"django.template.context_processors.static",
"django.template.context_processors.media",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"myapp.context_processors.site_settings",
],
"builtins": [
"django.templatetags.i18n",
"django.templatetags.static",
"django.templatetags.l10n",
],
"debug": False,
"string_if_invalid": "",
},
},
]
# Language
USE_I18N = True
USE_L10N = True
USE_TZ = True
LANGUAGE_CODE = "fa"
TIME_ZONE = "Asia/Tehran"
LANGUAGES = [
("fa", "فارسی"),
("en", "English"),
]
LOCALE_PATHS = [BASE_DIR / "locale"]
# Static & media
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# Storage
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
# config/settings/dev.py
from .base import *
DEBUG = True
ALLOWED_HOSTS = ["*"]
TEMPLATES[0]["OPTIONS"]["debug"] = True
TEMPLATES[0]["OPTIONS"]["string_if_invalid"] = "❌MISSING:%s"
INTERNAL_IPS = ["127.0.0.1"]
# django-debug-toolbar
INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
# config/settings/prod.py
from .base import *
DEBUG = False
ALLOWED_HOSTS = ["wordpresskar.ir", "www.wordpresskar.ir"]
# Cached loader
TEMPLATES[0]["OPTIONS"]["loaders"] = [
(
"django.template.loaders.cached.Loader",
[
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
),
]
# Manifest static files برای cache busting
STORAGES["staticfiles"] = {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
}
# امنیت
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Email
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
لودرها و ترتیب جستوجوی قالب
جنگو با لیست لودرها کار میکند و به ترتیب آنها را امتحان میکند. برای دیباگ، دانستن این ترتیب حیاتی است.
# ترتیب پیشفرض
1. django.template.loaders.filesystem.Loader # از DIRS
2. django.template.loaders.app_directories.Loader # از اپها
# در production با کش
"loaders": [
(
"django.template.loaders.cached.Loader",
[
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
),
]
# myapp/template_loaders.py
from django.template import Origin, TemplateDoesNotExist
from django.template.loaders.base import Loader
from django.utils._os import safe_join
class DatabaseTemplateLoader(Loader):
"""لودر قالب از دیتابیس برای CMS داینامیک."""
display_name = "Database Loader"
def get_contents(self, origin):
from myapp.models import DBTemplate
try:
tpl = DBTemplate.objects.get(
name=origin.name,
is_active=True,
)
except DBTemplate.DoesNotExist:
raise TemplateDoesNotExist(origin)
# ثبت زمان آخرین استفاده
DBTemplate.objects.filter(pk=tpl.pk).update(
last_used_at=__import__("django.utils.timezone",
fromlist=["now"]).now()
)
return tpl.content
def get_template_sources(self, template_name):
yield Origin(
name=template_name,
template_name=template_name,
loader=self,
)
# myapp/models.py (بخشی از مدل)
from django.db import models
class DBTemplate(models.Model):
name = models.CharField(max_length=200, unique=True, db_index=True)
content = models.TextField()
is_active = models.BooleanField(default=True)
updated_at = models.DateTimeField(auto_now=True)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "قالب داینامیک"
verbose_name_plural = "قالبهای داینامیک"
def __str__(self):
return self.name
اولین قالب: از ویو تا رندر
# blog/models.py
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True, allow_unicode=True)
excerpt = models.TextField(blank=True)
body = models.TextField()
is_published = models.BooleanField(default=False, db_index=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title, allow_unicode=True)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("blog:post_detail", kwargs={"slug": self.slug})
def __str__(self):
return self.title
# blog/views.py
from django.shortcuts import render, get_object_or_404
from django.core.paginator import Paginator
from django.db.models import Q
from .models import Post
def post_list(request):
qs = (
Post.objects
.filter(is_published=True)
.only("id", "title", "slug", "excerpt", "created_at")
.order_by("-created_at")
)
q = request.GET.get("q", "").strip()
if q:
qs = qs.filter(Q(title__icontains=q) | Q(body__icontains=q))
paginator = Paginator(qs, 12)
page_obj = paginator.get_page(request.GET.get("page"))
return render(request, "blog/list.html", {
"page_obj": page_obj,
"q": q,
})
def post_detail(request, slug):
post = get_object_or_404(
Post.objects.filter(is_published=True),
slug=slug,
)
return render(request, "blog/detail.html", {"post": post})
# blog/urls.py
from django.urls import path
from . import views
app_name = "blog"
urlpatterns = [
path("", views.post_list, name="post_list"),
path("<slug:slug>/", views.post_detail, name="post_detail"),
]
{# blog/templates/blog/list.html #}
{% extends "base.html" %}
{% block title %}لیست پست ها{% endblock %}
{% block meta %}
<meta name="description" content="آخرین پست های سایت">
{% endblock %}
{% block content %}
<h1>آخرین پست ها</h1>
<form method="get" class="search">
<input type="text" name="q" value="{{ q }}" placeholder="جستجو...">
<button type="submit">بگرد</button>
</form>
<div class="post-grid">
{% for post in page_obj %}
{% include "blog/partials/card.html" with post=post only %}
{% empty %}
<p class="empty">پستی یافت نشد.</p>
{% endfor %}
</div>
{% include "partials/pagination.html" with page_obj=page_obj only %}
{% endblock %}
{# blog/templates/blog/partials/card.html #}
<article class="card">
<h3>
<a href="{{ post.get_absolute_url }}">{{ post.title }}</a>
</h3>
{% if post.excerpt %}
<p>{{ post.excerpt|truncatechars:120 }}</p>
{% endif %}
<time datetime="{{ post.created_at|date:'c' }}">
{{ post.created_at|date:"Y-m-d" }}
</time>
</article>
قواعد lookup و دسترسی به متغیرها
ترتیب جستوجوی نقطهای در موتور قالب جنگو:
1. foo["bar"] # lookup دیکشنری
2. foo.bar # attribute
3. foo.bar() # اگر callable بود، صدا زده میشود
4. foo[bar] # lookup با مقدار bar بهعنوان کلید
5. fail silently # برمیگردد به string_if_invalid
{{ user.username }}
{{ user.profile.bio }}
{{ post.tags.all }} {# QuerySet #}
{{ post.tags.all|length }}
{{ settings.SITE_NAME }} {# از دیکشنری #}
{{ my_dict.key }}
{{ my_list.0 }} {# ایندکس #}
{{ my_list.0.name }}
{{ request.GET.q }}
{{ request.GET.urlencode }}
{{ request.META.HTTP_USER_AGENT }}
{# دسترسی ایمن به مقادیر تودرتو #}
{% if user.profile and user.profile.avatar %}
<img src="{{ user.profile.avatar.url }}" alt="{{ user.username }}">
{% else %}
<img src="{% static 'img/avatar-default.png' %}" alt="پیشفرض">
{% endif %}
{# استفاده از firstof برای مقادیر جایگزین #}
{% firstof user.get_full_name user.username user.email "کاربر مهمان" %}
تگ های ضروری در یک نگاه
{% if %}{% elif %}{% else %}{% endif %}
{% for %}{% empty %}{% endfor %}
{% extends %}
{% block %}
{% include %}
{% url %}
{% static %}
{% csrf_token %}
{% with %}
{% load %}
{% comment %}{% endcomment %}
{% verbatim %}{% endverbatim %}
{% autoescape %}
{% filter %}
{% firstof %}
{% now %}
{% cycle %}
{% ifchanged %}
{% regroup %}
{% widthratio %}
{% templatetag %}
{% spaceless %}{% endspaceless %}
{% localize %}
{% timezone %}
{# firstof #}
{% firstof user.get_full_name user.username "کاربر مهمان" %}
{# now #}
{% now "Y-m-d" %}
{% now "H:i:s" %}
{% now "j F Y" %}
{% now "D" as today_name %}{{ today_name }}
{# cycle #}
{% for row in rows %}
<tr class="{% cycle 'odd' 'even' %}">
<td>{{ row.name }}</td>
</tr>
{% endfor %}
{# cycle با نام #}
{% cycle 'a' 'b' as rowclass silent %}
{# spaceless #}
{% spaceless %}
<ul>
<li>آیتم ۱</li>
</ul>
{% endspaceless %}
{# filter #}
{% filter lower|truncatewords:10 %}
این متن بعد از اعمال فیلترها کوتاه و کوچک میشود
{% endfilter %}
{# verbatim برای قالب های JS #}
{% verbatim %}
<div id="app">{{ vueMessage }}</div>
{% endverbatim %}
{# regroup #}
{% regroup products by category as grouped %}
{% for group in grouped %}
<h3>{{ group.grouper }}</h3>
{% for p in group.list %}
<li>{{ p.name }}</li>
{% endfor %}
{% endfor %}
{# widthratio #}
{% widthratio done total 100 %}%
{# templatetag برای چاپ خود نشانه #}
{% templatetag openblock %} if x {% templatetag closeblock %}
تگ ifchanged را در راهنمای ifchanged در جنگو جداگانه توضیح دادهام و فیلترها را در راهنمای فیلترهای قالب.
تگ if و عملگرها
{% if user.is_authenticated and user.is_staff %}...{% endif %}
{% if a or b %}...{% endif %}
{% if not user.is_active %}...{% endif %}
{% if item in cart_items %}...{% endif %}
{% if count > 10 %}...{% endif %}
{% if price <= 100000 %}...{% endif %}
{% if status == 'published' %}...{% endif %}
{% if lang != 'en' %}...{% endif %}
{% if x is None %}...{% endif %}
{% if x is not None %}...{% endif %}
{% if user.is_authenticated %}
{% if user.is_superuser %}
<a href="{% url 'admin:index' %}">پنل مدیر ارشد</a>
{% elif user.is_staff %}
<a href="{% url 'admin:index' %}">پنل مدیریت</a>
{% else %}
<a href="{% url 'profile' %}">پروفایل</a>
{% endif %}
{% else %}
<a href="{% url 'login' %}">ورود</a>
<a href="{% url 'register' %}">ثبتنام</a>
{% endif %}
{# شناسایی دستگاه #}
{% if request.user_agent.is_mobile %}
<a href="tel:{{ phone }}">تماس</a>
{% elif request.user_agent.is_tablet %}
<a href="/tablet-app/">نسخه تبلت</a>
{% else %}
<a href="/desktop/">نسخه دسکتاپ</a>
{% endif %}
حلقه for و forloop کامل
{% for item in items %}
{{ forloop.counter }} {# 1, 2, 3, ... #}
{{ forloop.counter0 }} {# 0, 1, 2, ... #}
{{ forloop.revcounter }} {# N, N-1, ..., 1 #}
{{ forloop.revcounter0 }} {# N-1, ..., 0 #}
{{ forloop.first }} {# True فقط در اولین #}
{{ forloop.last }} {# True فقط در آخرین #}
{{ forloop.parentloop }} {# ارجاع به حلقه بیرونی #}
{{ forloop.length }} {# تعداد کل آیتمها #}
{% endfor %}
{# حلقه با فیلتر و ترتیب #}
{% for post in posts|dictsort:"created_at" reversed %}
<li>{{ post.title }}</li>
{% endfor %}
{% for tag in post.tags.all|slice:":5" %}
<span>{{ tag }}</span>
{% endfor %}
{# حلقه تودرتو #}
{% for category in categories %}
<h2>{{ category.name }}</h2>
<ul>
{% for product in category.products.all %}
<li>
{{ forloop.parentloop.counter }}.{{ forloop.counter }}
{{ product.name }}
</li>
{% empty %}
<li>محصولی ندارد</li>
{% endfor %}
</ul>
{% endfor %}
{# حلقه روی دیکشنری #}
{% for key, value in my_dict.items %}
<li>{{ key }}: {{ value }}</li>
{% endfor %}
{# unpacking تاپل #}
{% for name, age in people %}
<li>{{ name }} ({{ age }})</li>
{% endfor %}
{# کامل: جدول با zebra و last-row #}
<table>
{% for order in orders %}
<tr class="
{% cycle 'odd' 'even' %}
{% if forloop.last %}last-row{% endif %}
">
<td>{{ forloop.counter }}</td>
<td>{{ order.code }}</td>
<td>{{ order.total|floatformat:0 }}</td>
</tr>
{% empty %}
<tr><td colspan="3">سفارشی وجود ندارد.</td></tr>
{% endfor %}
</table>
فیلترهای پرکاربرد
{# رشته #}
{{ name|upper }}
{{ name|lower }}
{{ name|title }}
{{ name|capfirst }}
{{ text|truncatechars:80 }}
{{ text|truncatewords:20 }}
{{ text|linebreaks }}
{{ text|linebreaksbr }}
{{ value|default:"-" }}
{{ value|default_if_none:"-" }}
{{ html|safe }}
{{ s|escape }}
{{ s|escapejs }}
{{ s|slugify }}
{{ s|striptags }}
{{ s|wordcount }}
{{ s|urlencode }}
{{ s|iriencode }}
{{ s|linebreaksbr }}
{{ s|cut:" " }}
{{ s|slice:":5" }}
{{ s|add:" suffix" }}
{{ s|center:"20" }}
{{ s|ljust:"20" }}
{{ s|rjust:"20" }}
{{ s|stringformat:"05d" }}
{{ path|urlize }}
{{ path|urlizetrunc:30 }}
{{ s|json_script:"data" }}
{# اعداد #}
{{ price|floatformat:0 }}
{{ value|filesizeformat }}
{{ value|filesizeformat:true }}
{{ n|add:"5" }}
{{ n|divisibleby:"3" }}
{{ n|yesno:"بله,نه,نامعلوم" }}
{# تاریخ #}
{{ created|date:"Y-m-d H:i" }}
{{ created|time:"H:i" }}
{{ created|timesince }}
{{ created|timeuntil }}
{{ d|date:"j F Y" }}
{{ d|date:"D، j M" }}
{{ d|date:"g:i A" }}
{{ d|date:"Y-m-dTH:i:s" }}
{{ d|date:"SHORT_DATE_FORMAT" }}
{{ d|date:"DATETIME_FORMAT" }}
{# لیست #}
{{ list|length }}
{{ list|join:", " }}
{{ list|first }}
{{ list|last }}
{{ list|random }}
{{ list|dictsort:"name" }}
{{ list|dictsortreversed:"name" }}
{# humanize #}
{% load humanize %}
{{ 1234567|intcomma }}
{{ 1200000|intword }}
{{ 3|apnumber }}
{{ 3|ordinal }}
{{ d|naturaltime }}
{{ d|naturalday }}
{# فیلتر json_script برای انتقال ایمن داده به JS #}
{{ my_data|json_script:"app-data" }}
<script>
const data = JSON.parse(
document.getElementById("app-data").textContent
);
console.log(data);
</script>
# myapp/templatetags/persian.py
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
PERSIAN = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
ARABIC = str.maketrans("0123456789", "٠١٢٣٤٥٦٧٨٩")
@register.filter
def persian_digits(value):
"""تبدیل ارقام لاتین به فارسی."""
if value is None:
return ""
return str(value).translate(PERSIAN)
@register.filter
def arabic_digits(value):
if value is None:
return ""
return str(value).translate(ARABIC)
@register.filter
def persian_intcomma(value):
"""۱۲۳,۴۵۶ → ۱۲۳٬۴۵۶ با جداکننده فارسی."""
try:
s = f"{int(value):,}"
except (TypeError, ValueError):
return value
s = s.replace(",", "٬")
return s.translate(PERSIAN)
@register.filter
def persian_currency(value, unit="تومان"):
try:
n = f"{int(value):,}".replace(",", "٬").translate(PERSIAN)
except (TypeError, ValueError):
return value
return f"{n} {unit}"
@register.filter
def persian_ordinal(value):
"""عدد فارسی به همراه پسوند ترتیبی."""
mapping = {
1: "اول", 2: "دوم", 3: "سوم", 4: "چهارم", 5: "پنجم",
6: "ششم", 7: "هفتم", 8: "هشتم", 9: "نهم", 10: "دهم",
}
try:
return mapping[int(value)]
except (KeyError, ValueError, TypeError):
return str(value).translate(PERSIAN)
@register.filter
def jalali(value, fmt="%Y/%m/%d"):
"""تبدیل تاریخ میلادی به شمسی (نیازمند jdatetime)."""
try:
import jdatetime
if hasattr(value, "date"):
value = value.date()
return jdatetime.date.fromgregorian(date=value).strftime(fmt)
except Exception:
return value
وراثت قالب: extends، block، include
{# templates/base.html #}
{% load static i18n %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE|default:'fa' }}" dir="{% if LANGUAGE_BIDI %}rtl{% else %}ltr{% endif %}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<title>{% block title %}{{ SITE_NAME }}{% endblock %}</title>
{% block meta %}
<meta name="description" content="{% block meta_description %}{% endblock %}">
<link rel="canonical" href="{% block canonical %}{{ request.build_absolute_uri }}{% endblock %}">
{% endblock %}
<link rel="stylesheet" href="{% static 'css/app.css' %}">
{% block head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
{% include "partials/header.html" %}
<main class="container">
{% include "partials/breadcrumbs.html" %}
{% include "partials/messages.html" %}
{% block content %}{% endblock %}
</main>
{% include "partials/footer.html" %}
<script src="{% static 'js/app.js' %}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
{# blog/templates/blog/detail.html #}
{% extends "base.html" %}
{% load i18n humanize %}
{% block title %}{{ post.title }} | {{ block.super }}{% endblock %}
{% block meta_description %}{{ post.excerpt|truncatechars:155 }}{% endblock %}
{% block body_class %}post-detail{% endblock %}
{% block content %}
<article itemscope itemtype="https://schema.org/Article">
<header>
<h1 itemprop="headline">{{ post.title }}</h1>
<div class="meta">
<time itemprop="datePublished" datetime="{{ post.created_at|date:'c' }}">
{{ post.created_at|naturaltime }}
</time>
<span>{% trans "توسط" %} {{ post.author.get_full_name }}</span>
</div>
</header>
<div itemprop="articleBody">
{{ post.body|safe }}
</div>
<footer>
{% include "blog/partials/tags.html" with tags=post.tags.all only %}
</footer>
</article>
{% endblock %}
{% block scripts %}
{{ block.super }}
<script src="{% static 'js/highlight.js' %}"></script>
{% endblock %}
{# include با only برای جداسازی context #}
{% include "partials/card.html" with post=post only %}
{# با لیست پارامتر #}
{% include "partials/alert.html" with type="warning" title="توجه" message="پیام مهم" only %}
{# include داینامیک #}
{% include "partials/"|add:widget_type|add:".html" with widget=widget only %}
{# وراثت سه سطحی #}
{# base.html → base_dashboard.html → dashboard/index.html #}
{# base_dashboard.html #}
{% extends "base.html" %}
{% block body_class %}dashboard-layout{% endblock %}
{% block content %}
<div class="dashboard-wrapper">
{% include "partials/sidebar.html" %}
<section class="dashboard-main">
{% block dashboard_content %}{% endblock %}
</section>
</div>
{% endblock %}
{# dashboard/index.html #}
{% extends "base_dashboard.html" %}
{% block dashboard_content %}
<h1>داشبورد</h1>
{% endblock %}
{# block.super #}
{# base.html #}
{% block scripts %}
<script src="{% static 'js/core.js' %}"></script>
{% endblock %}
{# صفحه فرزند #}
{% block scripts %}
{{ block.super }}
<script src="{% static 'js/page.js' %}"></script>
{% endblock %}
فایل های استاتیک و media
{% load static %}
<link rel="stylesheet" href="{% static 'css/app.css' %}">
<link rel="icon" href="{% static 'img/favicon.ico' %}">
<script src="{% static 'js/app.js' %}" defer></script>
<img src="{% static 'img/logo.svg' %}" alt="لوگو">
{# با نسخهدهی برای cache busting #}
<link rel="stylesheet" href="{% static 'css/app.css' %}?v=1.2.3">
{# media فایل آپلودشده #}
{% if post.cover %}
<img src="{{ post.cover.url }}" alt="{{ post.title }}">
{% endif %}
{# media با صفتهای اضافه #}
<img src="{{ post.cover.url }}"
srcset="{{ post.cover_thumb.url }} 400w,
{{ post.cover.url }} 800w"
sizes="(max-width: 600px) 400px, 800px"
alt="{{ post.title }}">
{# css و js per-page با static #}
{% block head %}
{% if page_css %}
<link rel="stylesheet" href="{% static page_css %}">
{% endif %}
{% endblock %}
{% block scripts %}
{% for js in page_js_list %}
<script src="{% static js %}" defer></script>
{% endfor %}
{% endblock %}
{# inline SVG برای آیکونها (سریعتر از img) #}
{% include "partials/icons/search.svg" %}
{# SVG inline با کلاس #}
<svg class="icon icon-search" width="20" height="20" aria-hidden="true">
<use href="{% static 'img/icons.svg' %}#search"></use>
</svg>
فرم ها، CSRF و رندر سفارشی
# myapp/forms.py
from django import forms
from django.core.validators import MinLengthValidator
class ContactForm(forms.Form):
name = forms.CharField(
max_length=80,
validators=[MinLengthValidator(3)],
widget=forms.TextInput(attrs={
"class": "form-control",
"placeholder": "نام شما",
"autocomplete": "name",
}),
)
email = forms.EmailField(
widget=forms.EmailInput(attrs={
"class": "form-control",
"placeholder": "ایمیل شما",
"autocomplete": "email",
}),
)
subject = forms.ChoiceField(
choices=[
("support", "پشتیبانی"),
("sales", "فروش"),
("other", "سایر"),
],
widget=forms.Select(attrs={"class": "form-control"}),
)
message = forms.CharField(
widget=forms.Textarea(attrs={
"class": "form-control",
"rows": 6,
"placeholder": "پیام شما",
}),
)
interests = forms.MultipleChoiceField(
choices=[("tech", "فناوری"), ("art", "هنر"), ("sport", "ورزش")],
widget=forms.CheckboxSelectMultiple,
required=False,
)
agree = forms.BooleanField(
label="قوانین را میپذیرم",
widget=forms.CheckboxInput(attrs={"class": "form-check"}),
)
def clean_email(self):
email = self.cleaned_data["email"].lower()
if "spam" in email:
raise forms.ValidationError("این ایمیل مجاز نیست.")
return email
{# myapp/templates/myapp/contact.html #}
{% extends "base.html" %}
{% block content %}
<h1>تماس با ما</h1>
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-error">
{% for error in form.non_field_errors %}<p>{{ error }}</p>{% endfor %}
</div>
{% endif %}
{% for field in form %}
<div class="form-row {% if field.errors %}has-error{% endif %}">
<label for="{{ field.id_for_label }}">
{{ field.label }}
{% if field.field.required %}<span class="req">*</span>{% endif %}
</label>
{{ field }}
{% if field.help_text %}
<small class="help">{{ field.help_text }}</small>
{% endif %}
{% for error in field.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
{% endfor %}
<button type="submit" class="btn-primary">ارسال</button>
</form>
{% endblock %}
{# select دستی #}
<select name="{{ form.subject.name }}" class="form-control">
{% for value, label in form.subject.field.choices %}
<option value="{{ value }}"
{% if form.subject.value == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{# checkbox چندتایی #}
<fieldset>
<legend>علاقهمندیها</legend>
{% for choice in form.interests %}
<label class="form-check">
{{ choice.tag }} {{ choice.choice_label }}
</label>
{% endfor %}
</fieldset>
{# با form.as_p / as_div / as_table #}
{{ form.as_p }}
{{ form.as_div }}
{{ form.as_table }}
{{ form.as_ul }}
{# فقط فیلد بهتنهایی #}
{{ form.name }}
# myapp/forms.py با ModelForm
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "slug", "excerpt", "body", "is_published"]
widgets = {
"title": forms.TextInput(attrs={"class": "form-control"}),
"slug": forms.TextInput(attrs={"class": "form-control"}),
"excerpt": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
"body": forms.Textarea(attrs={"class": "form-control", "rows": 20}),
"is_published": forms.CheckboxInput(attrs={"class": "form-check"}),
}
labels = {
"title": "عنوان",
"is_published": "منتشر شده",
}
help_texts = {
"slug": "فقط حروف لاتین، خط تیره و عدد",
}
تگ ها و فیلترهای سفارشی
myapp/
templatetags/
__init__.py
blog_extras.py
nav.py
persian.py
# myapp/templatetags/blog_extras.py
from django import template
from django.urls import reverse, NoReverseMatch
from django.utils import timezone
from django.utils.html import format_html
from django.utils.safestring import mark_safe
register = template.Library()
@register.simple_tag
def current_year():
return timezone.now().year
@register.simple_tag
def current_datetime(fmt="%Y-%m-%d %H:%M"):
return timezone.localtime().strftime(fmt)
@register.filter
def reading_time(text, wpm=200):
"""زمان مطالعه تقریبی."""
if not text:
return "۰ دقیقه"
words = len(text.split())
minutes = max(1, round(words / wpm))
PERSIAN = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
return f"{str(minutes).translate(PERSIAN)} دقیقه"
@register.filter
def initials(value):
"""حرف اول نام."""
if not value:
return "?"
return "".join(p[0].upper() for p in str(value).split()[:2])
@register.simple_tag(takes_context=True)
def active_link(context, *url_names):
request = context.get("request")
if not request:
return ""
current = request.resolver_match.view_name if request.resolver_match else ""
if current in url_names:
return mark_safe(' class="active" aria-current="page"')
for name in url_names:
try:
if request.path == reverse(name):
return mark_safe(' class="active" aria-current="page"')
except NoReverseMatch:
continue
return ""
@register.simple_tag
def query_replace(request, **kwargs):
"""جایگزینی پارامترهای کوئری بدون از دست دادن بقیه."""
q = request.GET.copy()
for k, v in kwargs.items():
if v is None:
q.pop(k, None)
else:
q[k] = v
return q.urlencode()
@register.simple_tag
def query_append(request, **kwargs):
"""افزودن یا بهروزرسانی پارامتر به کوئری موجود."""
q = request.GET.copy()
for k, v in kwargs.items():
q[k] = v
return q.urlencode()
@register.simple_tag
def absolute_url(request, path=""):
return request.build_absolute_uri(path)
@register.simple_tag
def percentage(part, total, decimals=1):
try:
p = round(float(part) / float(total) * 100, decimals)
except (TypeError, ValueError, ZeroDivisionError):
return 0
return p
@register.simple_tag
def badge(text, variant="default"):
variants = {
"success": "bg-green-100 text-green-700",
"warning": "bg-yellow-100 text-yellow-700",
"error": "bg-red-100 text-red-700",
"default": "bg-slate-100 text-slate-700",
}
cls = variants.get(variant, variants["default"])
return format_html('<span class="badge {}">{}</span>', cls, text)
{% load blog_extras %}
<footer>© {% current_year %} — {{ SITE_NAME }}</footer>
<span>{{ post.body|reading_time }}</span>
<a href="{% url 'home' %}" {% active_link 'home' %}>خانه</a>
<a href="{% url 'blog:post_list' %}" {% active_link 'blog:post_list' 'blog:post_detail' %}>بلاگ</a>
{# جایگزینی کوئری برای فیلترها #}
<a href="?{% query_replace request page=1 sort='new' %}">جدیدترین</a>
<a href="?{% query_replace request page=1 sort='popular' %}">محبوبترین</a>
<a href="?{% query_append request tag='django' %}">فیلتر جنگو</a>
{% badge "منتشر شده" "success" %}
{% badge "پیشنویس" "warning" %}
{% badge "حذف شده" "error" %}
<span>{{ comment_count|initials }}</span>
# myapp/templatetags/nav.py
from django import template
from django.urls import reverse, NoReverseMatch
register = template.Library()
@register.simple_tag(takes_context=True)
def is_current(context, pattern_name, *args, **kwargs):
"""بررسی میکند آیا مسیر فعلی با الگوی داده شده مطابقت دارد."""
request = context["request"]
try:
target = reverse(pattern_name, args=args, kwargs=kwargs)
except NoReverseMatch:
return False
return request.path == target
Inclusion Tag و پارامترگذاری
# myapp/templatetags/cards.py
from django import template
from django.utils import timezone
register = template.Library()
@register.inclusion_tag("partials/card.html")
def post_card(post, show_excerpt=True, show_meta=True):
return {
"post": post,
"show_excerpt": show_excerpt,
"show_meta": show_meta,
}
@register.inclusion_tag("partials/pagination.html", takes_context=True)
def pagination(context, page_obj, window=2):
request = context["request"]
current = page_obj.number
total = page_obj.paginator.num_pages
start = max(1, current - window)
end = min(total, current + window)
# نگه داشتن پارامترهای دیگر در URL
query = request.GET.copy()
query.pop("page", None)
extra_query = query.urlencode()
return {
"page_obj": page_obj,
"page_range": range(start, end + 1),
"current": current,
"total": total,
"extra_query": extra_query,
}
@register.inclusion_tag("partials/user_badge.html")
def user_badge(user, size=32):
return {
"user": user,
"size": size,
"initials": (
user.get_full_name()[:1] or user.username[:1]
).upper() if user else "?",
}
{# templates/partials/card.html #}
<article class="card">
<h3><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h3>
{% if show_excerpt and post.excerpt %}
<p>{{ post.excerpt|truncatechars:120 }}</p>
{% endif %}
{% if show_meta %}
<time datetime="{{ post.created_at|date:'c' }}">
{{ post.created_at|date:"Y-m-d" }}
</time>
{% endif %}
</article>
{# templates/partials/pagination.html #}
{% if page_obj.has_other_pages %}
<nav class="pagination" role="navigation" aria-label="صفحهبندی">
{% if page_obj.has_previous %}
<a class="page-link" rel="prev"
href="?page={{ page_obj.previous_page_number }}{% if extra_query %}&{{ extra_query }}{% endif %}">
قبلی
</a>
{% else %}
<span class="page-link disabled">قبلی</span>
{% endif %}
{% if current > 3 %}
<a href="?page=1{% if extra_query %}&{{ extra_query }}{% endif %}">1</a>
<span>…</span>
{% endif %}
{% for n in page_range %}
{% if n == current %}
<span class="page-link active" aria-current="page">{{ n }}</span>
{% else %}
<a class="page-link"
href="?page={{ n }}{% if extra_query %}&{{ extra_query }}{% endif %}">
{{ n }}
</a>
{% endif %}
{% endfor %}
{% if current < total|add:"-2" %}
<span>…</span>
<a href="?page={{ total }}{% if extra_query %}&{{ extra_query }}{% endif %}">{{ total }}</a>
{% endif %}
{% if page_obj.has_next %}
<a class="page-link" rel="next"
href="?page={{ page_obj.next_page_number }}{% if extra_query %}&{{ extra_query }}{% endif %}">
بعدی
</a>
{% else %}
<span class="page-link disabled">بعدی</span>
{% endif %}
</nav>
{% endif %}
{% load cards %}
{% for post in posts %}
{% post_card post %}
{% endfor %}
{% for post in featured_posts %}
{% post_card post show_excerpt=False show_meta=False %}
{% endfor %}
{% pagination page_obj %}
{% pagination page_obj window=3 %}
Context Processor و داده سراسری
# myapp/context_processors.py
from django.core.cache import cache
from django.urls import reverse
from .models import SiteSetting, Menu
def site_settings(request):
data = cache.get("ctx:site_settings")
if data is None:
setting = SiteSetting.objects.first()
data = {
"SITE_NAME": setting.site_name if setting else "سایت",
"SITE_DESCRIPTION": setting.description if setting else "",
"SITE_LOGO": setting.logo.url if setting and setting.logo else "",
"SITE_PHONE": setting.phone if setting else "",
"SITE_EMAIL": setting.email if setting else "",
"SOCIAL_LINKS": setting.social_links if setting else {},
}
cache.set("ctx:site_settings", data, 3600)
return data
def navigation(request):
menu = cache.get("ctx:main_menu")
if menu is None:
menu = list(
Menu.objects
.filter(is_active=True, parent__isnull=True)
.prefetch_related("children")
.order_by("order")
)
cache.set("ctx:main_menu", menu, 3600)
return {"MAIN_MENU": menu}
def analytics_flags(request):
"""فقط برای staff ها آمار آنالیتیکس را میفرستد."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated or not user.is_staff:
return {}
return {
"DEBUG_TOOLBAR": True,
"IS_ADMIN": True,
}
# settings
TEMPLATES[0]["OPTIONS"]["context_processors"] += [
"myapp.context_processors.site_settings",
"myapp.context_processors.navigation",
"myapp.context_processors.analytics_flags",
]
{# در هر قالب بدون load #}
<header>
<div class="brand">
{% if SITE_LOGO %}
<img src="{{ SITE_LOGO }}" alt="{{ SITE_NAME }}">
{% else %}
<h1>{{ SITE_NAME }}</h1>
{% endif %}
</div>
<nav>
{% for item in MAIN_MENU %}
<a href="{{ item.url }}">{{ item.label }}</a>
{% if item.children.all %}
<ul class="submenu">
{% for sub in item.children.all %}
<li><a href="{{ sub.url }}">{{ sub.label }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</nav>
<div class="contact">
<a href="tel:{{ SITE_PHONE }}">{{ SITE_PHONE }}</a>
<a href="mailto:{{ SITE_EMAIL }}">{{ SITE_EMAIL }}</a>
</div>
</header>
# myapp/models.py (بخشی)
from django.db import models
from django.utils.translation import gettext_lazy as _
class SiteSetting(models.Model):
site_name = models.CharField(_("نام سایت"), max_length=100)
description = models.TextField(_("توضیحات"), blank=True)
logo = models.ImageField(upload_to="site/", blank=True)
phone = models.CharField(_("تلفن"), max_length=30, blank=True)
email = models.EmailField(_("ایمیل"), blank=True)
social_links = models.JSONField(default=dict, blank=True)
class Meta:
verbose_name = _("تنظیمات سایت")
verbose_name_plural = _("تنظیمات سایت")
def save(self, *args, **kwargs):
from django.core.cache import cache
cache.delete("ctx:site_settings")
super().save(*args, **kwargs)
super().save(*args, **kwargs)
class Menu(models.Model):
label = models.CharField(max_length=80)
url = models.CharField(max_length=300, blank=True)
parent = models.ForeignKey(
"self", null=True, blank=True,
related_name="children", on_delete=models.CASCADE,
)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["order"]
کش کردن بخشی از قالب
{% load cache %}
{# کش ساده برای ۱۰ دقیقه #}
{% cache 600 sidebar_main %}
{% include "partials/sidebar.html" %}
{% endcache %}
{# کش با کلید متغیر (هر کاربر جدا) #}
{% cache 300 user_menu user.id %}
<ul class="user-menu">
<li>{{ user.username }}</li>
<li><a href="{% url 'profile' %}">پروفایل</a></li>
</ul>
{% endcache %}
{# کش با fragment_name برای بیاعتبارسازی هدفمند #}
{% cache 3600 "category_list" category.slug category.updated_at.timestamp %}
<ul>
{% for p in category.posts.all %}
<li>{{ p.title }}</li>
{% endfor %}
</ul>
{% endcache %}
{# کش مشروط #}
{% if not user.is_authenticated %}
{% cache 1800 homepage_hero %}
{% include "partials/hero.html" %}
{% endcache %}
{% else %}
{% include "partials/hero_user.html" %}
{% endif %}
# بیاعتبارسازی کش در کد
from django.core.cache import cache
def invalidate_category(category_slug):
# روش اول: حذف کلید مشخص
cache.delete(f"template.cache.category_list.{category_slug}")
# روش دوم: استفاده از pattern (فقط Redis/django-redis)
try:
cache.delete_pattern("template.cache.category_list.*")
except AttributeError:
pass
# بهترین روش: تغییر timestamp در fragment name
# وقتی updated_at تغییر میکند، کلید جدید ساخته میشود
# settings.py — Redis برای production
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
"KEY_PREFIX": "wpkar",
"TIMEOUT": 300,
},
"template_fragments": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/2",
"TIMEOUT": 3600,
},
}
چندزبانه و i18n
{# فعالسازی LocaleMiddleware در settings ضروری است #}
{% load i18n %}
{# trans ساده #}
{% trans "Welcome to our site" %}
{# با متغیر #}
{% blocktrans with name=user.username count n=items|length %}
سلام {{ name }}، {{ n }} آیتم داری.
{% plural %}
سلام {{ name }}، {{ n }} آیتم داری.
{% endblocktrans %}
{# با context برای رفع ابهام مترجم #}
{% blocktrans context "button label" %}
ذخیره
{% endblocktrans %}
{# شمارهگذاری #}
{% blocktrans count counter=cart.items|length %}
یک کالا در سبد است.
{% plural %}
{{ counter }} کالا در سبد است.
{% endblocktrans %}
{# زبان فعلی #}
{% get_current_language as LANG %}
{% get_current_language_bidi as LANG_BIDI %}
{% get_available_languages as LANGS %}
<html lang="{{ LANG }}" dir="{% if LANG_BIDI %}rtl{% else %}ltr{% endif %}">
{# تغییر زبان #}
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<select name="language" onchange="this.form.submit()">
{% for code, name in LANGS %}
<option value="{{ code }}" {% if code == LANG %}selected{% endif %}>
{{ name }}
</option>
{% endfor %}
</select>
</form>
{# timezone در قالب #}
{% load tz %}
{% timezone "Asia/Tehran" %}
{{ value }}
{% endtimezone %}
{% localtime on %}{{ value }}{% endlocaltime %}
{% localtime off %}{{ value }}{% endlocaltime %}
{% get_current_timezone as TZ %}<span>{{ TZ }}</span>
{# تاریخ شمسی با jdatetime #}
{% load persian %}
{{ post.created_at|jalali }} {# 1405/07/02 #}
{{ post.created_at|jalali:"%d %B %Y" }} {# 02 مهر 1405 #}
# دستورات i18n
django-admin makemessages -l fa
django-admin makemessages -l en
django-admin makemessages -a
# ترجمه فایلهای .po
django-admin compilemessages
# در فایل .po
msgid "Welcome to our site"
msgstr "به سایت ما خوش آمدید"
msgid "Hello %(name)s"
msgstr "سلام %(name)s"
الگوهای تکرارشونده: pagination، breadcrumbs، active link
{# partials/breadcrumbs.html #}
{% if breadcrumbs %}
<nav aria-label="مسیر">
<ol class="breadcrumbs" itemscope
itemtype="https://schema.org/BreadcrumbList">
{% for crumb in breadcrumbs %}
<li itemprop="itemListElement" itemscope
itemtype="https://schema.org/ListItem">
{% if crumb.url and not forloop.last %}
<a itemprop="item" href="{{ crumb.url }}">
<span itemprop="name">{{ crumb.label }}</span>
</a>
{% else %}
<span itemprop="name">{{ crumb.label }}</span>
{% endif %}
<meta itemprop="position" content="{{ forloop.counter }}">
</li>
{% endfor %}
</ol>
</nav>
{% endif %}
# myapp/templatetags/nav.py
from django import template
from django.urls import reverse, NoReverseMatch
register = template.Library()
@register.simple_tag(takes_context=True)
def breadcrumbs(context, *pairs):
"""
استفاده:
{% breadcrumbs "بلاگ" "blog:post_list" "پست" post.get_absolute_url %}
"""
crumbs = [{"label": "خانه", "url": "/"}]
for i in range(0, len(pairs), 2):
label = pairs[i]
target = pairs[i + 1]
try:
url = reverse(target)
except (NoReverseMatch, TypeError):
url = target
crumbs.append({"label": label, "url": url})
context["breadcrumbs"] = crumbs
return ""
{% load nav %}
{% breadcrumbs "بلاگ" "blog:post_list" post.title post.get_absolute_url %}
{# partials/header.html #}
<nav class="main-nav">
<a href="{% url 'home' %}" {% active_link 'home' %}>خانه</a>
<a href="{% url 'blog:post_list' %}"
{% active_link 'blog:post_list' 'blog:post_detail' %}>بلاگ</a>
<a href="{% url 'about' %}" {% active_link 'about' %}>درباره</a>
<a href="{% url 'contact' %}" {% active_link 'contact' %}>تماس</a>
</nav>
{# partials/alert.html #}
<div class="alert alert-{{ type|default:'info' }}" role="alert">
{% if title %}<strong>{{ title }}:</strong>{% endif %}
{{ message|safe }}
{% if dismissible %}
<button type="button" aria-label="بستن">×</button>
{% endif %}
</div>
{# partials/empty_state.html #}
<div class="empty-state">
<svg class="icon" aria-hidden="true"><!-- svg --></svg>
<h3>{{ title|default:'چیزی نیست' }}</h3>
<p>{{ description|default:'' }}</p>
{% if action_url %}
<a class="btn" href="{{ action_url }}">{{ action_label }}</a>
{% endif %}
</div>
{# partials/messages.html #}
{% if messages %}
<div class="messages" role="status" aria-live="polite">
{% for message in messages %}
<div class="message message-{{ message.tags|default:'info' }}">
{{ message }}
<button type="button" aria-label="بستن">×</button>
</div>
{% endfor %}
</div>
{% endif %}
جایگزینی موتور: Jinja2
pip install Jinja2
# settings.py
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
],
},
},
{
"BACKEND": "django.template.backends.jinja2.Jinja2",
"DIRS": [BASE_DIR / "templates_jinja2"],
"APP_DIRS": True,
"OPTIONS": {
"environment": "myapp.jinja2.environment",
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
],
},
},
]
# myapp/jinja2.py
from django.contrib.staticfiles.storage import staticfiles_storage
from django.urls import reverse
from django.utils import timezone
from jinja2 import Environment
def environment(**options):
env = Environment(**options)
env.globals.update({
"static": staticfiles_storage.url,
"url": reverse,
"now": timezone.now,
})
return env
{# قالب Jinja2 #}
{% extends "base.html" %}
{% block content %}
{% for post in posts %}
<a href="{{ url('blog:post_detail', kwargs={'slug': post.slug}) }}">
{{ post.title }}
</a>
{% if post.created_at %}
<time>{{ post.created_at.strftime("%Y-%m-%d") }}</time>
{% endif %}
{% endfor %}
{% endblock %}
| ویژگی | DTL | Jinja2 |
|---|---|---|
| امنیت پیشفرض | بالا | متوسط |
| سرعت | خوب | عالی |
| انعطافپذیری | محدود | زیاد |
| یادگیری | ساده | پیچیدهتر |
| پشتیبانی Admin | کامل | نیاز به تنظیم |
| فیلترها و ماکروها | ساده | قدرتمند |
دیباگ قالب و ابزارهای حرفهای
# shell
from django.template.loader import get_template
tpl = get_template("blog/list.html")
print(tpl.origin.name)
print(tpl.origin.loader)
# لیست همه قالب های لودشده
from django.template.loader import engines
engine = engines["django"]
for t in engine.template_loaders:
print(t)
# myapp/templatetags/debug_tags.py
from django import template
from django.utils.html import escape
from django.utils.safestring import mark_safe
import pprint
register = template.Library()
@register.simple_tag(takes_context=True)
def debug_context(context, exclude=None):
exclude = set(exclude or ["True", "False", "None"])
data = {
k: v for k, v in context.flatten().items()
if k not in exclude
}
return mark_safe(f"<pre>{escape(pprint.pformat(data))}</pre>")
@register.simple_tag
def debug_var(value, label=""):
return mark_safe(
f"<pre>{escape(label)}: {type(value).__name__} = "
f"{escape(repr(value))}</pre>"
)
@register.simple_tag
def debug_queries():
from django.db import connection
total = len(connection.queries)
html = f"<strong>{total} queries</strong><ul>"
for i, q in enumerate(connection.queries, 1):
html += f"<li>{i}. [{q['time']}s] {escape(q['sql'])}</li>"
html += "</ul>"
return mark_safe(html)
{% load debug_tags %}
{% if DEBUG %}
<details>
<summary>Context</summary>
{% debug_context %}
</details>
<details>
<summary>User object</summary>
{% debug_var user "user" %}
</details>
{% debug_queries %}
{% endif %}
# settings.py (development)
TEMPLATES[0]["OPTIONS"]["string_if_invalid"] = "❌MISSING: %s"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {"class": "logging.StreamHandler"},
},
"loggers": {
"django.template": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": False,
},
"django.db.backends": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
pip install django-debug-toolbar
pip install django-silk
# urls.py
from django.conf import settings
if settings.DEBUG:
import debug_toolbar
urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns
تست قالب و اسنپشات تست
# myapp/tests/test_templates.py
from django.test import TestCase
from django.template import Template, Context
from django.template.loader import render_to_string
class TemplateRenderTests(TestCase):
def test_condition(self):
t = Template("{% if x %}yes{% else %}no{% endif %}")
self.assertEqual(t.render(Context({"x": True})), "yes")
self.assertEqual(t.render(Context({"x": False})), "no")
def test_loop(self):
t = Template("{% for i in items %}{{ i }}،{% endfor %}")
out = t.render(Context({"items": [1, 2, 3]}))
self.assertIn("1", out)
self.assertIn("3", out)
def test_empty(self):
t = Template("{% for i in items %}{{ i }}{% empty %}خالی{% endfor %}")
self.assertEqual(t.render(Context({"items": []})), "خالی")
def test_filter_chain(self):
t = Template("{{ title|lower|truncatechars:5 }}")
self.assertEqual(t.render(Context({"title": "HELLO"})), "hello")
def test_extends(self):
html = render_to_string("blog/list.html", {
"page_obj": [],
"q": "",
})
self.assertIn("آخرین پست ها", html)
def test_inclusion_tag(self):
from myapp.templatetags.cards import post_card
html = post_card({
"title": "Test",
"get_absolute_url": "/test/",
"excerpt": "excerpt",
"created_at": None,
})["post"]["title"]
self.assertEqual(html, "Test")
# myapp/tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from blog.models import Post
class BlogViewTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.post = Post.objects.create(
title="Hello Django",
slug="hello-django",
body="محتوای تست",
is_published=True,
)
def test_list_renders_title(self):
r = self.client.get(reverse("blog:post_list"))
self.assertEqual(r.status_code, 200)
self.assertTemplateUsed(r, "blog/list.html")
self.assertContains(r, "Hello Django")
def test_detail_renders_body(self):
r = self.client.get(
reverse("blog:post_detail", kwargs={"slug": "hello-django"})
)
self.assertContains(r, "محتوای تست")
def test_empty_list_message(self):
Post.objects.all().delete()
r = self.client.get(reverse("blog:post_list"))
self.assertContains(r, "پستی یافت نشد")
pip install pytest-django snapshottest
# conftest.py
import pytest
from django.test import Client
@pytest.fixture
def client():
return Client()
# test_snapshots.py
def test_homepage_snapshot(snapshot, client):
response = client.get("/")
snapshot.assert_match(response.content.decode(), "homepage.html")
کارایی قالب در پروژههای واقعی
{# ❌ بد: کوئری پنهان برای هر پست #}
{% for post in posts %}
<span>{{ post.author.profile.display_name }}</span>
<span>{{ post.comments.count }} نظر</span>
{% endfor %}
{# ✅ خوب: با select_related/prefetch در ویو #}
# views.py
posts = (
Post.objects
.select_related("author", "author__profile", "category")
.prefetch_related("tags", "comments")
.annotate(comments_count=Count("comments"))
.order_by("-created_at")
)
# template
{% for post in posts %}
<span>{{ post.author.profile.display_name }}</span>
<span>{{ post.comments_count }} نظر</span>
{% endfor %}
{# ❌ بد: include در حلقه بزرگ #}
{% for item in items %}
{% include "card.html" %}
{% endfor %}
{# ✅ خوب: inclusion_tag سبک یا انتقال منطق به ویو #}
{% for item in items %}
{% item_card item %}
{% endfor %}
{# یا حتی بهتر: ویو داده آماده میکند و قالب فقط رندر میکند #}
{# بارگذاری کتابخانهها یک بار در base.html #}
{# ❌ بد: در هر partial #}
{% load static i18n %}
{# ✅ خوب: در base.html یک بار #}
{% load static i18n humanize %}
{# در جنگو ۳.۱+، فرزندان ارث میبرند #}
| موقعیت | عملکرد | توصیه |
|---|---|---|
| حلقه کوچک (< ۱۰۰) | خوب | include مشکلی ندارد |
| حلقه بزرگ (> ۱۰۰۰) | کند | منطق را به ویو یا تگ منتقل کنید |
| دسترسی رابطهای بدون prefetch | N+1 | select_related/prefetch_related |
| فیلترهای سنگین تودرتو | کند | محاسبه در ویو |
| کش کل صفحه | سریع | فقط برای صفحات عمومی |
| کش fragment | سریع | برای بخشهای سنگین |
{# کش تکهای برای بخشهای سنگین #}
{% load cache %}
{% cache 3600 "featured" site_setting.updated_at.timestamp %}
{% for post in featured_posts %}
{% post_card post %}
{% endfor %}
{% endcache %}
# تست کوئریها در shell
from django.test.utils import CaptureQueriesContext
from django.db import connection
from django.test import Client
c = Client()
with CaptureQueriesContext(connection) as ctx:
r = c.get("/blog/")
print(f"{len(ctx)} queries")
for q in ctx.captured_queries[:5]:
print(q["sql"][:120])
pip install django-silk
# settings.py
INSTALLED_APPS += ["silk"]
MIDDLEWARE = [
"silk.middleware.SilkyMiddleware",
] + MIDDLEWARE
SILKY_PYTHON_PROFILER = True
SILKY_META = True
# urls.py
urlpatterns += [path("silk/", include("silk.urls", namespace="silk"))]
امنیت قالب: escape و safe
{# کاربر این را وارد کرده: <script>alert(1)</script> #}
{{ comment }} {# ✅ متن نمایش داده میشود #}
{{ comment|safe }} {# ❌ کد اجرا میشود #}
{{ comment|escape }} {# ✅ escape میکند #}
{# در attribute #}
<div title="{{ user_input }}"></div> {# ✅ escape پیشفرض #}
<div title="{{ user_input|safe }}"></div> {# ❌ خطرناک #}
{# در URL #}
<a href="?q={{ user_input }}">...</a> {# ⚠️ escape پیشفرض #}
<a href="?q={{ user_input|urlencode }}">...</a> {# ✅ بهتر #}
{# در JS #}
<script>var name = "{{ user_input }}";</script> {# ❌ خطرناک #}
<script>var name = "{{ user_input|escapejs }}";</script> {# ✅ امن #}
{# انتقال داده به JS #}
{{ data|json_script:"app-data" }} {# ✅ کاملاً امن #}
# پاکسازی محتوا قبل از ذخیره
pip install nh3 # یا bleach
import nh3
def clean_user_html(html):
return nh3.clean(
html,
tags={
"a", "b", "i", "u", "em", "strong",
"p", "br", "hr", "ul", "ol", "li",
"blockquote", "code", "pre",
"h2", "h3", "h4",
},
attributes={
"a": {"href", "title", "rel", "target"},
"img": {"src", "alt", "title"},
},
url_schemes={"http", "https", "mailto"},
)
# format_html برای تولید HTML امن در تگ سفارشی
from django.utils.html import format_html
from django.utils.safestring import mark_safe
@register.simple_tag
def badge_safe(text, variant="default"):
# ✅ امن: format_html مقادیر را escape میکند
return format_html(
'<span class="badge badge-{}">{}</span>',
variant,
text,
)
@register.simple_tag
def badge_unsafe(text, variant="default"):
# ❌ ناامن: mark_safe روی داده کاربر
return mark_safe(f'<span class="badge-{variant}">{text}</span>')
| موقعیت | روش امن | روش ناامن |
|---|---|---|
| نمایش نظر کاربر | {{ comment }} | {{ comment|safe }} |
| محتوای ادیتور داخلی | nh3 قبل از ذخیره | safe بدون پاکسازی |
| متغیر داخل JS | {{ x|escapejs }} | درج مستقیم |
| متغیر داخل URL | {{ x|urlencode }} | درج مستقیم |
| انتقال داده به JS | {{ x|json_script:"id" }} | درج مستقیم JSON |
| attribute HTML | {{ x|escape }} | درج بدون escape |
| HTML تولیدی در تگ | format_html | mark_safe روی داده کاربر |
اطلاعات جامعتر درباره XSS در ویکیپدیا موجود است. سیاستهای امنیتی جامع را در راهنمای امنیت جنگو نوشتهام.
اشتباهات رایج در قالب ها
در پروژهها این پانزده مورد را مکرر دیدهام:
1. نبود base.html و کپی ساختار در هر صفحه
2. URL دستی بهجای {% url %}
3. فراموشی {% csrf_token %} در فرم POST
4. استفاده از |safe روی داده کاربر
5. کوئری پنهان بدون select_related/prefetch_related
6. include در حلقههای بزرگ
7. بارگذاری مکرر کتابخانهها در هر partial
8. منطق تجاری در قالب (شرطهای تودرتوی عمیق)
9. فراموشی {% empty %} در حلقهها
10. {% load %} در هر فایل بهجای base.html
11. محتوای اضافه قبل از {% extends %}
12. {{ var|safe }} در attribute HTML
13. رندر مستقیم بدون request → context processor اجرا نمیشود
14. نبود استراتژی کش برای قالب های سنگین
15. قالب های بیش از ۵۰۰ خط بدون split
{# ❌ اشتباه ۱۱: extends بعد از کاراکتر #}
{% load static %}
{% extends "base.html" %} {# ← خطا! #}
{# ✅ درست #}
{% extends "base.html" %}
{% load static %}
{# ❌ اشتباه ۱۳: رندر بدون request #}
from django.template import Template, Context
html = Template("...").render(Context({"user": user}))
# context processor ها اجرا نمیشوند
{# ✅ درست #}
from django.template.loader import render_to_string
html = render_to_string("tpl.html", {"user": user}, request=request)
{# ❌ اشتباه ۱۵: قالب غول #}
<!-- page.html 800 lines -->
{# ✅ شکستن #}
{% extends "base.html" %}
{% block content %}
{% include "page/hero.html" %}
{% include "page/features.html" %}
{% include "page/testimonials.html" %}
{% include "page/pricing.html" %}
{% include "page/faq.html" %}
{% include "page/cta.html" %}
{% endblock %}
پرسش های پرتکرار درباره قالب جنگو
تفاوت function-based و class-based view در قالب چیست؟
از نظر قالب هیچ تفاوتی ندارند. هر دو با render یا TemplateResponse کار میکنند. تفاوت در ساختار کد ویو است.
آیا میتوانم داخل قالب پایتون بنویسم؟
خیر. برای هر منطقی از تگ سفارشی، فیلتر، context processor، یا انتقال به ویو استفاده کنید.
چرا {% extends %} خطا میدهد؟
چون اولین تگ غیرکامنت قالب نیست. هیچ کاراکتری، حتی فضای خالی، نباید قبل از آن باشد.
چطور از تکرار در قالب جلوگیری کنم؟
سه ابزار: {% include %} برای قطعات، {% extends %} برای ساختار، {% block %} برای نقاط قابل override. برای الگوهای پیچیده، inclusion_tag.
آیا میتوانم قالب جنگو را در JS رندر کنم؟
خیر. قالب جنگو سمت سرور رندر میشود. برای SPA از API استفاده کنید. برای ترکیب با Vue یا Alpine از {% verbatim %} استفاده کنید.
چطور تعداد کوئریها را در قالب کم کنم؟
با select_related و prefetch_related در ویو. هر دسترسی به رابطهای که prefetch نشده، یک کوئری جدا میسازد.
آیا میتوانم قالب را در زمان اجرا انتخاب کنم؟
بله. با render(request, template_name, context) که template_name متغیر باشد، یا با select_template که لیستی از نامها میگیرد.
چطور یک قالب را برای ایمیل بسازم؟
با render_to_string("emails/welcome.html", context, request=request). برای ایمیل از HTML ساده و inline CSS استفاده کنید.
آیا کش قالب را باید در production فعال کنم؟
بله. cached loader در production سرعت رندر را محسوس بالا میبرد. در development غیرفعال باشد.
تفاوت {% include %} و {% extends %} در چیست؟
extends وراثت قالب است و برای ساختار کلی صفحه. include برای درج قطعه قالب است و رابطه وراثتی ندارد.
چطور از بارگذاری مکرر کتابخانهها جلوگیری کنم؟
در قالب والد (base.html) یک بار {% load %} کنید. در جنگو ۳.۱ به بعد به فرزندان ارث میرسد.
آیا استفاده از تگ سفارشی همیشه توصیه میشود؟
خیر. اگر تگ منطق تجاری دارد، جای آن ویو است. تگ سفارشی برای قالببندی و مقادیر محاسبهشده سبک.
چه زمانی از {% with %} استفاده کنم؟
وقتی یک عبارت را چند بار استفاده میکنید یا کوئری سنگینی است که نمیخواهید چند بار اجرا شود.
آیا تگ ها روی کارایی سایت تأثیر دارند؟
کم. بیشترین تأثیر از کوئریهای پنهان و include های مکرر میآید.
چطور یک تگ برای نمایش تاریخ شمسی بسازم؟
با simple_tag و کتابخانه jdatetime یا django-jalali. نمونهاش در بخش فیلترهای سفارشی بالا آمده.
آیا میتوانم از یک قالب در چند اپ استفاده کنم؟
بله. قالب های موجود در templates/ سراسری برای همه اپها در دسترس هستند.
چطور خطای template syntax را سریع پیدا کنم؟
خطای جنگو نام فایل و شماره خط را میدهد. از string_if_invalid و ابزارهای debug استفاده کنید.
چطور قالب را در برابر XSS ایمن کنم؟
escape پیشفرض را نگه دارید، |safe را روی داده کاربر اعمال نکنید، و از nh3 برای پاکسازی استفاده کنید.
آیا میتوانم قالب را در زمان اجرا کامپایل کنم؟
قالب ها در بارگذاری اولیه کامپایل میشوند. برای قالب های داینامیک از لودر سفارشی استفاده کنید.
تفاوت {% comment %} و {# #} در چیست؟
{# #} یکخطی است و نمیتواند تگ داشته باشد. {% comment %} چندخطی است.
چطور از تکرار در قالب های بزرگ جلوگیری کنم؟
سلسلهمراتب base، partials، inclusion_tag ها. هیچ قالب نباید بیش از ۳۰۰ خط باشد.
آیا میتوانم دسترسی به قالب را محدود کنم؟
در سطح ویو با @login_required و @user_passes_test. قالب خودش نمیتواند دسترسی را محدود کند.
چطور قالب را از نگاه سئو بهینه کنم؟
با تگ های semantic مثل article، header، time، meta، canonical، و schema.org. نمونهاش در بخش وراثت قالب بالاست.
آیا میتوانم قالب را در قالب دیگر include کنم؟
بله، {% include %} میتواند هر قالب دیگری را درج کند. فقط از include های تودرتوی عمیق پرهیز کنید.
چطور میتوانم محتوای بلوک را از فرزند حذف کنم؟
اگر بلوک را در فرزند override نکنید، محتوای والد نمایش داده میشود. برای حذف کامل، بلوک را خالی override کنید یا والد را طوری بنویسید که شرط داشته باشد.
آیا میتوانم فیلتر سفارشی با آرگومان بسازم؟
بله. @register.filter تابعی میپذیرد که اولین آرگومان مقدار است و بقیه آرگومانها از قالب پاس میشوند:
@register.filter
def multiply(value, arg):
return value * arg
{{ x|multiply:5 }}
چطور در تگ سفارشی به request دسترسی داشته باشم؟
با takes_context=True:
@register.simple_tag(takes_context=True)
def my_tag(context):
request = context["request"]
return request.user.username
آیا میتوانم در تگ سفارشی کوئری دیتابیس بزنم؟
بله ولی با احتیاط. اگر تگ در حلقه صدا زده شود، N+1 میسازد. داده را در ویو آماده کنید.
چطور میتوانم رفتار پیشفرض escape را برای یک بلوک خاموش کنم؟
{% autoescape off %}
{{ user_html }}
{% endautoescape %}
اما هرگز روی داده کاربر این کار را نکنید. برای داده داخلی که به آن اعتماد دارید، اشکالی ندارد.
آیا میتوانم زمان رندر قالب را اندازه بگیرم؟
import time
from django.template.loader import render_to_string
start = time.perf_counter()
html = render_to_string("tpl.html", ctx)
elapsed = (time.perf_counter() - start) * 1000
print(f"{elapsed:.2f}ms")
یا از django-debug-toolbar و django-silk برای پروفایل کامل استفاده کنید.
چطور محتوای یک قالب را در متغیر ذخیره کنم؟
{% with hero_html=... %}
{# روش درست: با include در متغیر ذخیره نمیشود #}
{# از capture در تگ سفارشی استفاده کنید یا از render_to_string در ویو #}
یک انتخاب شخصی برای پروژه بعدی
اگر بخواهم در یک جمله بگویم قالب جنگو را چطور طراحی کنید: ساختار در base.html، قطعات در partials/، منطق در ویو، و هر چیز تکراری به یک تگ سفارشی یا include منتقل شود. این قاعده در پروژههای بزرگ شش ماه بعد هم قابل نگهداری میماند. اگر الگوی متفاوتی دارید که نتیجه بهتری گرفته، خوشحال میشوم بشنوم؛ بهخصوص اگر در مقیاس بزرگ جواب داده باشد یا در حل مشکلی خاص مؤثر بوده باشد.