昼飯データベースを作りたい【EP1-4】はじめてのDjango勉強編

#本稿は続きものです。
[前回](https://qiita.com/EconUC/items/b9048d917159db35a860)
[前々回](https://qiita.com/EconUC/items/408ae1a7118fe8078369)
[初回のもの書き](https://qiita.com/EconUC/items/cf1f69cfefb6c63e4c31)

前回で`polls`のアプリケーションの機能を追加して投票アプリとして動くものとなりました。
最後にテストを行いバグの直しに差し掛かって前回は終わりました。

今回はDjangoチュートリアル5~7の内容をさらっていきます。

#包括的なテストの実施
前回作成したテストで`was_published_recently`のバグを修正しましたが、このバグ修正によって他の部分に影響してしまっては元も子もないので、それがないかをテストします。
以下を新たに`tests.py`の`QuestionModelTests`クラスの下に書き足します。

```python:mysite/polls/tests.py
def test_was_published_recently_with_old_question(self):
    #ギリギリ昨日を表す
    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):
    #ギリギリ今日を表す
    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)
```

`was_published_recently()`は昨日以上で今以下のものを`True`で返すので、`test_was_published_recently_with_old_question()`は`False`になって`test_was_published_recently_with_recent_question()`は`True`で帰ってくるはずです。
この予想が`self.assertIs`に入っていますので、その通りになるかチェックしていきます。

```:cmd
(Django) C:\User\mysite>python manage.py test polls
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.002s

OK
Destroying test database for alias 'default'...
```
ということで`Ran 3 tests in 0.002s`が表示され、前回に書いた分を合わせて3つのテストが成功したことがわかります。

#ビューのテスト
チュートリアルによるとなんとこの投票アプリまだバグがあるみたいです。
それは`was_published_recently`で問題となっていた部分とも重なりますが、未来の日時で`pub_date`されていた質問でもビューでは表示されてしまうというものです。
今回は対話シェルを使ってテスト環境を構築します。

```python:cmd
>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()
>>> from django.urls import reverse
>>> response = client.get(reverse('polls:index'))
```
これでテスト用のクライアントの設置は終わりました。
チュートリアルにでは`client.get('/')`をその前に行いエラーの検出を行っていますがここでは割愛。
そんでもって`response`には何が入っているかというと、、、

```python:cmd
>>> response.status_code
200
>>> response.content                                                                     </ul>\n'
b'\n    <ul>\n    \n        <li><a href="/polls/1/">What&#x27;s new?</a></li>\n    \n    </ul>\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: What's new?>]>
```
こんな感じです。
`response.content`の中身はどこから引いているのでしょうか。
公式ドキュメントを読みましたが、ちょっとわからなかったです。
`html`らしきものをどっかから引いていることはわかるのですが逆に言えばそれしかわかりません。

##ビューの改良
気を取り直してビューの改良です。
未来の日時で登録された質問を表示しないように改良します。

```python:mysite/polls/views.py
class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'

#この関数を書き換えている
    def get_queryset(self):
#以下差分
         return Question.objects.filter(
        pub_date__lte=timezone.now()
    ).order_by('-pub_date')[:5]
```
`pub_date__lte = timezone.now()`は現在日時よりも以前の日時の`pub_date`を指します。
`__lte`はless than equalのことでつまりは`pub_date <= timezone.now()`みたいなことになっていると言い換えられるんじゃないでしょうか。

##ビューのテストコードを書く
`was_published_recently`の時に書いたようなテスト用コードを作って簡単にテストできるように準備をしていきます。

```python:mysite/polls/tests.py
def create_question(question_text, days):
   
    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):
        
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past 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):
        
        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: Past question.>']
        )

    def test_two_past_questions(self):
      
        create_question(question_text="Past question 1.", days=-30)
        create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question 2.>', '<Question: Past question 1.>']
        )
```
長めのコードになりましたが、その内容は質問がない場合の`test_no_question()`、過去の時間で追加された質問の場合の`test_past_question`、未来の時間で追加された質問の場合の`test_future_question()`、追加された時間が過去と未来の2つの質問がある場合の`test_future_question_and_past_question`、2つとも過去に追加された質問の場合の`test_two_past_questions()`の4つのパターンをテストするように書かれています。
`create_question`はテストの中で質問を作成する過程を関数として定義したものになります。

うまくいけば、以下のように成功します。一度`FAIL`が出るとそのあとコードを直しても同じターミナルだとうまくいきませんでした。なので、ミスった後のやり直しの際は別のターミナルを上げて行うのがよいかもしれません。

```:cmd
(Django) C:\User\mysite>python manage.py test polls
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..........
----------------------------------------------------------------------
Ran 8 tests in 0.044s

OK
Destroying test database for alias 'default'...
```

##DetailViewのテスト
さっきまでのテストで改善したのは`IndexView`で未来に投稿された質問は表示されなくなるもの、URLで引くと`DetailView`のほうはまだ表示されてしまいます。ということでこの問題の解決を行います。
まずは`DetailView`クラスに`IndexView`に足したのと同様に`get_queryset()`メソッドを追加します。

```python:mysite/polls/views.py
class DetailView(generic.DetailView):
    model = Question
    template_name = 'polls/detail.html'
    def get_queryset(self):
       
        return Question.objects.filter(pub_date__lte=timezone.now())
```

このあとチュートリアルがなぜか英語になってしまっていますが、書いてあることとしては`pub_date`が過去のものは表示して、未来のものは表示しないということをチェックするためのテストを追加しなければいけないということです。

```python:mysite/polls/test.py
class QuestionDetailViewTests(TestCase):
    def test_future_question(self):
        
        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):
        
        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)
```

これも`IndexView`の時と同じような解釈ができます。`test_future_question`は`pub_date`が未来のものが`DetailView`として出てこないことを確認し、`test_past_question`では`pub_date`が過去のものが`DetailView`で表示されるかどうかを確認します。

これもテストすると以下のようになります。
今までのテストは合計で10個になりましたのでテストの結果でも10個のテストが行われているはずなので結果でも`Ran 10 tests`となるはずです。

```:cmd
(Django) C:\User\mysite>python manage.py test polls
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..........
----------------------------------------------------------------------
Ran 10 tests in 0.058s

OK
Destroying test database for alias 'default'...
```
ここまで見るとこのまま`ResultView`のテストも作れそうですね。
チュートリアルでは提案で留まっていましたがここはひとつ書いてみましょう。

```python:mysite/polls/test.py
class QuestionResultViewTests(TestCase):
    def test_future_question(self):
    
        future_question = create_question(question_text='Future question.', days=5)
        url = reverse('polls:results', args=(future_question.id,))
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_past_question(self):
        
        past_question = create_question(question_text='Past Question.', days=-5)
        url = reverse('polls:results', args=(past_question.id,))
        response = self.client.get(url)
        self.assertContains(response, past_question.question_text)
```
実は`DetailView`のときとほぼ変わらんのです。変化したところは`url = reverse('polls:results', args=..)`のところのだけです。

あと`views.py`の`ResultsView`にも`get_queryset`をちゃんと入れておきましょう。
こんな感じで

```python:mysite/polls/views.py
class ResultsView(generic.DetailView):
    model = Question
    template_name = 'polls/results.html'
    def get_queryset(self):
       
        return Question.objects.filter(pub_date__lte=timezone.now())
```
これでチュートリアルでのテストは終わりですが、DjangoにはHTMLが作る画面のふるまいをテストする機能もあるみたいです。本当に高機能です。

#静的ファイルの管理

ここからチュートリアルの6に入ります。
ここまでWebページを作るうえでよく出てくるCSSとかJavaScriptとかを使ってきませんでしたが、Djangoでもこれらはしっかり使えます。Djangoはこうしたファイルを静的ファイルとして扱っています。
`django.contrib.staticfiles`は各アプリケーションこうした静的ファイルを集約して管理することができるみたいですね。

##アプリ構造のカスタマイズ
Djangoが静的ファイルを扱うための`static`フォルダを`polls`の下に作ります。`template`フォルダを作ったときと同様に`static`の下にさらに`polls`フォルダを作ります。
そしたら以下のCSSを書いていきます。

```css:mysite/polls/static/polls/style.css
li a {
    color: green;
}
```
そしたら`index.html`のほうにもCSSへの参照を追加します。

```html:mysite/polls/templates/polls/index.html
{% load static %}

<link rel="stylesheet" type="text/css" href="{% static 'polls/style.css' %}">
```
この文はファイルの一番上に記述しておきましょう。`{% static %}`は静的ファイルの場所を示すDjangoのテンプレートタグです。

確認すると、、、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402157/de9a18e0-7e1e-93d2-0041-ff359310a4b6.png)

確かに緑になっていますが、違いが微妙すぎる（笑）

##背景画像の追加
背景画像の追加を行うために`style.css`に設定を追加していきます。

```css:mysite/polls/static/polls/style.css
body {
    background: white url("images/background.png") no-repeat;
}
```
リンクでわかると思われますが、`mysite/polls/static/polls/image/`フォルダの下にある`background.png`という画像を参照しているので、そこに該当するファイルをあらかじめ置いておかないと画像は表示されないです。
ということで、いらすとやから適当な画像を持ってきた結果がこちらです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402157/41f86a56-4a31-824f-651a-7c5c040adcde.png)
いい感じに表示されましたね！

#adminフォームのカスタマイズ
何個か前のところで管理者画面から`Question`の追加などができることを確認しましたが（[EP1-2](https://qiita.com/EconUC/items/408ae1a7118fe8078369)）、Djangoではその画面さえもカスタマイズできます。
チュートリアルより質問のカスタマイズの画面をいじるコードを試してみます。

```python:mysite/polls/admin.py
from django.contrib import admin
from .models import Question

# Register your models here
class QuestionAdmin(admin.ModelAdmin):
    fieldsets = [
        (None, {'fields': ['question_text']}),
        ('Date information', {'fields': ['pub_date']}),
    ]

admin.site.register(Question, QuestionAdmin)
```

変更点は`QuestionAdmin`クラスの追加と`admin.site.register()`の引数に`QuestionAdmin`を追加したことです。
これで管理者としてログインして質問の変更画面に入ってみると以下のようにフィールドの区切りができています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402157/0ddd38fe-93b0-8900-39f1-368148199961.png)
今は質問のみ見ることができていますが、質問についている各選択肢も表示させることができます。

```python:mysite/polls/admin.py
from .models import Choice, Question

admin.site.register(Choice)
```
変更したところは、`models`から`Choice`も`import`したことと、`admin.site.register(Choice)`を加えたところです。
確認してみると、まずこのように最初のところで`Choice`が選べるようになっています
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402157/4ab37d71-9db8-b3d4-cdfb-5fb9068b89b1.png)
そして`Choice`を選択してその中の選択肢の設定の画面に入ります。そうすると以下のような設定画面にたどり着けます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402157/6a9f5465-285e-e605-14d6-3dcd1909251b.png)
これでも十分いい感じになっていますが、`Choice`は`Question`に紐づいているので一緒に編集できるようになるともっと使い勝手がよさそうな感じがしませんか？
そのために`admin.site.register(Choice)`をやめてもっと改造します。

```python:mysite/polls/admin.py
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]
```
`ChoiceInline`クラスを追加して`QuestionAdmin`クラスの中にそれを受ける部分を追加します。`admin.site.register(Choice)`は消しておきます。
そうすると以下のように`Question`の画面で`Choice`も編集できるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402157/72db37fb-d77d-6ff7-db6c-4ad0ba06c855.png)

続いて`Question`の設定画面の一つ前のポイントであるこの画面を使いやすくしていきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402157/0f0d4510-8516-d8a9-81fb-598d5e29fccc.png)
別にそんな使いづらさを感じていたわけではないんですが、まあさらに便利なものにしていくために`admin.py`と`models.py`に新たなコードを追加します。

```python:mysite/polls/admin.py
class QuestionAdmin(admin.ModelAdmin):
    fieldsets = [
        (None,               {'fields': ['question_text']}),
        ('Date information', {'fields': ['pub_date'], 'classes': ['collapse']}),
    ]
    inlines = [ChoiceInline]
#以下追加文
    list_display = ('question_text', 'pub_date', 'was_published_recently')
    list_filter = ['pub_date']
    search_fields = ['question_text']
```

```python:mysite/polls/models.py
class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField('date published')
    def __str__(self) -> str:
        return self.question_text
    def was_published_recently(self):
        now = timezone.now()
        return now - datetime.timedelta(days=1) <= self.pub_date <= now
#以下追加文
    was_published_recently.admin_order_field = 'pub_date'
    was_published_recently.boolean = True
    was_published_recently.short_description = 'Published recently?'
```

`list_display`で表示する内容の拡張をし、`list_filter`でサイドバーでのフィルタが効くようになり、`search_fields`での検索もできるようになりました。（囲ったところが主な変更箇所です）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402157/cad4e9bf-dd46-9607-ad58-3fbd0c878961.png)

#管理者画面をさらにカスタマイズ
管理者画面も`IndexView`の時と同様に`html`ファイルを使ってカスタマイズすることが可能です。
正直デフォルトでも十分なのですがいずれ使えると便利なのかもしれません。
まずは`setting.py`にこのプロジェクトが参照してくれる`template`のパスを伝えます。

```python:mysite/mysite/setting.py
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(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',
            ],
        },
    },
]
```
実はここチュートリアルのコードのコピペでは失敗しました。
チュートリアルでは`'DIRS': [BASE_DIR / 'templates'],`となっていますがこれだと文字列型の計算とみなされ、当然文字列の割り算はできないためエラーになってしまいます。
ということで`os.path.join(BASE_DIR, 'templates')],`とすることでパスを明記しエラーを回避しました。
英語のほうのチュートリアルを読んでもここについての解は得られませんでした。チュートリアルが間違っているとかあるんでしょうか？多分私の見落としだと思うのですが、、、

そして管理者画面のTemplateをDjangoソースファイルから引っ張ってきます。
なんとDjangoのパッケージの中にはTemplateの例も入っているんですね。優しい。
Djangoのソースファイルがどこにあるのかは以下のコードで確認ができます。

```:cmd
(Django) C:\Users\mysite> python -c "import django; print(django.__path__)"
```
場所がわかったらそこをエクスプローラーとかで開いてあげたのち`base_site.html`を`mysite/templates/admin`の下に置いてあげましょう。
`templates`フォルダとその下の`admin`フォルダはここで新たに作っておいてください。
（`base_site.html`はソースファイル内の`django/contrib/admin/templates/admin`内にいます）
そしたら、`base_site.html`を少し編集します。

```html:mysite/templates/admin/base_site.html
{% block branding %}
<h1 id="site-name"><a href="{% url 'admin:index' %}">管理者画面</a></h1>
{% endblock %}
```
変更箇所だけ抜き出すとここになります。`<a><\a>`タグの中に適当に文字を入れるとそれがページの頭のところに出ます。
デフォルトでは`{{ site_header|default:_('Django administration') }}`が入っています。

そしてその変更が以下のように反映されているか確認しましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402157/a525b7d5-22ce-a737-0b68-2a13d7fd84ba.png)
ちゃんと変わっていますね！！

これでDjangoチュートリアルは一通り終わりになります。
結構量がありましたね。
正直ここまで来てもまだ手放しでは全く無理ですね（笑）
ですが基本がわかればこれ以降の勉強も大きく前進すると思います。それくらいの歯ごたえのある内容でした。
なんかこの後に高度なチュートリアルがありますがそれはさすがにまだやりません（笑）
必要になったときにね、、、

次はDjangoをもう少し回しながらGitの勉強をしていくのでよろしくお願いします。