モデル1つで管理画面が手に入る ― admin登録・汎用ビュー・テンプレート
記事モデルを1つ書いて admin.py に3行足したら、検索ボックスと絞り込み付きの管理画面が出てきた。自作CMSでいちばん時間が溶けるのが投稿・編集画面まわりなので、この節約は素直に大きいです。
本記事は「Djangoフレームワーク入門と実践」の第3回です。第1回でMTVの骨格を、第2回でモデルとマイグレーションを扱いました。今回で、記事を投稿して表示できる最小のCMSとして動く形まで持っていきます。
この記事でわかること
-
admin.pyの数行が、画面のどこに化けるか - 汎用ビュー(
ListView/DetailView)が肩代わりしている処理 - テンプレートの基本記法と、URLをハードコードしない書き方
- 一覧の絞り込みが、詳細ページには効かないこと
admin.py の3行が、画面のどこに出るか
第2回で作った Article と Category を登録します。
# articles/admin.py
from django.contrib import admin
from .models import Article, Category
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ("title", "category", "is_published", "published_at")
list_filter = ("is_published", "category")
search_fields = ("title", "body")
admin.site.register(Category)
Category のように admin.site.register(モデル) だけでも登録できます。ArticleAdmin のほうは表示のカスタマイズを足したもので、@admin.register() はそのデコレータ版の書き方です。
管理ユーザーを作って、/admin/ を開きます。
python manage.py createsuperuser
python manage.py runserver
この画面で見るのは、admin.py に書いた3行が画面のどこに化けたかです。一覧の列、右のフィルター欄、上部の検索ボックスの3か所を見てください。
対応はこうなっています。
| 書いたもの | 画面に出るもの |
|---|---|
list_display |
一覧の列(タイトル・カテゴリ・公開する・公開日時) |
list_filter |
右側の「フィルター」欄 |
search_fields |
上部の検索ボックス |
モデルの verbose_name
|
「記事」「カテゴリ」という日本語のラベル |
| フィールドの第1引数 | 「タイトル」「公開する」などの列見出し |
__str__() |
カテゴリ列に出ている「技術」「日記」 |
日本語化されているのは、第1回で LANGUAGE_CODE = "ja" にしたためです。日時が 2026年8月24日6:39 と出ているのも TIME_ZONE = "Asia/Tokyo" の効果で、同じレコードをshellで見ると 2026-08-23 21:39 です。DBはUTC、画面はJST。第1回で設定した USE_TZ の9時間ぶんが、ここに出ています。
管理画面は「サイト運営者向け」です
一般利用者に見せる画面ではありません。権限管理はできますが、エンドユーザー向けのUIとして作られていないので、公開機能をここで代用しないでください。URLを変えるだけを防御にせず、HTTPS、強い認証、最小権限、必要に応じたネットワーク制限を組み合わせます。Djangoの check --deploy と公式のDeployment checklistも公開前に確認します。
汎用ビューは、書かなかった処理を持っている
ここからが閲覧側です。汎用ビューを使うと数行で書けます。
# articles/views.py
from django.views.generic import DetailView, ListView
from .models import Article
class ArticleListView(ListView):
model = Article
template_name = "articles/list.html"
def get_queryset(self):
return Article.objects.filter(is_published=True).select_related("category")
class ArticleDetailView(DetailView):
model = Article
template_name = "articles/detail.html"
def get_queryset(self):
return Article.objects.filter(is_published=True)
select_related("category") は、カテゴリ名を表示するときにクエリが増えるのを防ぐためのものです。理由はこの記事の最後で触れ、第5回で実際のクエリ本数を測ります。
短く書けますが、短さより「何を肩代わりしているか」のほうが大事です。次の図の右側が、書かなかったのに動いている処理です。
今回 get_queryset() を上書きしたのは、下書きを一覧から外すためです。書かなければ「全件」になります。
ページ送りも1行で足せます。
paginate_by = 5
これだけで1ページ5件に分割され、テンプレートから page_obj が使えるようになります。記事11件で3ページに分かれ、3ページ目が1件になることを確認しました。
URLは書かずに name から引く
ルーティングを書きます。name= を付けておくのがポイントです。
# articles/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("", views.ArticleListView.as_view(), name="article_list"),
path("articles/<int:pk>/", views.ArticleDetailView.as_view(), name="article_detail"),
]
<int:pk> の pk は、第2回で「Djangoが自動で足す」と書いた主キーです。第1回のつまずき所②のとおり、これをプロジェクト側から include() するのを忘れないでください。
テンプレートは articles/templates/articles/ に置きます。アプリ名のディレクトリを1つ挟むのは、複数アプリで同名のテンプレートがぶつかるのを避けるためです。
<!-- articles/templates/articles/list.html -->
<h1>記事一覧</h1>
<ul>
{% for article in object_list %}
<li>
<a href="{% url 'article_detail' article.pk %}">{{ article.title }}</a>
<span>({{ article.category.name }})</span>
</li>
{% empty %}
<li>まだ記事がありません。</li>
{% endfor %}
</ul>
{% url 'article_detail' article.pk %} は、URLを直接書かずに urls.py の name から逆に引く書き方です。URL構造を変えたときに、テンプレートを直さなくて済みます。{% empty %} は0件のときのブロックで、この手の分岐を自分で書かなくてよくなっています。
一覧の変数名が object_list なのは ListView の既定です(context_object_name で変えられます)。
3件登録して2件だけ出るのが正しい
python manage.py runserver
http://127.0.0.1:8000/ を開くと、管理画面から投稿した記事のうち公開済みのものだけが並びます。
<h1>記事一覧</h1>
<ul>
<li><a href="/articles/2/">ORMを触った</a><span>(技術)</span></li>
<li><a href="/articles/1/">Djangoを始めた</a><span>(技術)</span></li>
</ul>
3件登録して2件だけ出ているのが正しい状態です。並び順が新しい順なのは、第2回でモデルに書いた Meta.ordering = ["-published_at"] が効いているためです。
閲覧側はここまでで40行ほどです。管理側はコードを書いていません。
ここが漏れやすい ― 一覧と詳細は別々
さて、一覧から下書きは消えました。では、その下書きのURLを直接叩いたらどうなるか。
上のコードで DetailView にも同じ get_queryset() を書いている点に注目してください。これがないと、下書き記事のURLを直接叩けば中身が読めてしまいます。
一覧から消えていると「見えなくなった」と思いがちですが、一覧の絞り込みは一覧にしか効きません。詳細ページは別のビューで、別にDBを引いています。両方に同じ条件が要ります。
Djangoにはテスト用のHTTPクライアントが同梱されていて、articles/tests.py に書いて python manage.py test articles で回せます。非公開記事のURLを叩いて404が返ることを、テストとして固定しておきます。
def test_draft_detail_returns_404(self):
r = self.client.get(f"/articles/{self.draft.pk}/")
self.assertEqual(r.status_code, 404)
実務ではこうなる
一覧で article.category.name を表示すると、素のままでは記事の件数だけ追加クエリが飛びます。上のコードで select_related("category") を入れているのはそのためです。これがN+1問題で、第5回で実際のクエリ本数を測って扱います。
管理画面のカスタマイズは、今回試した3項目(list_display/list_filter/search_fields)の範囲なら、ほぼ設定を書くだけで済みます。承認フローや複数モデルにまたがる一括操作は今回検証していません。まず運営者の定型作業に収まるかを確認し、要件が外れるなら専用画面やサービス層を検討します。
テンプレートにロジックを持ち込まないのも、書いていて分かります。Djangoのテンプレート言語は意図的に機能が絞ってあり、複雑な条件式は書けません。これは制約ではなく方針で、書けそうにないと感じたらそれはView側でやる仕事という合図です。
確認した範囲
Django 5.2.17、Python 3.11、SQLite。管理画面は実際にログインして記事一覧を表示し、その画面をそのまま掲載しています(list_display/list_filter/search_fields が反映されていること、日本語表示と日時のJST変換を確認)。
閲覧側は記事3件(うち下書き1件)で一覧と詳細をHTTP経由で確認し、次の4点を articles/tests.py のテストとして固定しました。下書きが一覧に出ないこと、下書きの詳細URLが404になること、paginate_by = 5 で11件が3ページに分かれ3ページ目が1件になること、select_related によって一覧が1クエリで済むこと。 python manage.py test articles で4件とも通っています。
なお paginate_by はサンプル本体の views.py には入れていません(記事に載せた最小形に合わせています)。ページ送りのテストだけ、その1行を一時的に足した状態で挙動を確かめています。
公式リファレンス
管理画面
ビューとテンプレート
公開前に読むもの
参考文献・参照資料
根拠は公式ドキュメントとサンプルのローカル実行結果です。管理画面のスクリーンショットはサンプルデータだけを表示した実画面です。
ここまでの3回で、Djangoの基礎(MTV・ORM・管理画面)を一通り通しました。次の第4回からは、同じことをSpring Bootで書いたらどうなるかを比べていきます。最初のテーマはORMの設計思想です。DjangoのModelは検索も保存も自分で持っていますが、Spring Data JPAはEntityとRepositoryを分けます。
なお第4回からは、Spring側シリーズと題材を揃えるため、Article ではなく Order/Product(注文受付)を使います。Djangoの読み方は第1〜3回のままです。

