Contents
1. Django テンプレートエンジンが提供する基本機能
テンプレートは文字列置換や簡易演算に限定された DSL(Domain Specific Language)です。ロジックはビュー側で処理し、結果だけを渡すことで 「表示ロジックはテンプレート、ビジネスロジックはビュー」 という役割分担が保たれます。この設計は保守性とセキュリティの両立に寄与します。
1‑1. ビルトインフィルターだけでは足りないケース
標準で用意されている date、upper、truncatechars といったフィルターは汎用的ですが、実務では以下のような要件が頻出します。
- 日本語ローカライズされた日付書式(例:
2023年04月01日) - 文字列中の特定キーワードを HTML マークアップで強調
- 複数フィールドを組み合わせた独自フォーマット
これらは カスタムフィルター を作成すればテンプレート側の記述がシンプルになり、ビューコードとテンプレートコードの境界が明確になります。
2. カスタムフィルターパッケージのディレクトリ構成
Django は app_name/templatetags/ ディレクトリ配下に置かれたモジュールを自動的に検出し、 {% load module_name %} で利用可能にします。以下は推奨される最低限の構成例です。
|
1 2 3 4 5 6 7 8 9 10 |
myproject/ ├─ myapp/ │ ├─ templatetags/ │ │ ├─ __init__.py # 空でも必須 │ │ └─ custom_filters.py │ ├─ models.py │ ├─ views.py │ └─ … └─ manage.py |
__init__.pyが無いと Python パッケージとして認識されず、インポートエラーになります。- モジュール名はファイル名(拡張子除く)になるので、
custom_filters.pyを作成したらテンプレート側では{% load custom_filters %}と記述します。
3. カスタムフィルターの実装とエスケープ制御
3‑1. 基本的な登録手順
django.template.Library のインスタンスを作成し、デコレータで関数を登録します。以下は最小構成です。
|
1 2 3 4 5 |
# myapp/templatetags/custom_filters.py from django import template register = template.Library() |
3‑2. 単純な変換フィルター(例:日本語日付)
@register.filter(name='ja_date') と書くと、テンプレート上で |ja_date が使用可能になります。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
import datetime @register.filter(name='ja_date') def ja_date(value, fmt='%Y年%m月%d日'): """ datetime / date オブジェクトを日本語表記の文字列に変換する。 受け取ったオブジェクトが日付でない場合はそのまま返す。 """ if not isinstance(value, (datetime.date, datetime.datetime)): return value return value.strftime(fmt) |
3‑3. エスケープ制御の正しい書き方
Django のテンプレートエンジンは自動エスケープがデフォルトで有効です。出力が安全(HTML が既にエスケープ済み)な場合は is_safe=True を付与し、逆に手動でエスケープ処理を行う必要があるときは needs_autoescape=True を指定します。
3‑3‑1. 手動エスケープが不要な例(安全マークアップ)
|
1 2 3 4 5 |
@register.filter(is_safe=True) def wrap_mark(text, tag='mark'): """テキスト全体を <tag> で囲む。HTML が安全とみなす場合に使用""" return f'<{tag}>{text}</{tag}>' |
3‑3‑2. 手動エスケープが必要な例(自前の文字列置換)
|
1 2 3 4 5 6 7 8 9 10 11 12 |
@register.filter(needs_autoescape=True) def highlight(text, word, autoescape=True): """ 指定した単語を <mark> で強調表示する。 autoescape が True のときは Django のエスケープ関数で安全化してから置換。 """ if autoescape: from django.utils.html import escape text = escape(text) word = escape(word) return text.replace(word, f'<mark>{word}</mark>') |
needs_autoescape=Trueが付いていると、テンプレート側で自動エスケープが有効な場合は第 3 引数にTrueが渡されます。- エスケープ済みの文字列を再度エスケープしないよう注意してください。
3‑4. フィルター実装時のベストプラクティス
| 項目 | 推奨内容 |
|---|---|
| 引数の型チェック | isinstance で受け取る型を限定し、期待外れの場合は元値を返す |
| ドキュメンテーション文字列 | 必ず """...""" で説明を書き、テンプレートタグ生成ツールが参照できるようにする |
| テスト容易性 | 純粋関数として実装し、副作用(DBアクセス等)を持たせない |
4. テンプレート側でのカスタムフィルター利用例
4‑1. フィルターのロード方法
テンプレートの先頭に以下を記述すれば、同ディレクトリ内の全てのフィルターが使用可能です。
|
1 2 |
{% load custom_filters %} |
4‑2. 実装例:日付と文字列省略
4‑2‑1. 日付変換(ja_date)
|
1 2 3 |
{% load custom_filters %} <p>公開日:{{ article.pub_date|ja_date }}</p> |
2023-04-01が2023年04月01日と表示されます。
4‑2‑2. テキスト省略(truncate_ja)
|
1 2 3 4 5 6 7 8 |
# myapp/templatetags/custom_filters.py @register.filter(name='truncate_ja') def truncate_ja(value, length=30): """指定文字数で切り、末尾に … を付与する""" if len(value) <= length: return value return value[:length] + '…' |
|
1 2 3 |
{% load custom_filters %} <p>{{ comment.body|truncate_ja:50 }}</p> |
- 文字列が
50文字を超える場合は自動で省略記号が付加されます。
5. カスタムフィルターのテスト方法
5‑1. 単体テスト(Pure Python)
SimpleTestCase を使えば Django のテンプレートエンジンに依存しない関数単位のテストが書けます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# myapp/tests/test_filters.py from django.test import SimpleTestCase from myapp.templatetags.custom_filters import ja_date, truncate_ja import datetime class CustomFilterUnitTests(SimpleTestCase): def test_ja_date(self): dt = datetime.datetime(2022, 12, 31) self.assertEqual(ja_date(dt), '2022年12月31日') def test_truncate_ja_short(self): txt = '短文' self.assertEqual(truncate_ja(txt, 10), '短文') def test_truncate_ja_long(self): txt = 'これはテスト用の長い文字列です。' self.assertEqual(truncate_ja(txt, 5), 'これはテ…') |
5‑2. テンプレート統合テスト
テンプレートを実際にレンダリングして、フィルターが期待通りに動作するか確認します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# myapp/tests/test_template_filters.py from django.test import SimpleTestCase from django.template import Context, Template import datetime class CustomFilterTemplateTests(SimpleTestCase): def test_ja_date_in_template(self): tmpl = Template( '{% load custom_filters %}' '{{ date|ja_date:"%Y/%m/%d" }}' ) ctx = Context({'date': datetime.date(2021, 5, 4)}) self.assertEqual(tmpl.render(ctx), '2021/05/04') def test_highlight_with_autoescape(self): tmpl = Template( '{% load custom_filters %}' '{{ text|highlight:"Django" }}' ) ctx = Context({'text': '<script>alert("x")</script> Django is great'}) # 期待結果は <mark>Django</mark> がエスケープされた形になる self.assertIn('<mark>Django</mark>', tmpl.render(ctx)) |
5‑3. キャッシュが原因で変更が反映されないときの対処
| 症状 | 原因 | 対策 |
|---|---|---|
| テンプレートを編集したのに画面が変わらない | 開発サーバーがテンプレートキャッシュを保持している | python manage.py runserver を再起動、または from django.core.cache import cache; cache.clear() を実行 |
フィルター追加後に ImportError が出る |
templatetags ディレクトリが INSTALLED_APPS に含まれていない |
該当アプリを settings.INSTALLED_APPS に追記 |
注: Django 標準コマンドに
clear_cacheは存在しません。キャッシュクリアは上記 Python コードか、使用しているキャッシュバックエンドの管理ツールで行ってください。
6. カスタムフィルターをパッケージ化して他プロジェクトでも再利用
6‑1. プロジェクト構成例
|
1 2 3 4 5 6 7 8 9 10 |
django-ja-filters/ ├─ src/ │ └─ django_ja_filters/ │ ├─ __init__.py │ └─ templatetags/ │ ├─ __init__.py │ └─ ja_filters.py ├─ pyproject.toml └─ README.md |
src/以下に実装を置くことで、ビルド時にパッケージ名とディレクトリ構造が衝突しません。
6‑2. pyproject.toml の基本設定
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# pyproject.toml [build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project] name = "django-ja-filters" version = "0.1.0" description = "日本語向け Django カスタムテンプレートフィルター集" readme = "README.md" authors = [{ name = "Your Name", email = "you@example.com" }] requires-python = ">=3.9" dependencies = ["Django>=4.2"] license = { text = "MIT" } [project.urls] Homepage = "https://github.com/yourname/django-ja-filters" |
setup.cfg(または setup.py)でパッケージ探索を有効にします。
|
1 2 3 4 5 6 7 |
# setup.cfg [options] packages = find: package_dir = =src include_package_data = true |
6‑3. 開発環境へのインストール
ローカル開発中は editable install が便利です。
|
1 2 3 |
$ pip install -e . # ソースコードを変更すると即座にインポート先にも反映されます |
6‑4. PyPI 公開手順
python -m buildで配布用アーカイブ(wheel と sdist)を作成twine upload dist/*で公式リポジトリへアップロード
|
1 2 3 |
$ python -m build $ twine upload dist/* |
- バージョンは セマンティックバージョニング に従い、機能追加はマイナーバージョン、破壊的変更はメジャーバージョンを上げます。
long_descriptionには README をそのまま流用すると、PyPI のパッケージページが見やすくなります。
7. まとめと次のアクション
- テンプレートエンジンは表示専用 と認識し、ロジックはビュー/サービス層に置くことで保守性を確保。
templatetagsディレクトリ配下にモジュールを作り、@register.filter(is_safe/needs_autoescape)で関数を登録すれば、テンプレートからシンプルに呼び出せます。- エスケープ制御は必ず意識 し、HTML が安全かどうかに応じて
is_safe=Trueとneeds_autoescape=Trueを使い分けましょう。 - 単体テストとテンプレート統合テストを併用すれば、実装ミスやキャッシュによる不整合を早期に検出できます。
- 最後に パッケージ化 して
pip installできる形にすれば、社内外のプロジェクトで再利用が容易になります。
次のステップ:本記事のコード例をローカルプロジェクトに組み込み、テストを走らせて動作確認してください。その後、必要に応じて自分だけのフィルターを追加し、
pyproject.tomlとsetup.cfgを整えて社内 PyPI へ公開すれば完了です。
ぜひ実際に手を書きながら学んでみてください!