動作環境とこれまでの経緯
- ホスト環境
- macOS Catalina 10.15.7
- VirtualBox 6.1.14
- ゲスト環境
- Ubuntu Server 20.04.1 LTS
- Docker 19.03.13
- Docker Compose 1.27.3
- パッケージ
- Django 3.1.4
- uwsgi 2.0.18
- mysqlclient 1.4.6
- これまでの経緯
まだ本番環境いらないんじゃないか説
前回、本番環境を作ろうとしたところ、アホみたいな理由で失敗しましたが、コードを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
の全内容については、前回の記事を読んでね。
app:
docker-compose -f docker-compose.dev.yml run python ./manage.py startapp $(APP_NAME)
これで $ make app APP_NAME=app
でアプリケーションを新規作成できます。
忘れずに settings.py
に「users」アプリをインストールしておきましょう。
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 とか「完全な例」とかを参考にしながら試行錯誤した結果、最終的にこうなりました。
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つあります。
-
uuid を primary key に設定している
これは、Django をあとから REST 化しようと考えているからです。REST 化すると URL で操作対象のユーザを特定することになるので、id を primary key にするとセキュリティ的によくないんですね。なので、RESTful な設計にするときは、uuid を primary key にすることがベストプラクティスです。 -
UserManager から username を削除している
これは、メールアドレスでログインできるようにしたいからです。User モデルの方でも「USERNAME_FIELD」には email を設定し、「REQUIRED_FIELDS」は空に設定してあります。
3. AdminUserAdmin クラスを作る
これもいろいろと試行錯誤した結果、次のとおりになりました。
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
に定義します。
AUTH_USER_MODEL = 'users.User'
5. マイグレーションして管理画面からカスタムユーザを登録できることを確認する
次のコマンドを叩けば、マイグレーションできます。
$ docker-compose -f docker-compose.dev.yml run python ./manage.py migrate
これもコマンドが長くて面倒なので、Makefile
を作っておきます。
migrate:
docker-compose -f docker-compose.dev.yml run python ./manage.py migrate
これで $ make migrate
でマイグレーションできます。簡単だね。
マイグレーションに成功したら、管理画面には、メールアドレスでログインするようになります。
ちなみに、私の会社アドレスは上記のとおりなので、ウチの会社でエンジニアやりたいって人は気軽に連絡ください。
今回苦しんだバグ
Migration admin.0001_initial is applied before its dependency account.0001_initial on database
**「なんじゃこりゃぁあ!!!」**と、私の中の松田優作が叫びに叫んだバグ。
光の速さでググりまくったところ、同じバグに直面した先人たちが多数。どうやら、カスタムユーザを作ったら必ずマイグレーションに失敗するらしい。Django さんサイドの問題やないかこれ。
ここは先人たちの教えどおりに対処しよう。
INSTALLED_APPS = [
# 'django.contrib.admin', # コメントアウト
...
]
urlpatterns = [
# path('admin/', admin.site.urls), # コメントアウト
...
]
この2ヶ所をコメントアウトしてマイグレーションした後、元に戻して再度マイグレーションするとうまくいきました。
……と、こういう書き方をすると、まるであっという間にバグを潰したかのように見えますが、実際は User を作る過程と合わせてかなり試行錯誤しました。だって、あちこちいじったら、いろんなエラー出るんだもん。。
というわけで、例によってコマンドを簡略化するための 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 の中身が全部ブッ飛ので、最初のマイグレーションとスーパーユーザーの作成まで戻ってしまいます。
こんなメガンテを唱えずにすむように、静かに祈りましょう。