Djangoのモデル設計とfixtureでブログのデータベースを作る【AIと一緒に作るDjangoブログ入門 #4】

Djangoのモデル設計とfixtureでブログのデータベースを作る【AIと一緒に作るDjangoブログ入門 #4】

Post・Category・DisplaySlotの3つのモデルを設計し、管理画面で記事を管理できるようにする章。fixtureでのテストデータ投入、変更に強いDB設計(フラグ vs 別テーブル)の選び方も解説。

はじめに

前章でトップページのデザインが完成しました。ただ表示していたのはコードに直接書いたダミーデータです。

この章ではモデルを作ってMySQLにデータを保存し、管理画面から記事を管理できるようにします。

この章でやること:

  • モデル(Category・Post・DisplaySlot)を作る
  • マイグレーションでDBにテーブルを作る
  • 管理画面に登録する
  • fixtureでテストデータを投入する
  • views.pyをDBから取得するコードに差し替える
ココココ

ついにDBデビューだね!ここを越えるとブログっぽさが一気に出るよ〜


モデルとは何か

モデルはDjangoとデータベースをつなぐ「設計図」です。

Pythonのクラスとして書くと、Djangoが自動でSQLに変換してMySQLにテーブルを作ってくれます。SQLを直接書く必要はありません。

# こう書くと...
class Post(models.Model):
    title = models.CharField(max_length=200)

# MySQLにこんなテーブルが作られる
# CREATE TABLE blog_post (
#     id INT AUTO_INCREMENT PRIMARY KEY,
#     title VARCHAR(200)
# );

DBの設計

今回作るテーブルは3つです。

Categoryテーブル(カテゴリ)

フィールド 内容
name CharField カテゴリ名(Django・Docker等)
slug SlugField URL用(django・docker等)

Postテーブル(記事)

フィールド 内容
title CharField タイトル
slug SlugField URL用(記事ごとに固有)
summary CharField 一覧用の要約
body TextField 本文(Markdown)
category ForeignKey カテゴリ(Categoryと紐付け)
is_published BooleanField 公開フラグ
view_count PositiveIntegerField 閲覧数
published_at DateTimeField 公開日時
created_at DateTimeField 作成日時(自動)
updated_at DateTimeField 更新日時(自動)

DisplaySlotテーブル(表示スロット)

フィールド 内容
slot_type CharField スロット種別(特集・ピックアップ)
post ForeignKey 表示する記事
order IntegerField 表示順

なぜDisplaySlotを別テーブルにするのか

「特集にどの記事を出すか」を管理する方法は2つあります:

方法 内容
フラグ方式 Postに is_featured フィールドを追加する
別テーブル方式 DisplaySlotテーブルで管理する

フラグ方式はシンプルですが、「どのスロットに何件出すか」をコードで固定する必要があります。別テーブル方式なら管理画面からスロットの内容を自由に変えられます。変更に強い設計です。

ココココ

迷ったら別テーブル!って覚えておいて。後から「やっぱ3個並べたい」ってなっても怖くないよ


Step1. models.pyを書く

~/myblog/blog/models.py を開いて以下に書き換えます。この章では文字データだけを扱い、アイキャッチ画像は第6章で追加します。

from django.db import models
from django.urls import reverse


class Category(models.Model):
    """記事のカテゴリ。"""

    name = models.CharField(max_length=100, verbose_name='カテゴリ名')
    # URLに使うため半角英数字・ハイフンのみ。uniqueで重複を防ぐ
    slug = models.SlugField(unique=True, verbose_name='スラッグ')

    class Meta:
        verbose_name = 'カテゴリ'
        verbose_name_plural = 'カテゴリ'

    def __str__(self):
        return self.name


class Post(models.Model):
    """ブログ記事。"""

    title = models.CharField(max_length=200, verbose_name='タイトル')
    # URL に使う識別子。例: /posts/django-tutorial/
    # SEOと可読性のため記事ごとに固有の英数字を必ず設定する
    slug = models.SlugField(max_length=200, unique=True, verbose_name='URL用スラッグ')
    # 一覧ページ用の短い説明。省略可能
    summary = models.CharField(max_length=300, verbose_name='要約', blank=True)
    # 本文(Markdown形式)。管理画面では後の章で Toast UI Editor に置き換える
    body = models.TextField(verbose_name='本文')
    # カテゴリを削除しても記事は残す(SET_NULL)
    category = models.ForeignKey(
        Category,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        verbose_name='カテゴリ',
    )
    is_published = models.BooleanField(default=False, verbose_name='公開')
    # 未公開の場合はNullを許容する
    published_at = models.DateTimeField(null=True, blank=True, verbose_name='公開日時')
    # 記事詳細ページを開くたびに1加算する
    view_count = models.PositiveIntegerField(default=0, verbose_name='閲覧数')
    created_at = models.DateTimeField(auto_now_add=True, verbose_name='作成日時')
    updated_at = models.DateTimeField(auto_now=True, verbose_name='更新日時')

    class Meta:
        verbose_name = '記事'
        verbose_name_plural = '記事'
        # 公開日時の新しい順に並べる。
        # 同じ published_at の記事が複数あっても順序がブレないよう、第2キーで id 降順を必ず併用する
        ordering = ['-published_at', '-id']

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        """この記事の詳細ページURLを返す。

        sitemap.xml や管理画面の「サイトを表示」リンクで使われる。
        """
        return reverse('blog:detail', kwargs={'slug': self.slug})


class DisplaySlot(models.Model):
    """トップページの表示スロット管理。

    どのスロットにどの記事を置くかを管理する。
    スロットの種類や数をコードを変えずに管理画面から追加・変更できる。
    """

    SLOT_FEATURED = 'featured'
    SLOT_PICKUP = 'pickup'

    SLOT_CHOICES = [
        (SLOT_FEATURED, '特集バナー'),
        (SLOT_PICKUP, 'ピックアップ'),
    ]

    slot_type = models.CharField(max_length=50, choices=SLOT_CHOICES, verbose_name='スロット')
    post = models.ForeignKey(Post, on_delete=models.CASCADE, verbose_name='記事')
    # 同じスロット内での並び順。数字が小さいほど先に表示する
    order = models.IntegerField(default=0, verbose_name='表示順')

    class Meta:
        verbose_name = '表示スロット'
        verbose_name_plural = '表示スロット'
        ordering = ['slot_type', 'order']

    def __str__(self):
        return f'{self.get_slot_type_display()} / {self.post.title}'
ココココ

get_absolute_url 忘れがちだけど絶対に書いておいて!sitemap.xml と管理画面の「サイトを表示」がここを見るから、後で「あれ動かない」になるよ


Step2. マイグレーションを実行する

モデルを書いただけではDBにテーブルは作られません。マイグレーションが必要です。

# マイグレーションファイルを生成する(設計図を作る)
docker compose run --rm web python manage.py makemigrations

# マイグレーションを実行する(実際にテーブルを作る)
docker compose run --rm web python manage.py migrate

makemigrationsとmigrateの違い

コマンド 役割
makemigrations models.pyの変更を検知してマイグレーションファイルを生成する
migrate マイグレーションファイルをもとにDBにテーブルを作る

2段階になっている理由は、生成されたファイルをGitで管理してチームで共有するためです。


Step3. 管理画面に登録する

~/myblog/blog/admin.py を開いて以下に書き換えます:

from django.contrib import admin

from .models import Category, DisplaySlot, Post


@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    """カテゴリの管理画面設定。"""

    list_display = ['name', 'slug']
    # nameを入力すると自動でslugを生成する
    prepopulated_fields = {'slug': ('name',)}


@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    """記事の管理画面設定。"""

    list_display = ['title', 'category', 'is_published', 'view_count', 'published_at']
    list_filter = ['is_published', 'category']
    # 一覧画面から直接クリックで切り替えられる
    list_editable = ['is_published']
    search_fields = ['title', 'body']
    # titleを入力すると自動でslugを生成する
    prepopulated_fields = {'slug': ('title',)}


@admin.register(DisplaySlot)
class DisplaySlotAdmin(admin.ModelAdmin):
    """表示スロットの管理画面設定。"""

    list_display = ['slot_type', 'post', 'order']
    list_editable = ['order']
    list_filter = ['slot_type']
    ordering = ['slot_type', 'order']

なお、本文の編集を Markdown エディタ(Toast UI Editor)に置き換えたり、アイキャッチのプレビューを管理画面に出したりするカスタマイズは後の章で必ず追加します。まずは素のDjango管理画面で動く形を固めます。


Step4. スーパーユーザーを作る

管理画面にログインするためのユーザーを作ります。

docker compose run --rm -it web python manage.py createsuperuser

ユーザー名・メールアドレス・パスワードを聞かれるので入力します。

作成後は http://localhost/admin/ にアクセスしてログインできます。


Step5. fixtureでテストデータを投入する

管理画面から1件ずつ記事を作るのは大変です。fixtureはテストデータをJSONファイルに書いておき、コマンド1つで一括投入できる仕組みです。第3章でダミーとして書いた「特集バナー+記事一覧」を、ここで本物のデータにします。

まずfixture用のフォルダとファイルを作ります。

mkdir -p ~/myblog/blog/fixtures

~/myblog/blog/fixtures/initial_data.json を作成して、以下をそのまま貼り付けます。

[
  { "model": "blog.category", "pk": 1, "fields": { "name": "Django", "slug": "django" } },
  { "model": "blog.category", "pk": 2, "fields": { "name": "Docker", "slug": "docker" } },
  { "model": "blog.category", "pk": 3, "fields": { "name": "Python", "slug": "python" } },

  {
    "model": "blog.post",
    "pk": 1,
    "fields": {
      "title": "AIと一緒に作るDjangoブログ入門",
      "slug": "django-blog-intro",
      "summary": "DockerとDjangoを使ってブログサイトをゼロから作る方法を解説します。",
      "body": "## はじめに\n\nこの記事では **Docker** と **Django** を使ってブログサイトをゼロから作ります。",
      "category": 1,
      "is_published": true,
      "view_count": 0,
      "published_at": "2026-05-21T00:00:00Z",
      "created_at": "2026-05-21T00:00:00Z",
      "updated_at": "2026-05-21T00:00:00Z"
    }
  },
  {
    "model": "blog.post",
    "pk": 2,
    "fields": {
      "title": "Docker入門:なぜDockerを使うのか",
      "slug": "docker-intro",
      "summary": "MacにPythonをインストールしなくていい理由と、Dockerの仕組みを解説します。",
      "body": "Dockerは仮想の箱を作るツールです。Pythonが入った箱を用意するだけで動かせます。",
      "category": 2,
      "is_published": true,
      "view_count": 0,
      "published_at": "2026-05-20T00:00:00Z",
      "created_at": "2026-05-20T00:00:00Z",
      "updated_at": "2026-05-20T00:00:00Z"
    }
  },
  {
    "model": "blog.post",
    "pk": 3,
    "fields": {
      "title": "Bootstrap5でデザインを整える",
      "slug": "bootstrap5-design",
      "summary": "CSSを書かなくてもBootstrap5を使えば綺麗なデザインが作れます。",
      "body": "Bootstrap5はCSSのフレームワークです。クラスをHTMLに書くだけでレスポンシブなレイアウトが作れます。",
      "category": 1,
      "is_published": true,
      "view_count": 0,
      "published_at": "2026-05-19T00:00:00Z",
      "created_at": "2026-05-19T00:00:00Z",
      "updated_at": "2026-05-19T00:00:00Z"
    }
  },
  {
    "model": "blog.post",
    "pk": 4,
    "fields": {
      "title": "settings.pyの設定を理解する",
      "slug": "settings-py-guide",
      "summary": "DEBUG・ALLOWED_HOSTS・DATABASESなど重要な設定項目を解説します。",
      "body": "DjangoのDEBUG・ALLOWED_HOSTS・DATABASESなど、settings.pyの重要な設定項目を解説します。",
      "category": 1,
      "is_published": true,
      "view_count": 0,
      "published_at": "2026-05-18T00:00:00Z",
      "created_at": "2026-05-18T00:00:00Z",
      "updated_at": "2026-05-18T00:00:00Z"
    }
  },
  {
    "model": "blog.post",
    "pk": 5,
    "fields": {
      "title": "環境変数(.env)とは何か",
      "slug": "env-file-guide",
      "summary": "パスワードをコードに直接書いてはいけない理由を解説します。",
      "body": "パスワードをコードに直接書くとGitHubで全世界に公開されてしまいます。.envに書くのが安全です。",
      "category": 3,
      "is_published": false,
      "view_count": 0,
      "published_at": null,
      "created_at": "2026-05-17T00:00:00Z",
      "updated_at": "2026-05-17T00:00:00Z"
    }
  },

  { "model": "blog.displayslot", "pk": 1, "fields": { "slot_type": "featured", "post": 1, "order": 0 } },
  { "model": "blog.displayslot", "pk": 2, "fields": { "slot_type": "pickup",   "post": 2, "order": 0 } },
  { "model": "blog.displayslot", "pk": 3, "fields": { "slot_type": "pickup",   "post": 3, "order": 1 } }
]

このデータの読み方

部分 内容
blog.category ×3 Django / Docker / Python のカテゴリ
blog.post ×5 記事5件(うち pk:5is_published: false =下書き。一覧に出ないことを後で確認できます)
blog.displayslot ×3 featured(特集バナー)に記事1、pickup(注目記事)に記事2・3を割り当て

pk(プライマリキー)はレコードのID。categorypost の数字は、その pk を指す参照です(例: post の "category": 1 は「Djangoカテゴリ」)。

ココココ

is_published: false の記事をわざと1件入れてるのがポイント。このあと Step6 のビューで filter(is_published=True) を使うから、「下書きは一覧に出ない」が本当に効いてるか確認できるよ!

投入コマンドを実行します。

docker compose run --rm web python manage.py loaddata initial_data

Installed 11 object(s) と出れば成功です。ブラウザで http://localhost を開くと、第3章でダミーだった特集バナーと記事一覧が、今度はDBのデータで表示されます。

fixtureの使いどころ

用途 説明
開発環境のリセット DBを消してやり直すときに同じデータを復元できる
チーム開発 全員が同じテストデータで開発できる

逆に、今のDBの中身をfixtureとして書き出すこともできます(バックアップやデータ更新に便利)。

docker compose run --rm web python manage.py dumpdata blog --indent 2 > blog/fixtures/initial_data.json

Step6. views.pyをDBから取得するコードに書き換える

~/myblog/blog/views.py を開いて以下に書き換えます:

from django.shortcuts import render

from .models import Category, DisplaySlot, Post


def index(request):
    """トップページ(記事一覧)を表示する。"""

    # 公開済みの記事を新しい順に取得する(モデルの ordering と揃える)
    published = Post.objects.filter(is_published=True).order_by('-published_at', '-id')

    # 特集バナー:管理画面で選ばれたスロットの先頭1件
    # スロットが未設定の場合は最新の公開記事を代わりに表示する
    # post__is_published=True で「下書きをスロットに入れた時にトップへ漏れる」のを防ぐ
    featured_slot = (
        DisplaySlot.objects
        .filter(slot_type=DisplaySlot.SLOT_FEATURED, post__is_published=True)
        .select_related('post')
        .first()
    )
    featured = featured_slot.post if featured_slot else published.first()

    # ピックアップ:管理画面で選ばれた記事を表示順に並べる(下書きは除外)
    pickup = (
        DisplaySlot.objects
        .filter(slot_type=DisplaySlot.SLOT_PICKUP, post__is_published=True)
        .select_related('post')
        .order_by('order')
    )
    pickup_posts = [slot.post for slot in pickup]

    # 記事一覧:特集・ピックアップを除いた公開記事を新しい順に表示する
    exclude_pks = [p.pk for p in pickup_posts]
    if featured:
        exclude_pks.append(featured.pk)
    post_list = published.exclude(pk__in=exclude_pks)

    # サイドバー:よく読まれている記事(閲覧数の多い順で3件)
    popular_posts = published.order_by('-view_count')[:3]

    # サイドバー:カテゴリ一覧
    categories = Category.objects.all()

    context = {
        'featured': featured,
        'pickup': pickup_posts,
        'post_list': post_list,
        'popular_posts': popular_posts,
        'categories': categories,
    }
    return render(request, 'blog/index.html', context)

テンプレートをDB版に合わせる

ビューが渡す変数(post_list)とモデルのフィールドに合わせて、~/myblog/blog/templates/blog/index.html の記事一覧部分を更新します。

{# 記事一覧(DBの公開記事。特集・ピックアップを除いた残り) #}
{% for post in post_list %}
<article class="blog-post mb-4">
    <h2 class="display-5 mb-1">{{ post.title }}</h2>
    <p class="blog-post-meta text-secondary">
        {{ post.published_at|date:"Y年n月j日" }}
        {% if post.category %}{{ post.category.name }}{% endif %}
    </p>
    <p>{{ post.summary }}</p>
</article>
{% empty %}
<p>まだ記事がありません。</p>
{% endfor %}

{% empty %} は、ループする要素が0件のときに表示される構文です。記事が無いときに空白ではなく「まだ記事がありません」と出せます。


Step7. 起動確認

cd ~/myblog
docker compose up

ブラウザで http://localhost を開いてDBのデータが表示されれば成功です。

管理画面(http://localhost/admin/)から「表示スロット」を追加すると特集・ピックアップに任意の記事を表示できます:

  1. 「表示スロット」→「追加」
  2. スロット:特集バナー または ピックアップ を選ぶ
  3. 記事を選んで表示順を入力して保存

まとめ

この章でダミーデータからDBへの切り替えが完成しました。

管理画面で記事を登録
  ↓
DisplaySlotでトップページの表示を管理
  ↓
views.pyがDBから取得してテンプレートに渡す
  ↓
ブラウザに表示

次章では、記事タイトルをクリックして記事詳細ページを開けるようにし、カテゴリ絞り込みキーワード検索まで作ります。

関連記事

Markdownで記事を綺麗に表示する — コードハイライトと目次【AIと一緒に作るDjangoブログ入門 #6.1】
Django
Markdownで記事を綺麗に表示する — コードハイライトと目次【AIと一緒に作るDjangoブログ入門 #6.1】

第5章まで素のテキストだった記事本文を、Markdownで整形して表示できるようにする章。Python-Markdow…

Django記事詳細・カテゴリ・キーワード検索ページの実装【AIと一緒に作るDjangoブログ入門 #5】
Django
Django記事詳細・カテゴリ・キーワード検索ページの実装【AIと一緒に作るDjangoブログ入門 #5】

slug方式の記事詳細ページ、カテゴリ別の一覧、キーワード検索を実装。base.htmlでの共通レイアウト化、F()式…

テンプレート継承でDjangoブログのトップページを作る【AIと一緒に作るDjangoブログ入門 #3】
Django
テンプレート継承でDjangoブログのトップページを作る【AIと一緒に作るDjangoブログ入門 #3】

views.pyでダミーデータを用意し、Djangoのテンプレート継承(base.html)でブログのトップページを作…