前回の記事はこちら。

モデル

最初にモデルを編集します。
models.pyを下記のようにします。

Categoryクラスを作成して、Postクラスに新たにCategoryと紐づけるカラムを追加します。

posts/models.py

class Category(models.Model):
    title = models.CharField(max_length=255, 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 = 'カテゴリー'

    def __str__(self):
        return self.title

class Post(models.Model):
    title = models.CharField(max_length=255, verbose_name='タイトル')
    body = models.TextField(verbose_name='内容')
    is_public = models.BooleanField(default=True, verbose_name='公開')
    published_at = models.DateTimeField(verbose_name='公開日')
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, verbose_name ='カテゴリ')
    
    class Meta:
        verbose_name = '投稿'
        verbose_name_plural = '投稿'
    
    def __str__(self):
        return self.title

下記コマンドを実行しマイグレーションファイルを作成します。

$ python manage.py makemigrations posts

次の質問がきたら「1」を選択します。

It is impossible to add the field 'created_at' with 'auto_now_add=True' to category without providing a default. This is because the database needs something to populate existing rows.
1) Provide a one-off default now which will be set on all existing rows
2) Quit and manually define a default value in models.py.

デフォルト値を聞かれるのでそのままエンターします。(timezone.nowを使用)

Please enter the default value as valid Python.
Accept the default 'timezone.now' by pressing 'Enter' or provide another value.
The datetime and django.utils.timezone modules are available, so it is possible to provide e.g. timezone.now as a value.
Type 'exit' to exit this prompt
[default: timezone.now] >>>

posts/migrations/0002_category_alter_post_options_alter_post_body_and_more.pyというファイルが生成されます。

migrateコマンドを実行しましょう。

$ python manage.py migrate

これでテーブルが作成されます。

管理画面にカテゴリー項目を追加

カテゴリーを登録できるように管理画面に追加します。
投稿編集画面でもカテゴリーが選択できるように修正しましょう。

posts/admin.py

from django.contrib import admin

from .models import Post, Category

class PostAdmin(admin.ModelAdmin):
    list_display  = ['title', 'category', 'published_at']
    search_fields = ['title', 'body']

admin.site.register(Post, PostAdmin)


class CategoryAdmin(admin.ModelAdmin):
    list_display  = ['title', 'created_at', 'updated_at']

admin.site.register(Category, CategoryAdmin)

管理画面にカテゴリー項目が追加されました。

管理画面にカテゴリー項目を追加

これで管理画面からカテゴリーから登録できるようになります。
投稿の編集画面からもカテゴリーが選択できるようになっているので、カテゴリーの登録と投稿を編集してカテゴリーを紐づけてみましょう。

これで管理画面側の設定は完了です。

詳細画面にカテゴリー名表示

次は管理画面で登録した情報をフロント画面に表示してみましょう。
詳細画面は特に考えることなく表示したい箇所に下記を記述するだけです。

posts/templates/posts/post_detail.html

<div>{{ post.category }}</div>

一覧画面にカテゴリー名表示

一覧も基本的には同じで書きのようにすればカテゴリーを表示してくれます。

posts/templates/posts/post_list.html

<ul>
    {% for post in post_list %}
        <li>
            {{ post.category }}
            <a href="{% url 'posts:detail' post.id %}">{{ post.title }}</a>
        </li>
    {% endfor %}
</ul>

ただこのままだとn+1問題といってループの数だけカテゴリーのクエリが発行されてしまいます。
(Debug ToolbarのSQLを確認してみてください)
Eager Loadingを行いクエリをまとめましょう。
Djangoではビューのクエリにprefetch_relatedを追記するだけです。

posts/views.py

class IndexView(generic.ListView):
    paginate_by = 10

    def get_queryset(self):
        return (
            Post.objects
                .filter(is_public=True)
                .order_by('-published_at')
                .prefetch_related('category')
        )

表示結果は変わらずクエリの数を減らせたと思います。

管理画面の一覧も同じように対応しておきましょう。

posts/admin.py

class PostAdmin(admin.ModelAdmin):
    # ...

    def get_queryset(self, request):
        query = super().get_queryset(request)
        return query.prefetch_related('category')

カテゴリーで絞り込む

カテゴリー毎に表示できるようにしてみます。
/posts/category/1/にアクセスするとカテゴリーIDが1の投稿一覧を表示するようにします。

ルーティング

新しく追加するルーティングを設定します。

posts/views.py

urlpatterns = [
    path('', views.IndexView.as_view(), name='index'),
    path('category/<int:category>/', views.IndexView.as_view(), name='index_category'),
    path('<int:pk>/', views.DetailView.as_view(), name='detail'),
]

ビュー

get_querysetでカテゴリーパラメータが存在する場合は絞り込むように修正します。
新たにカテゴリー一覧とタイトルの変数を渡す為get_context_dataメソッドを作成します。
ここでcontextを返すことでテンプレートで表示できるようになります。

posts/views.py

class IndexView(generic.ListView):
    paginate_by = 10

    def get_queryset(self):
        
        posts = Post.objects.filter(is_public=True)

        # カテゴリーが指定されていたら条件追加
        if ('category' in self.kwargs):
            posts = posts.filter(category=self.kwargs['category'])

        posts = posts.order_by('-published_at').prefetch_related('category')

        return posts


    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        
        context['title'] = 'お知らせ'

        context['categories'] = Category.objects.values('id', 'title')

        # 現在のページのカテゴリータイトル
        if ('category' in self.kwargs):
            result = next(
                item for item in context['categories']
                if item['id'] == self.kwargs['category']
            )
            context['title'] = result['title']
        
        return context

テンプレート

一覧のテンプレートでは各カテゴリーにリンクするナビゲーションを追加します。

posts/templates/posts/post_list.html

{% extends 'base.html' %}

{% block main %} 
    <h1>{{ title }}</h1>

    <ul>
        {% for category in categories %}
            <li><a href="{% url 'posts:index_category' category.id %}">{{ category.title }}</a></li>
        {% endfor %}
    </ul>

    {% if post_list %}
        <ul>
            {% for post in post_list %}
                <li>
                    <a href="{% url 'posts:detail' post.id %}">{{ post.title }}</a>
                    {{ post.category }}
                </li>
            {% endfor %}
        </ul>

        {% if is_paginated %}
            {% if page_obj.has_previous %}
                <a href="?page=1">
                    &laquo;
                </a>
                <a href="?page={{ page_obj.previous_page_number }}">
                    &lsaquo;
                </a>
            {% endif %}

            {% for number in page_obj.paginator.page_range %}
                {% if page_obj.number == number %}
                    {{ number }}
                {% else %}
                    <a href="?page={{ number }}">{{ number }}</a>
                {% endif %}
            {% endfor %}

            {% if page_obj.has_next %}
                <a href="?page={{ page_obj.next_page_number }}">
                    &rsaquo;
                </a>
                <a href="?page={{ page_obj.paginator.num_pages }}">
                    &raquo;
                </a>
            {% endif %}
        {% endif %}
    {% else %}
        <p>{{ title }}はありません。</p>
    {% endif %}
{% endblock %}

これでうまく動いているのではないかと思いますので今回は以上になります。
次回は多対多の関係をやってく予定です。

GitHubはこちら。