Djangoのパスワード再設定周辺について

# Introduction
Djangoでログイン認証を書くのはそれほど難しくないと思います。
これは、django側で色々と用意してくれていて、ドキュメント通りの挙動を達成したいなら容易と言う意味だと思っています。
しかし、少し手を加えて何かをしたいケースが出てきた時に、全体的な挙動を把握する必要があって、初心者の私には結構大変な時があります。

以下は、それのメモ書き

# Prerequisite
OS: macOS high sierra
django: 2.1
python: 3.6.4

# Problems
## send_emailするためにはどうしたらいいんだっけ？
まずは、[公式ドキュメント](https://docs.djangoproject.com/ja/2.1/topics/email/)を参照しましょう。

send_mailの説明の前に、

```text
設定内の EMAIL_HOST と EMAIL_PORT で指定されたSMTPホストとポートを使用して、メールが送信されます。
EMAIL_HOST_USER と EMAIL_HOST_PASSWORD が指定されている場合は、SMTP サーバーの認証に使われます。
そして、EMAIL_USE_TLS と EMAIL_USE_SSL 設定により、セキュアコネクションを使うかどうかをコントロールします。
```

見た所、settings.pyに色々書いて、それを使ってSMTPサーバーからメールが送信されるようです。
SMTPサーバーは環境にも依存すると思うので、今回は詳しく書きません。
とりあえず、覚えておけば良いのはsettings.pyの[この辺](https://docs.djangoproject.com/ja/2.1/ref/settings/#std:setting-EMAIL_HOST)を記入してあげると理想的な挙動を達成できると言うことです。

以下では、開発用途のために、コンソール出力を用いて再設定操作を記述します。

```python:project_name/settings.py
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

# ちなみに、ページ遷移だけ見たいなら以下で大丈夫
EMAIL_BACKEND = 'django.core.mail.backends.dummy.EmailBackend'
```

まず、上記を記入します。
これによって、SMTPサーバーからメールクライアントへの送信内容をコンソール上で確認することができるようになります。
最終的にはこう言う感じ

```text
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit
Subject: xxx
From: webmaster@localhost
To: example@example.com
Date: Day, dd Month yyyy hh:mm:ss -0000
Message-ID: <xxx@xxx.xxc>

    このメールは localhost:8000 で、あなたのアカウントのパスワードリセットが要求されたため、送信されました。    

    次のページで新しいパスワードを選んでください:
    
        http://localhost:8000/reset/xxx/yyy/
    
    あなたのユーザー名 (念のため): example_user

    ご利用ありがとうございました！

    localhost:8000 チーム
```

### やること
loginなどのhtml, urlなどの設定はある程度終わっていると仮定して進めます。
終わっていない場合は、[公式](https://docs.djangoproject.com/ja/2.1/topics/auth/default/)を参照して、ガシッとやってください

```python:app_name/urls.py
from django.urls import path, re_path, include

urlpatterns = [
    path('accounts/', include('django.contrib.auth.urls'),
]
```

これやったら、runserverして、localhost:8000に行って、accounts/password_reset/に行ってください。
django側で用意してくれているページが表示されているはずです。
usersテーブルがすでにあって、メールアドレスとパスワードを探すことができれば問題なく動くと思います。

#### 小ネタ
---
あれ？パスワードも必要なの？と、ここで私は思いました。（無知ですね）
実際、パスワードがnullの状態でこの操作をするとTypeErrorが出ます。

djangoのmake_hash_valueってところでエラーが出るんですが、中身を見ると、

```python:token.py
class PasswordResetTokenGenerator:
    """
    Strategy object used to generate and check tokens for the password
    reset mechanism.
    """
    ....

    def _make_hash_value(self, user, timestamp):
        """
        Hash the user's primary key and some user state that's sure to change
        after a password reset to produce a token that invalidated when it's
        used:
        1. The password field will change upon a password reset (even if the
           same password is chosen, due to password salting).
        2. The last_login field will usually be updated very shortly after
           a password reset.
        Failing those things, settings.PASSWORD_RESET_TIMEOUT_DAYS eventually
        invalidates the token.

        Running this data through salted_hmac() prevents password cracking
        attempts using the reset token, provided the secret isn't compromised.
        """
        # Truncate microseconds so that tokens are consistent even if the
        # database doesn't support microseconds.
        login_timestamp = '' if user.last_login is None else user.last_login.replace(microsecond=0, tzinfo=None)
        return str(user.pk) + user.password + str(login_timestamp) + str(timestamp)

```

見事に、user.passwordとあります笑
ちなみに、この_make_hash_valueはsend_mailする際の、contextデータを作成するのに必要な処理で、default_token_generatorというtoken_generatorをごっそり書き換えたりしない限り、仕様に従う以外ないと思います。

何はともあれ、こう言う理由も踏まえた上で、passwordはnull以外にしておきましょう（ログイン認証なので当たり前ですが笑）
故に、Usersのpasswordフィールドは、null=Falseにするなり、SQLでnullにならないようにするのが良いと思われます。
model側ではnull=False, default=xxx（暗号化処理とか）とかにしておくのが良いかもですね。

## send_mailの内容を確認したいんだけど、どうするのがいい？
で、send_mailが一応できるようになるわけなんですが、最初はdjango側が用意しているテンプレートが使われます。
これの場所がソースコードからだとよくわからん問題に遭遇しました笑

問題の部分はここ

```python:django/contrib/auth/views.py

class PasswordResetView(PasswordContextMixin, FormView):
    email_template_name = 'registration/password_reset_email.html' <- こいつ
    extra_email_context = None
    form_class = PasswordResetForm
    from_email = None
    html_email_template_name = None
    subject_template_name = 'registration/password_reset_subject.txt'
    success_url = reverse_lazy('password_reset_done')
    template_name = 'registration/password_reset_form.html'
    title = _('Password reset')
    token_generator = default_token_generator
```

'registration/password_reset_email.html'ってどこにあんねん...
と思っていたので、色々ググって見たら、django/contrib/admin/template/にいました。

とりあえず、これを使ってemailのテンプレを作りましょう。
htmlを書き換えたら、template/mail/配下に適当な名前をつけて保存してください。

次に、views.pyで、

```python:views.py
from django.contrib.auth import views


class PasswordResetView(views.PasswordResetView):
    email_template_name = 'さっき作ったhtmlのtemplate以降のパス'

```

で良いです。
そしたら、urlをいじらなくてはいけません。
理由は、include('django.contrib.auth.urls')の中身が

```python:django/contrib/auth/urls.py

# The views used below are normally mapped in django.contrib.admin.urls.py
# This URLs file is used to provide a reliable view deployment for test purposes.
# It is also provided as a convenience to those who want to deploy these URLs
# elsewhere.

from django.contrib.auth import views
from django.urls import path

urlpatterns = [
    path('login/', views.LoginView.as_view(), name='login'),
    path('logout/', views.LogoutView.as_view(), name='logout'),

    path('password_change/', views.PasswordChangeView.as_view(), name='password_change'),
    path('password_change/done/', views.PasswordChangeDoneView.as_view(), name='password_change_done'),

    path('password_reset/', views.PasswordResetView.as_view(), name='password_reset'),
    path('password_reset/done/', views.PasswordResetDoneView.as_view(), name='password_reset_done'),
    path('reset/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
    path('reset/done/', views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
]

```
であるからです。
せっかく、viewを変更しても、urlを変更しないと意味がありません。

```python:app_name/urls.py
from django.urls import path
from app_name.views import PasswordResetView

urlpatterns = [
    path('accounts/password_reset/', PasswordResetView.as_view(), name='password_reset'), <- これだけ変更

    path('login/', views.LoginView.as_view(), name='login'),
    path('logout/', views.LogoutView.as_view(), name='logout'),
    path('accounts/password_reset/done/', views.PasswordResetDoneView.as_view(), name='password_reset_done'),
    path('reset/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
    path('reset/done/', views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
    path('password_change/', views.PasswordChangeView.as_view(), name='password_change'),
    path('password_change/done/', views.PasswordChangeDoneView.as_view(), name='password_change_done'),

]
```

って書いて、さっきと同じように認証処理をやると良いと思います。

### 小ネタ
---
このemail_tempate_nameが'registration/password_reset_email.html'をどのように参照しているのか気になって、コードを追って見たのですが、私の頭では厳しいものがありました笑
form_validで、データを有効化するのですが、手順としてはcontextデータを入れて、saveをします。
saveの時に、email_template_nameをrender_to_stringでrenderingするんですが、Templateクラスのrenderメソッドはcontextデータを元にして、うまいことレンダリングしているようです。
任意のhtmlを指定しない限り、defaultのテンプレートが読まれる模様です。


## html_email_template_nameとemail_template_nameの違いとは...?

試しに、html_email_template_name, email_template_nameに同じパスを書いて、実行して見てください。
二回、同じ内容が出力されます。

実際のところ、

```python:
class PasswordResetForm(forms.Form):
    email = forms.EmailField(label=_("Email"), max_length=254)

    def send_mail(self, subject_template_name, email_template_name,
                  context, from_email, to_email, html_email_template_name=None):
        """
        Send a django.core.mail.EmailMultiAlternatives to `to_email`.
        """
        subject = loader.render_to_string(subject_template_name, context)
        # Email subject *must not* contain newlines
        subject = ''.join(subject.splitlines())
        body = loader.render_to_string(email_template_name, context)

        email_message = EmailMultiAlternatives(subject, body, from_email, [to_email])
        if html_email_template_name is not None:
            html_email = loader.render_to_string(html_email_template_name, context)
            email_message.attach_alternative(html_email, 'text/html')

        email_message.send()
```

となっていて、attach_alternativeのdocstringには
> Attach an alternative content representation. (別の内容表現をはっつけるよー）

って書いてあります。
まあ、なんで補足のなんかしらのページをはっつけたい時に使ってねってことですかね。

# Conclusion 

djangoをやってると、（無知のせいもありますが）よくわからんところで結構つまづきます。
日本語のサイトだと、いまだに1系の関数表記で書かれているケースが多く実用的でもないので、結構調べるのにも苦戦します。

僕のはメモ書き程度のクオリティーですが、以下の記事はもっと正確に書いてあると思うのでそちらも合わせてどうぞ。

# Reference 
* https://stackoverflow.com/questions/31662125/overriding-djangos-reset-password-email-templates
* https://github.com/django/django/tree/master/django/contrib/admin
* https://qiita.com/okoppe8/items/caa03dd2d9281e9cf263
* https://docs.djangoproject.com/ja/2.1/topics/email/
* https://nwpct1.hatenablog.com/entry/django-email