Django基礎 #5 Templates と静的ファイル

読了 8分

#4 URL と Views で view が render(request, "blog/post_list.html", {"posts": posts}) のような呼び出しで HTML を返しました。今回はその HTML の正体 — Django テンプレート の構文と、テンプレートが参照する 静的ファイル (CSS、JS、画像) の処理を見ます。

テンプレートディレクトリ構造 #

Django のテンプレート検索パスは 2 つ — 各アプリの templates/プロジェクト共通の templates/

推奨構造
myblog/
├── config/
│   └── settings.py
├── templates/                       # プロジェクト共通 (base.html など)
│   └── base.html
└── blog/
    ├── views.py
    └── templates/
        └── blog/                    # もう一段深く入れるのがコンベンション
            ├── post_list.html
            └── post_detail.html

blog/templates/blog/post_list.html のように アプリ名をもう一度入れる理由 は、別アプリの同じ名前のテンプレートとの衝突を避けるためです。2 つのアプリが両方 post_list.html を持っていると、どちらが選択されるか分からなくなります。

settings.py の TEMPLATES 設定 #

config/settings.py
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],     # 共通テンプレートディレクトリ
        "APP_DIRS": True,                      # 各アプリの templates/ を自動検索
        "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",
            ],
        },
    },
]

DIRS に共通ディレクトリ、APP_DIRS=True でアプリ別の自動検索。

変数の出力 — {{ }} #

blog/templates/blog/post_detail.html
<h1>{{ post.title }}</h1>
<p>作成者: {{ post.author.username }}</p>
<p>作成日: {{ post.created_at }}</p>
<div>{{ post.content }}</div>

{{ var }} が変数の出力。ドット記法 は次の順序で試します:

  1. dict のキー (var["title"])
  2. オブジェクトの属性 (var.title)
  3. メソッド (var.title()) — 引数なしで呼び出せる場合
  4. リストのインデックス (var[0])

この自動の試行が、テンプレートが 単純な式だけ 許可する理由です — Python コードを直接書けません (それが Django テンプレートの哲学)。

自動エスケープ — XSS のデフォルト防御 #

Django テンプレートは {{ }} で出力されるすべての値を 自動 HTML エスケープ します。<script>alert(1)</script> が変数に入っていると &lt;script&gt;... に変換されます。

これを解除したい (例: 事前に sanitize した HTML) なら |safe フィルタ:

エスケープを解除
{{ post.content_html|safe }}

|safe は本当に信頼できるデータにだけ。ユーザー入力にそのまま付けると XSS 脆弱性です。

タグ — {% %} #

タグが ロジック を担当します。よく使うものたち。

{% if %} #

条件文
{% if post.is_published %}
  <span class="badge">公開済み</span>
{% elif post.status == "draft" %}
  <span class="badge">草稿</span>
{% else %}
  <span class="badge">その他</span>
{% endif %}

{% if posts %}
  <p>合計 {{ posts|length }} 件</p>
{% else %}
  <p>まだ記事がありません。</p>
{% endif %}

比較演算子 — ==!=<><=>=innot inisis not。論理演算子 — andornot

{% for %} #

繰り返し
<ul>
{% for post in posts %}
  <li>
    <a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
    <small>by {{ post.author.username }}</small>
  </li>
{% empty %}
  <li>登録された記事がありません。</li>
{% endfor %}
</ul>

{% empty %} が空のコレクションのときの分岐。とてもよく使います。

ループの中で使える変数:

変数意味
forloop.counter1 から始まるインデックス
forloop.counter00 から始まる
forloop.first最初の繰り返しなら True
forloop.last最後の繰り返しなら True
forloop.revcounter最後から 1 まで
forloop の活用
{% for post in posts %}
  <div class="{% if forloop.first %}featured{% endif %}">
    {{ forloop.counter }}. {{ post.title }}
  </div>
{% endfor %}

{% url %} #

URL の生成
<a href="{% url 'blog:post_list' %}">一覧</a>
<a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
<a href="{% url 'blog:post_detail' post_id=post.id %}">{{ post.title }}</a>

#4 で見た named URL のテンプレート版。

{% include %} #

別のテンプレートを差し込む
{% include "blog/_post_card.html" %}
{% include "blog/_post_card.html" with post=post show_author=True %}

部分テンプレートを再利用。ファイル名を _ で始めるのが partial のコンベンション。

{% csrf_token %} #

フォームには必ず入れなければならないタグ。

フォーム
<form method="post">
  {% csrf_token %}
  <input name="title" />
  <button>保存</button>
</form>

抜けると 403 Forbidden で弾かれます。#6 で詳しく。

フィルタ — | #

フィルタは変数を 変換 します。{{ value|filter }} または {{ value|filter:"arg" }}

よく使うフィルタ
{{ post.title|upper }}                    {# 大文字 #}
{{ post.title|lower }}
{{ post.title|truncatechars:50 }}         {# 50 文字で切って ... #}
{{ post.title|truncatewords:10 }}
{{ post.created_at|date:"Y-m-d H:i" }}    {# 日付フォーマット #}
{{ post.created_at|timesince }}            {# "3 日前" #}
{{ post.content|linebreaks }}              {# 改行 → <p>, <br> #}
{{ post.content|striptags }}               {# HTML タグ除去 #}
{{ posts|length }}
{{ post.content|default:"内容なし" }}
{{ value|yesno:"はい,いいえ,不明" }}
{{ price|floatformat:2 }}                  {# 1234.567 → 1234.57 #}
{{ name|capfirst }}                        {# 最初の文字だけ大文字 #}
{{ posts|first }}
{{ posts|last }}
{{ posts|slice:":5" }}

フィルタは チェーン 可能 — {{ post.title|truncatechars:50|upper }}

テンプレート継承 — {% extends %} / {% block %} #

Web ページの共通骨格 (ヘッダ、フッタ、サイドバー) を一カ所にまとめておき、ページごとに変わる部分だけを埋めるパターン。

templates/base.html — 共通骨格
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>{% block title %}My Blog{% endblock %}</title>
  {% load static %}
  <link rel="stylesheet" href="{% static 'css/main.css' %}">
</head>
<body>
  <header>
    <a href="{% url 'blog:post_list' %}">ホーム</a>
    {% if user.is_authenticated %}
      <span>{{ user.username }}</span>
      <a href="{% url 'logout' %}">ログアウト</a>
    {% else %}
      <a href="{% url 'login' %}">ログイン</a>
    {% endif %}
  </header>

  <main>
    {% block content %}{% endblock %}
  </main>

  <footer>
    <p>&copy; 2026 My Blog</p>
  </footer>
</body>
</html>
blog/templates/blog/post_list.html — 子
{% extends "base.html" %}

{% block title %}記事一覧 - My Blog{% endblock %}

{% block content %}
  <h1>記事一覧</h1>
  <ul>
    {% for post in posts %}
      <li>
        <a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
        <small>{{ post.created_at|date:"Y-m-d" }}</small>
      </li>
    {% empty %}
      <li>まだ記事がありません。</li>
    {% endfor %}
  </ul>
{% endblock %}

ルール:

  • 子テンプレートの 最初の行{% extends "..." %}
  • 親の {% block name %}...{% endblock %} の位置に子の同じ名前の block が入る
  • 子が空にすると親のデフォルトがそのまま表示される
  • block 内で {{ block.super }} を使うと親の内容 + 子の内容を合わせられる

多段継承 #

3 段継承の例
base.html               (サイト骨格)
blog/_layout.html       (サイドバー追加、blog 共通)
blog/post_list.html     (実際のページ)

各段階が自分の上のテンプレートを extends すれば良いです。深く積み上げるほど慎重に。

静的ファイル — CSS、JS、画像 #

settings とディレクトリ #

config/settings.py
STATIC_URL = "static/"

# 開発段階での追加検索パス
STATICFILES_DIRS = [
    BASE_DIR / "static",
]

# 本番デプロイ時の collectstatic の出力位置
STATIC_ROOT = BASE_DIR / "staticfiles"

各アプリの static/ ディレクトリも自動的に検索されます (django.contrib.staticfiles が有効化されているとき — デフォルト)。

静的ファイルの位置
myblog/
├── static/                        # プロジェクト共通
│   └── css/
│       └── main.css
└── blog/
    └── static/
        └── blog/                  # アプリ名をもう一度 (テンプレートと同じコンベンション)
            ├── css/
            │   └── post.css
            └── images/
                └── logo.png

テンプレートで {% static %} #

static の使用
{% load static %}

<link rel="stylesheet" href="{% static 'css/main.css' %}">
<link rel="stylesheet" href="{% static 'blog/css/post.css' %}">
<img src="{% static 'blog/images/logo.png' %}" alt="logo">
<script src="{% static 'blog/js/post.js' %}"></script>

{% load static %} をテンプレートの上に一度宣言する必要があります。通常は base.html<head> の上のほうに。

本番デプロイ — collectstatic #

runserver (開発サーバー) は静的ファイルを自動で配信します。本番では collectstatic ですべての静的ファイルを 1 つのディレクトリ (STATIC_ROOT) に集めた後、Nginx などの Web サーバーが直接配信します。

collectstatic
uv run python manage.py collectstatic

このコマンドがやること:

  • すべてのアプリの static/ + STATICFILES_DIRS のファイルを STATIC_ROOT にコピー
  • 同じ名前があれば最初の検索パスのファイルが優先

本番の詳しい流れ (WhiteNoise、CDN、manifest storage) は 中級 #6 Static/Media 運用 で。

Media ファイル — ユーザーアップロード #

STATIC開発者が作った ファイル (CSS、JS)、MEDIAユーザーがアップロードした ファイル (プロフィール画像、添付ファイルなど)。両者は別の場所です。

config/settings.py
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
config/urls.py — 開発段階のみ
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    # ...
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

本番でユーザーアップロードを直接配信してはいけません — S3 のような外部ストレージに。これも 中級 #6 で。

Django テンプレート vs Jinja2 — 一行比較 #

Django テンプレートは 意図的に制限された言語 です。Python コードを直接書けず、任意の関数呼び出しも難しいです ({% load %} したタグ / フィルタだけ)。それが セキュリティとデザイナーフレンドリー を守る方式です。

Jinja2 はより強力 — テンプレート内でほぼすべての Python 式が可能です。それだけビジネスロジックがテンプレートに漏れやすいという意味でもあります。

Django でも Jinja2 を backend として使えますが (TEMPLATESBACKEND を変更)、Admin / Form / その他のビルトインは Django テンプレート基準なので混ざって動作します。最初は Django テンプレートをそのまま使うのが無難です。

ユーザー定義タグ / フィルタ #

よく使う表現があるなら自分で作れます。

blog/templatetags/blog_extras.py
from django import template

register = template.Library()


@register.filter
def markdown_to_html(value: str) -> str:
    import markdown
    return markdown.markdown(value)


@register.simple_tag
def current_year():
    from datetime import datetime
    return datetime.now().year
テンプレート
{% load blog_extras %}

<div>{{ post.content|markdown_to_html|safe }}</div>
<footer>&copy; {% current_year %}</footer>

templatetags/ ディレクトリ、__init__.py が必要。このルールを破ると Django が見つけられません。

まとめ #

今回つかんだもの:

  • テンプレートの位置 — アプリ別 templates/<app>/... + 共通 templates/
  • TEMPLATES 設定の DIRS / APP_DIRS
  • 変数 — {{ }}、自動 HTML エスケープ (XSS 防御)
  • タグ — {% if %}{% for %} (with {% empty %})、{% url %}{% include %}{% csrf_token %}
  • フィルタ — |date|truncatechars|linebreaks|safe、…
  • テンプレート継承 — {% extends %}{% block %}{{ block.super }}
  • 静的ファイル — STATIC_URLSTATICFILES_DIRS{% load static %}{% static %}
  • collectstatic は本番用
  • Media ファイルは別 (MEDIA_URL / MEDIA_ROOT)
  • Django テンプレートは意図的に制限された言語 — セキュリティ / シンプルさ
  • ユーザー定義タグ / フィルタ — templatetags/

次回(#6 Forms と ModelForm)ではこのテンプレートの中でフォームを処理する標準の方法 — Form / ModelForm で検証、CSRF、エラー表示まで一度に解くパターンを扱います。

X