7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

AbstractBaseUser を継承したカスタムユーザーで E-mail ログインを実現する

Last updated at Posted at 2020-12-29

動作環境とこれまでの経緯

まだ本番環境いらないんじゃないか説

前回、本番環境を作ろうとしたところ、アホみたいな理由で失敗しましたがコードを1行も書いてない現時点で本番環境などいらないんじゃないか、という本質的なことに気づいたので、前々回に Docker Compose で構築した開発環境でコーディングを進めたいと思います。

今回やりたいこと

前々回で、最初のマイグレーションをして、$ docker-compose up で Django のスタート画面を見たところから始めます。

今回やりたいことは、

  • カスタムユーザのクラスを作る
  • メールアドレスでログインできるようにする
    の2つ。

公式サイトには AbstractUser を継承する例が記載されていますが、User の内容をグリグリいじりたいので、今回は AbstractBaseUser を継承して作っていきます。

手順

1.「users」という名前のアプリを作成する
2. users/models.py に AbstractBaseUser を継承した User クラスと、BaseUserManager を継承した UserManager クラスを作る
3. users/admin.py に UserAdmin を継承した AdminUserAdmin クラスを作る
4.「AUTH_USER_MODEL = 'users.User'」を定義する
5. マイグレーションして管理画面からカスタムユーザを登録できることを確認する

1. users アプリを作成する

次のコマンドを叩けば、「users」アプリが作成されます。

$ docker-compose -f docker-compose.dev.yml run python ./manage.py startapp users

今後、アプリをいろいろと開発していくのに、毎回長ったらしいコマンドを打つのが嫌だったので、Makefile に追加しました。Makefile の全内容については、前回の記事を読んでね。

Makefile(抜粋)
app:
	docker-compose -f docker-compose.dev.yml run python ./manage.py startapp $(APP_NAME)

これで $ make app APP_NAME=app でアプリケーションを新規作成できます。

忘れずに settings.py に「users」アプリをインストールしておきましょう。

settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    #My applications
    'users',    # 追加
]

2. User クラスと UserManager クラスを作る

公式の AbstructUser とか AbstructBaseUser とか UserManager とか「完全な例」とかを参考にしながら試行錯誤した結果、最終的にこうなりました。

users/models.py
import uuid
from django.db import models
from django.utils import timezone
from django.core.mail import send_mail
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.validators import UnicodeUsernameValidator

class UserManager(BaseUserManager):
    use_in_migrations = True

    def _create_user(self, email, password, **extra_fields):
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, email, password=None, **extra_fields):
        extra_fields.setdefault('is_staff', False)
        extra_fields.setdefault('is_superuser', False)
        return self._create_user(email, password, **extra_fields)

    def create_superuser(self, email, password, **extra_fields):
        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)

        if extra_fields.get('is_staff') is not True:
            raise ValueError('Superuser must have is_staff=True.')
        if extra_fields.get('is_superuser') is not True:
            raise ValueError('Superuser must have is_superuser=True.')

        return self._create_user(email, password, **extra_fields)
    

class User(AbstractBaseUser, PermissionsMixin):

    uuid = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
    username_validator = UnicodeUsernameValidator()
    
    username = models.CharField(
        _('username'),
        max_length=150,
        blank=True,
        null=True,
        help_text=_('Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'),
        validators=[username_validator],
        error_messages={
            'unique': _("A user with that username already exists."),
        },
    )
    
    last_name = models.CharField(_(''), max_length=150)
    first_name = models.CharField(_(''), max_length=150)
    last_name_kana = models.CharField(_('姓(かな)'), max_length=150)
    first_name_kana = models.CharField(_('名(かな)'), max_length=150)
    old_last_name = models.CharField(_('旧姓'), max_length=150, blank=True, null=True)
    old_last_name_kana = models.CharField(_('旧姓(かな)'), max_length=150, blank=True, null=True)
    email = models.EmailField(_('メールアドレス'), unique=True)

    sex = models.CharField(_('性別'), max_length=4, choices=(('男性','男性'), ('女性','女性')))
    birthday = models.DateField(_('生年月日'), blank=True, null=True)

    country = models.CharField(_(''), default='日本', max_length=15, editable=False)
    postal_code = models.CharField(_('郵便番号(ハイフンなし)'), max_length=7, blank=True, null=True)
    prefecture = models.CharField(_('都道府県'), max_length=5, blank=True, null=True)
    address = models.CharField(_('市区町村番地'), max_length=50, blank=True, null=True)
    building = models.CharField(_('建物名'), max_length=30, blank=True, null=True)
    tel = models.CharField(_('電話番号(ハイフンなし)'), max_length=11, blank=True, null=True)
    
    url = models.URLField(_('URL'), max_length=300, blank=True, null=True)
    photo = models.ImageField(_('写真'), blank=True, null=True)

    is_staff = models.BooleanField(
        _('staff status'),
        default=False,
        help_text=_('Designates whether the user can log into this admin site.'),
    )
    is_active = models.BooleanField(
        _('active'),
        default=True,
        help_text=_(
            'Designates whether this user should be treated as active. '
            'Unselect this instead of deleting accounts.'
        ),
    )
    date_joined = models.DateTimeField(_('date joined'), default=timezone.now)

    objects = UserManager()

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    class Meta:
        db_table = 'User'
        verbose_name = _('user')
        verbose_name_plural = _('ユーザー')
    
    def clean(self):
        super().clean()
        self.email = self.__class__.objects.normalize_email(self.email)

    def get_full_name(self):
        full_name = '%s %s' % (self.last_name, self.first_name)
        return full_name.strip()

    def get_full_name_kana(self):
        full_name_kana = '%s %s' % (self.last_name_kana, self.first_name_kana)
        return full_name_kana.strip()
    
    def get_short_name(self):
        return self.first_name

    def email_user(self, subject, message, from_email=None, **kwargs):
        send_mail(subject, message, from_email, [self.email], **kwargs)

本来なら、このあたりの記事に書いてあるようにちゃんとした多言語対応が必要なのですが、そんな小難しいことは必要になったときに考えればいいという割り切りのもと、各フィールドには日本語をそのまま書いてます。こまけぇこたぁいいんだよ!!

さて、ポイントは2つあります。

  1. uuid を primary key に設定している
    これは、Django をあとから REST 化しようと考えているからです。REST 化すると URL で操作対象のユーザを特定することになるので、id を primary key にするとセキュリティ的によくないんですね。なので、RESTful な設計にするときは、uuid を primary key にすることがベストプラクティスです。

  2. UserManager から username を削除している
    これは、メールアドレスでログインできるようにしたいからです。User モデルの方でも「USERNAME_FIELD」には email を設定し、「REQUIRED_FIELDS」は空に設定してあります。

3. AdminUserAdmin クラスを作る

これもいろいろと試行錯誤した結果、次のとおりになりました。

users/admin.py
from .models import User
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils.translation import gettext_lazy as _

@admin.register(User)
class AdminUserAdmin(UserAdmin):

    fieldsets = (
        (None, {'fields': ('username', 'password')}),
        (_('Personal info'), {'fields': ('last_name', 'first_name', 'last_name_kana', 'first_name_kana', 'old_last_name', 'old_last_name_kana', 'email', 'sex', 'birthday', 'postal_code', 'prefecture', 'address', 'building', 'tel', 'url', 'photo')}),
        (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser',
                                       'groups', 'user_permissions')}),
        (_('Important dates'), {'fields': ('last_login', 'date_joined')}),
    )
    list_display = ('get_full_name', 'get_full_name_kana', 'email', 'sex', 'birthday', 'postal_code', 'prefecture', 'address', 'building', 'tel', 'is_staff',)
    search_fields = ('username', 'email',)
    ordering = ('date_joined',)
    filter_horizontal = ('groups', 'user_permissions',)

4. 「AUTH_USER_MODEL = 'users.User'」を定義する

認証の対象となるユーザモデルは、「users.User」であることを settings.py に定義します。

settings.py
AUTH_USER_MODEL = 'users.User'

5. マイグレーションして管理画面からカスタムユーザを登録できることを確認する

次のコマンドを叩けば、マイグレーションできます。

$ docker-compose -f docker-compose.dev.yml run python ./manage.py migrate

これもコマンドが長くて面倒なので、Makefile を作っておきます。

Makefile(抜粋)
migrate:
	docker-compose -f docker-compose.dev.yml run python ./manage.py migrate

これで $ make migrate でマイグレーションできます。簡単だね。

マイグレーションに成功したら、管理画面には、メールアドレスでログインするようになります。
スクリーンショット 2020-12-29 22.13.50.png

また、カスタムしたとおりにユーザを登録することができます。
スクリーンショット 2020-12-29 22.10.12.png

ちなみに、私の会社アドレスは上記のとおりなので、ウチの会社でエンジニアやりたいって人は気軽に連絡ください

今回苦しんだバグ

Migration admin.0001_initial is applied before its dependency account.0001_initial on database

**「なんじゃこりゃぁあ!!!」**と、私の中の松田優作が叫びに叫んだバグ。

光の速さでググりまくったところ、同じバグに直面した先人たちが多数。どうやら、カスタムユーザを作ったら必ずマイグレーションに失敗するらしい。Django さんサイドの問題やないかこれ。

ここは先人たちの教えどおりに対処しよう。

settings.py
INSTALLED_APPS = [
    # 'django.contrib.admin', # コメントアウト
    ...
]
urls.py
urlpatterns = [
    # path('admin/', admin.site.urls), # コメントアウト
    ...
]

この2ヶ所をコメントアウトしてマイグレーションした後、元に戻して再度マイグレーションするとうまくいきました。

……と、こういう書き方をすると、まるであっという間にバグを潰したかのように見えますが、実際は User を作る過程と合わせてかなり試行錯誤しました。だって、あちこちいじったら、いろんなエラー出るんだもん。。

というわけで、例によってコマンドを簡略化するための Makefile を作りました。

Makefile(抜粋)
all_clear:
	docker-compose -f docker-compose.dev.yml down
	docker volume rm app.db.volume
	find . -path "*/migrations/*.py" -not -name "__init__.py" -delete
	find . -path "*/migrations/*.pyc" -delete

開発環境といえども、できれば使いたくない滅びの呪文です。この呪文を唱えると、コンテナで動く DB の中身が全部ブッ飛ので、最初のマイグレーションとスーパーユーザーの作成まで戻ってしまいます。

こんなメガンテを唱えずにすむように、静かに祈りましょう。

参考

7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?