Django公式チュートリアルやってみた(5~7)

# はじめに
今回はDjango公式チュートリアルをやってみたので学んだことをアウトプットをしていきます。

[1~4の記事はこちらから](https://qiita.com/Yuya_baseball/items/fdac797512aba079261a)
本記事では5~7について学んだことを書いていきます。

テストの部分の理解が曖昧だったのでかなりボリューミーかと...

※私が学んだ部分のみ書いているので公式チュートリアルを網羅したい方は、是非ご自身でやってみたほうが早いかと思います。

https://docs.djangoproject.com/ja/4.2/intro/

# はじめての Django アプリ作成、その 5
## なぜテストが必要なのか
- 問題点の早期発見が可能
- 人為的なミスや誤りを防げる
- テストを書くことはチームで共同作業を行う上で役立つ
## Djangoでテストコードを書いてみる
まずはバグを確認。 
`Qustion.was_published_recently()`のメソッドはQuestionが昨日以降に作成された場合に True を返し未来の日付になっている場合には False を返す

しかし、現状のシステムでは未来の日付の場合でも True を返してしまう。
これがバグとなっている。

これが本当か、まずは一度`shell`を使って確かめましょう。
```
$ python manage.py shell
```
```python
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # pub_dateが30日先のQuestionインスタンスを作成する。
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # 30日先って本当に最近か？
>>> future_question.was_published_recently()
True
```
公式チュートリアル上
30日先は未来ではないため、この結果は間違っています。

## バグをあぶり出すためにテストを作成する
問題をテストするために shell でたった今したことこそ、自動テストでやります。

pollsディレクトリ配下に`tests.py`を作成して以下を追記しましょう。
```polls/tests.py
import datetime

from django.test import TestCase
from django.utils import timezone

from .models import Question


class QuestionModelTests(TestCase):
    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently()はpub_dateが未来の質問に対してFalseを返します。
        """
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)
```

## 実際にテストを実行
```
python manage.py test polls
```

```
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
    self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)
Destroying test database for alias 'default'...
```
上記で起きたことを整理しましょう
- `manage.py test polls` は、polls アプリケーション内にあるテストを探す
- `django.test.TestCase` クラスのサブクラスを発見
- テストのための特別なデータベースを作成
- テスト用のメソッドとして、`test` で始まるメソッドを探します
- `test_was_published_recently_with_future_question` の中で、`pub_date` フィールドに今日から30日後の日付を持つ Question インスタンスが作成されます
- そして最後に `assertIs()` メソッドを使うことで、本当に返してほしいのは `False` だったにもかかわらず、 `was_published_recently()` が `True` を返していることを発見します
## バグを修正する
ということでバグの原因を発見したので
`models.py`を修正しましょう。
```polls/models.py
def was_published_recently(self):
    now = timezone.now()
    return now - datetime.timedelta(days=1) <= self.pub_date <= now
```

もう一度テストを実行した結果が以下です。
```
python manage.py test polls

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
Destroying test database for alias 'default'...
```
これでバグが解消されました。
## 複数のテストを実行
もしかするとこれ以外のバグが今後見つかるかもしれないので
先に潰しておきましょう。

`tests.py`に以下のコードを追記します。

```polls/tests.py
def test_was_published_recently_with_old_question(self):
    """
    was_published_recently()は、pub_dateが1日以上古い質問に対してFalseを返す
    """
    time = timezone.now() - datetime.timedelta(days=1, seconds=1)
    old_question = Question(pub_date=time)
    self.assertIs(old_question.was_published_recently(), False)


def test_was_published_recently_with_recent_question(self):
    """
    was_published_recently()は、published_dateが直近1日以内の質問に対してTrueを返す
    """
    time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
    recent_question = Question(pub_date=time)
    self.assertIs(recent_question.was_published_recently(), True)
```
これで過去、現在、未来に対してのテストが揃いました。
これで期待通りに動作する事を保証できます。

## Django の View をテストする
Django は、ビューレベルでのユーザとのインタラクションをシミュレートすることができる Client を用意しています。これを tests.py の中や shell でも使うことができます。

まずはshellから
```
$ python manage.py shell
```
下記のコードを1行ずつ実行する
```python
from django.test.utils import setup_test_environment
# テンプレートのレンダラーをインストール
# response.context を調査できる
setup_test_environment()

from django.test import Client
# テストクライアントのクラスをインポート
client = Client()
# クライアントのインスタンスを生成

response = client.get("/")
# 実行結果
Not Found: /

response.status_code
# 実行結果
404

from django.urls import reverse
response = client.get(reverse('polls:index'))
response.status_code

# 実行結果
200

response.content

# 実行結果
b'\n  <ul>\n    \n      <li><a href="/polls/3/">test3</a></li>\n    \n      <li><a href="/polls/2/">hello</a></li>\n    \n      <li><a href="/polls/1/">what&#x27;s up?</a></li>\n    \n  </ul>\n\n'

response.context['latest_question_list']

# 実行結果
<QuerySet [<Question: test3>, <Question: hello>, <Question: what's up?>]>
```
現在の投票のリストは
まだ公開されていない (つまり pub_date の日付が未来になっている) 
投票が表示される状態になっています。これを修正しましょう。

```polls/views.py
...
# 以下のimportを追加
from django.utils import timezone

...
class IndexView(generic.ListView):
    template_name = "polls/index.html"
    context_object_name = "latest_question_list"

    # get_queryset メソッドを修正
    def get_queryset(self):
    """
    過去に公表された5つの質問（将来公表される予定のものは含まない）を返す。
    """
    return Question.objects.filter(
          pub_date__lte=timezone.now()
        ).order_by('-pub_date')[:5]
```
## View のテストを追加する

```polls/tests.py
# 以下のfromを追記
from django.urls import reverse

"""
以下は question を簡単に作れるようにするショートカット関数と
新しいテストクラスを追記
"""

# question を簡単に作れるようにするショートカット関数
def create_question(question_text, days):
    """
    引数から質問を作成する。
    過去に投稿された質問を作りたいなら-1~nの値を第二引数に取り
    まだ公開されてない質問を作成したいなら+1~nの値を第二引数に取る
    """
    time = timezone.now() + datetime.timedelta(days=days)
    return Question.objects.create(question_text=question_text, pub_date=time)


class QuestionIndexViewTests(TestCase):
    def test_no_questions(self):
        """
        質問がない場合は、適切なメッセージが表示
        """
        response = self.client.get(reverse("polls:index"))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No polls are available.")
        self.assertQuerySetEqual(response.context["latest_question_list"], [])

    def test_past_question(self):
        """
        過去の投稿日の質問一覧を表示
        """
        question = create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse("polls:index"))
        self.assertQuerySetEqual(
            response.context["latest_question_list"],
            [question],
        )

    def test_future_question(self):
        """
        投稿日が未来の質問は表示されません
        """
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse("polls:index"))
        self.assertContains(response, "No polls are available.")
        self.assertQuerySetEqual(response.context["latest_question_list"], [])

    def test_future_question_and_past_question(self):
        """
        過去の問題と未来の問題の両方が存在する場合でも、表示されるのは過去の問題のみ
        """
        question = create_question(question_text="Past question.", days=-30)
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse("polls:index"))
        self.assertQuerySetEqual(
            response.context["latest_question_list"],
            [question],
        )

    def test_two_past_questions(self):
        """
        過去の質問2つが表示されているか確認
        """
        question1 = create_question(question_text="Past question 1.", days=-30)
        question2 = create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse("polls:index"))
        self.assertQuerySetEqual(
            response.context["latest_question_list"],
            [question2, question1],
        )
```

## DetailView のテスト
上記のテストは上手く動作して未来の質問はindexに表示されないが、 detail.html への正しいURLを知っていたり推測したユーザは、まだページに到達する事が出来る。そのため同じように未来の投稿日の場合はページを表示しないように polls/views.py コードを書き換える必要がある。

```polls/views.py
class DetailView(generic.DetailView):
    ...

    def get_queryset(self):
        """
        まだ公開されていない質問は除外
        """
        return Question.objects.filter(pub_date__lte=timezone.now())
```
tests.py に下記のコードを追記
```polls/tests.py
class QuestionDetailViewTests(TestCase):
    def test_future_question(self):
        """
        detail.htmlの未来の日付のページにアクセスする場合は404を表示
        """
        future_question = create_question(question_text="Future question.", days=5)
        url = reverse("polls:detail", args=(future_question.id,))
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_past_question(self):
        """
        detail.htmlの過去の日付のページにアクセスする場合はページを表示
        """
        past_question = create_question(question_text="Past Question.", days=-5)
        url = reverse("polls:detail", args=(past_question.id,))
        response = self.client.get(url)
        self.assertContains(response, past_question.question_text)
```

# はじめての Django アプリ作成、その 6, 7
## スタイルシート(.css)を追加
pollsディレクトリにstaticディレクトリを作成する。
その結果、Djangoはそこから静的ファイルを探してくれます。

先ほど追加したディレクトリに style.css を追加して
超簡単ですが、`a`タグの文字色を緑にしてましょう
```polls/static/polls/style.css
li a {
    color: green;
}
```
次に、`polls/templates/polls/index.html`に先程のCSSの記載を反映させる様にします。
```polls/templates/polls/index.html
{% load static %}

<link rel="stylesheet" href="{% static 'polls/style.css' %}">
```
## 画像も追加する
CSSだけではなく画像ファイルも追加してみましょう
`static/polls/images/background.png`を追加して
`sytle.css`にコードを追記しましょう。
```polls/static/polls/style.css
body {
    background: white url("images/background.png") no-repeat;
}
```
## adminのフォームをカスタマイズする
admin フォームの表示方法や操作の仕方をデフォルトから変更したいこともよくあります。
それに対応するためには、オブジェクトを登録する時にオプションを指定します。

## 編集フォームでのフィールドの並び順を替える
```polls/admin.py
from django.contrib import admin

from .models import Question


class QuestionAdmin(admin.ModelAdmin):
    fields = ["pub_date", "question_text"]


admin.site.register(Question, QuestionAdmin)
```
この様にすると、カラムの位置が変更されます。
> ![スクリーンショット 2025-01-14 0.29.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/05553d4d-8c39-40c2-9be7-32c754a2aabc.png)
## フィールドの分割
現在は2つしかフィールドがありませんが
数十のフィールドがある場合は、非常にややこしくなります。
そのために、フィールドを分割も可能です。

```polls/admin.py
from django.contrib import admin

from .models import Question


class QuestionAdmin(admin.ModelAdmin):
    fieldsets = [
        (None, {"fields": ["question_text"]}),
        ("Date information", {"fields": ["pub_date"]}),
    ]


admin.site.register(Question, QuestionAdmin)
```
## ChoiceオブジェクトをQuestionフォームから追加・編集する
QuestionフォームからChoiceの部分を追加・編集できるようにします。
```polls/admin.py
from django.contrib import admin

# Choiceを追記
from .models import Choice, Question

class ChoiceInline(admin.StackedInline):
    model = Choice
    extra = 3

class QuestionAdmin(admin.ModelAdmin):
    fieldsets = [
        (None,               {'fields': ['question_text']}),
        ('Date information', {'fields': ['pub_date'], 'classes': ['collapse']}),
    ]
    inlines = [ChoiceInline]

admin.site.register(Question, QuestionAdmin)
```
コードを追加するとQuestionフォームに3つ選択肢が増やされました。
ただこれだと、多くの画面スペースを必要とするのでインライン要素にする。

class ChoiceInline の引数を TabularInline に変更する。
```
class ChoiceInline(admin.TabularInline):
    #...
```
かなりスッキリしましたね。

## 質問一覧ページをカスタマイズ

（http://127.0.0.1:8000/admin/polls/question/）

上記のURLに質問の一覧が表示されている。

現在は オブジェクトの名前（どんな質問が格納されているのがわかる）だけが表示されていますが、各フィールドの値を表示してわかりやすくする。

```polls/admin.py
class QuestionAdmin(admin.ModelAdmin):
    # ...
    list_display = ('question_text', 'pub_date', 'was_published_recently')
```

各カラムのヘッダーをクリックすると並び替えを行えるが、
`was_published_recently` だけは並び替えをサポート出来ていないので、
デコレータを使用して並び替えをする。

```polls/models.py
from django.contrib import admin

class Question(models.Model):
    # ...

    # デコレータを追記
    @admin.display(
        boolean=True,
        ordering='pub_date',
        description='Published recently?',
    )
    def was_published_recently(self):
        now = timezone.now()
        return now - datetime.timedelta(days=1) <= self.pub_date <= now
```
## 質問を日付、文字検索で絞り込む
- pub_date の日付を元に質問を絞れるようにする
- 質問を文字で検索できる様にする
```polls/admin.py
from django.contrib import admin

from .models import Choice, Question

class ChoiceInline(admin.StackedInline):
    model = Choice
    extra = 3

class QuestionAdmin(admin.ModelAdmin):
    fieldsets = [
        (None,               {'fields': ['question_text']}),
        ('Date information', {'fields': ['pub_date'], 'classes': ['collapse']}),
    ]
    inlines = [ChoiceInline]
    # 以下の1行で日付で絞り込みが可能になる
    list_filter = ['pub_date']
    # 質問の検索機能を追加
    search_fields = ["question_text"]

admin.site.register(Question, QuestionAdmin)
```
## 管理サイトの見た目をカスタマイズ
`manage.py`と同じ階層に`templates`を作成
作成したことを`settings.py`に追記しましょう
```mysite/settings.py
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        # DIRSに追記
        'DIRS': [BASE_DIR / 'templates'],
        'APP_DIRS': True,
        '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',
            ],
        },
    },
]
```
templatesの配下に`admin`フォルダを作成。
その中にデフォルトのDjango adminのテンプレートをコピーして貼り付ける。

場所は 下記のコマンドから確認できる。
```
python -c "import django; print(django.__path__)"
```
ファイルを編集して
``{{ site_header|default:_('Django administration') }} ``を置き換えます
```templates/admin/base_site.html
{% block branding %}
<h1 id="site-name"><a href="{% url 'admin:index' %}">Django公式チュートリアル</a></h1>
{% endblock %}
```
これでデフォルトのテンプレートをオーバライドすることができました。

# 良かったこと
体系的にDjangoの基礎を学べたことが良かったです。
# 難しかったこと
## 文字だけの説明苦戦
今までは図や動画で解説がありましたが
文字だけになったので疑問に感じた都度調べてイメージを固めました。

いきなり公式チュートリアルをやるより
YoutubeやUdemyなどの動画教材で概要を把握した後にやると理解度が早いかと思います。

## テストの内容を把握
以下がテストを書いた際の感想です。
- コード量が一気に増えた
- どのパターンのテストなのか
- メソッド名が長すぎてアンチパターンではないかと思った

今後開発する上でテストすることは必要になってくることが多いかと思います。

どの程度までテストするのかを自分で判断できるようなエンジニアになって、この記事懐かしいなぁと思いたいです。

## adminのカスタマイズ
アプリやカラムが多くなると、管理画面のカスタマイズも必要になると思いました。

日付、文字検索などは絞り込みに必要だし
カラム名を出力することで管理しやすくなると感じました。

1回で覚えられないので、都度調べて身につけたいと考えています。