LoginSignup
25
39

More than 3 years have passed since last update.

Djangoでカスタムユーザーについて甘く見て痛い目を見たという話

Last updated at Posted at 2020-05-02

はじめに

Djangoでユーザー登録・認証・ログイン機能周りを整備したいと思い、進めていたら詰まったので記録を残しておきます。

結論

Djangoでアプリ開発をする際はprojectを立ち上げる前に要件定義と設計は済ませておくこと

設計等で織り込み済みでない限り、基本的には1project、1アプリを守るほうがDjangoの開発になれるまではベター。

勉強で開発をしているときもめんどくさがらずにprojectを再度立ち上げること、使い回さない。

何が起きたか

Djangoチュートリアルも終わったので、こちらの記事こちらの記事 を元に、
Djangoでユーザー登録・認証・ログイン機能周りを整備したTODOアプリの雛形のようなものを作ろうと色々とやっていたのですが、面倒くさがりでチュートリアルのprojectを流用していたところUserモデル及びSuperUserの仕様周りで詰まってしまいました。

Djangoでの会員登録機能

Djangoで会員登録機能を使う場合は、基本的にDjangoで用意されているUserモデルを使います。
と、いうよりフレームワークを導入する以上はおそらくどのフレームワークであってもよほどのことがない限りは、フレームワーク側で用意されている認証の仕組みを使うことになり、それには間違いなく組み込みのモデルやメソッドがあるものです。

で、ここからが問題なのですがDjangoでは1つのProjectフォルダで複数のアプリケーションを管理することができ、逆に言うと複数のprojectフォルダに同じアプリケーションを導入することもできるようになっています。
しかし、実はUserモデルはprojectにつき1つしか使えません。
これは厳密に言うとDjangoは導入にあたってUserモデルをもとにカスタムユーザモデルを作ることを強く推奨していて、さらに初回のマイグレーションまでにそれが作成されてなければなりません。
その際にUserモデルを認証機能て扱うためにsettings.pyで設定するAUTH_USER_MODELの部分を設定するのですが、これをあとから変更するのはprojectを立ち上げ直した方が早いレベルで手間がかかります。
参考

つまり、1つのprojectで複数のアプリを管理する場合はprojectを立ち上げた時点で既にどのアプリケーションに認証周りの機能をもたせるかということを決めておかないといけないということです。

これを守らない……つまりチュートリアルでUserクラスを定義してるのに、別のアプリケーションを作ってそこで認証機能をつけたくなった私やノリでprojectを立ち上げてそのまま勢いでUserクラスを作ると大変なことになるということですね。

しかし、やってしまったものはしょうがないので今回は後学のためという意味も込めてどうにかして既存のprojectのままどうにかならないかやってみました。

やったこと

  • 既存のUserモデルをカスタマイズする
  • Userモデルを拡張するモデルを新たに作成するアプリケーションフォルダで作成しOneToOneFieldを用いてリレーションする。
  • カスタムしたUserモデルとUserモデルを拡張したモデルを管理画面に反映するためにUserモデルがある側のadmin.pyを編集する。

また、これに際してTODOアプリ側のモデルを機能で使うモデルと上記のUser拡張モデル(ついでにメール認証フラグのためのレコードをオーバーライドしています)は別々のファイルにしてあります。

そして、models.py周りをいじるということは当然makemigrationsをやり直すことになるので一応慎重にやっていきましょう。

既存のUserモデルのカスタマイズ

2020-05-03_00h05_45.png

ディレクトリ図を書くのが億劫なので画像で失礼します。
src以下が開発環境でprojectフォルダ以下のapppollstodoが各アプリケーションでUserモデルを定義したのはapp、今回製作したいアプリケーションがtodoとなります。

まずはUserモデル本体をカスタムしていきます。
参考:DjangoでUserモデルのカスタマイズ

app/models.py

from django.db import models
from django.core.mail import send_mail
from django.contrib.auth.models import PermissionsMixin, UserManager
from django.contrib.auth.base_user import AbstractBaseUser
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from django.core.validators  import EmailValidator

class CustomUserManager(UserManager):

    use_in_migrations = True

    def _create_user(self,email,password, **extra_fields):
        if not email:
            raise ValueError('The given email must be set')

        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(emai, password, **extra_fields)

class User(AbstractBaseUser, PermissionsMixin):

    username = models.CharField(max_length=30, unique=True)
    email = models.EmailField(_('email address'), unique=True, validators=[EmailValidator('Invalid email address.')])
    first_name = models.CharField(_('first name'), max_length=30, blank=True)
    last_name = models.CharField(_('last name'), max_length=150, blank=True)


    is_staff = models.BooleanField(
        _('staff status'),
        default=False,
        help_text=_(
            'Designates whether this 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 = CustomUserManager()

    EMAIL_FIELD = 'email'
    USERNAME_FIELD = 'email'
    REQUIRED_FIELD = []

    class Meta:
        verbose_name = _('user')
        verbose_name_plural = _('users')

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


class SampleDB(models.Model):
    class Meta:
        db_table = 'sample_table' # tablename
        verbose_name_plural = 'sample_table' # Admintablename
    sample1 = models.IntegerField('sample1', null=True, blank=True) # 数値を格納
    sample2 = models.CharField('sample2', max_length=255, null=True, blank=True)


class CustomUserManager(UserManager):

    use_in_migrations = True

    def _create_user(self,email,password, **extra_fields):
        if not email:
            raise ValueError('The given email must be set')

        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(emai, password, **extra_fields)

この部分はユーザー作成及びスーパーユーザーを作る際のメソッドをオーバーライドしています。
つまり、ここはDjangoの管理画面へのログインなどにも関わる部分になるのでdjango.contrib.auth.modelsUserManagerの部分をコピペして必要なところを加筆修正する形をとりました。
ソースは こちら


class User(AbstractBaseUser, PermissionsMixin):

    # Userモデルの基本項目。
    username = models.CharField(max_length=30, unique=True)
    email = models.EmailField(_('email address'), unique=True, validators=[EmailValidator('Invalid email address.')])
    first_name = models.CharField(_('first name'), max_length=30, blank=True)
    last_name = models.CharField(_('last name'), max_length=150, blank=True)

    # adminサイトへのアクセス権をユーザーが持っているか判断するメソッド
    is_staff = models.BooleanField(
        _('staff status'),
        default=False,
        help_text=_(
            'Designates whether this 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 = CustomUserManager()

    # 平たくいうと上からメールドレスフィールド、ユーザー名として使うフィールド、スーパーユーザーを作る際に必ず入力するべきフィールドを指定している。
    EMAIL_FIELD = 'email'
    USERNAME_FIELD = 'email'
    REQUIRED_FIELD = []

    class Meta:
        verbose_name = _('user')
        verbose_name_plural = _('users')

    # メールの送信に関するメソッド
    def email_user(self, subject, message, from_email=None, **kwargs):
        send_mail(subject, message, from_email, [self.email], **kwargs)

こちらは同じくdjango.contrib.auth.modelsよりAbstractBaseUserを継承しています。
PermissionsMixinはもとのクラスで継承があるのでこちらでも継承しています。
役割としては権限周りに関するメソッドの集まりみたいです。
models.BooleanFieldはモデルフィールドにおけるBooleanFielddefaultデフォルトの値を設定してTrue、Falseを返します。
参考元ではユーザー名の代わりにメールアドレスでログインするような処理をしたいのでusernameフィールドを消して、emailフィールドにその役割を担わせています。
その設定がUSERNAME_FIELD = 'email'となります。
今回はこのTODOアプリは汎用的であるということと、これをもとにリファクタリングして機能を追加したものを作っていきたいのでusernameを残してあります。

ちなみに


def get_full_name(self):
        """Return the first_name plus the last_name, with a space in
        between."""
        full_name = '%s %s' % (self.first_name, self.last_name)
        return full_name.strip()

def get_short_name(self):
    """Return the short name for the user."""
    return self.first_name

# 他アプリケーションが、username属性にアクセスした場合に備えて定義しておく、アクセスがあったらメールアドレスを返す
@property
def username(self):
    return self.email



といった項目も必要に応じて追加します。
今回はfirst_name及びlast_nameフィールドは使わないので実装していませんが、仮に姓名を登録させるようなフォームを作る場合はあると便利だと思います。

Userモデルを拡張するモデルを新たに作成するアプリケーションフォルダで作成しOneToOneFieldを用いる

todo/models/account.py

from django.conf import settings
from django.db import models
from django.core import validators

class Activate(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    key = models.CharField(max_length=255, blank=True, unique=True)
    expiration_date = models.DateTimeField(blank=True, null=True)

    def __str__(self):
        return self.key


    class Meta:
        verbose_name = 'メール認証フラグ'
        verbose_name_plural = 'メール認証フラグ'

今度はTODOアプリケーション側にUserモデルを拡張するようなモデルを作ります。
今回はUserモデル側の情報の他にメール認証のためのフィールドを定義しておきます。
OneToOneFieldはあるモデルと1対1関係でリレーションを定義するための設定です。
ユーザーに対して認証フラグは1つなので1対1関係となるわけです。
多対1などはForeignKeyで定義することになるようです。

あとは分割したモデルをパッケージするためにmodels.pyを削除し、modelsフォルダを作成しその中に__init__.pyを作成し、分割したモデルも同じフォルダに入れておきます。
__init__.pyは以下のようにパッケージするモデルをインポートするように定義します。

todo/models/__init__.py

from .todo import Todo
from .account import Activate

これでモデルの分割は完了です。

カスタムしたUserモデルとUserモデルを拡張したモデルを管理画面に反映するためにUserモデルがある側のadmin.pyに手を入れる。

あとはadmin.pyに手を入れてれば一段落となります。

app/admin.py

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.utils.translation import ugettext_lazy as _
from .models import User
# todoアプリケーションのaccount.pyからActivateオブジェクトをインポート
from todo.models.account import Activate
from.models import SampleDB

class MyUserChangeForm(UserChangeForm):
    class Meta:
        model = User
        fields = '__all__'


class MyUserCreationForm(UserCreationForm):
    class Meta:
        model = User
        fields = ('email',)

# リレーションしたメール認証フラグをUserモデルの管理画面でも扱えるようにする。
class ActivateInline(admin.StackedInline):
    model = Activate
    max_num = 1
    can_delete = False


class MyUserAdmin(UserAdmin):
    fieldsets = (
        (None, {'fields': ('username', 'password')}),
        (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}),
        (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser',
                                       'groups', 'user_permissions')}),
        (_('Important dates'), {'fields': ('last_login', 'date_joined')}),
    )
    add_fieldsets = (
        (None, {
            'classes': ('wide',),
            'fields': ('email', 'password1', 'password2'),
        }),
    )
    form = MyUserChangeForm
    add_form = MyUserCreationForm
    list_display = ('username','email', 'first_name', 'last_name', 'is_staff')
    list_filter = ('is_staff', 'is_superuser', 'is_active', 'groups')
    search_fields = ('username','email', 'first_name', 'last_name')
    ordering = ('email',)
    # ActivateInlineクラスを指定しておく
    inlines = [ActivateInline]


admin.site.register(User, MyUserAdmin)
admin.site.register(SampleDB)


今度はdjango.contrib.auth.admin.pyから借用し、これをオーバーライドして管理画面でカスタムユーザーモデルを表示できるようにします。
肝はfrom todo.models.account import Activateの部分。

todo/admin.py

from django.contrib import admin
from django.contrib.auth.models import User
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.utils.translation import ugettext_lazy as _
from .models import Todo, Activate
from app.models import User

# Register your models here.

class TodoAdmin(admin.ModelAdmin):

    fields = ['todo_title', 'memo', 'datetime', 'tags']

    list_display = ('todo_title', 'datetime', 'created_at', 'tag_list')
    list_display_links = ('todo_title', 'datetime','tag_list')

    def get_queryset(self, request):
        return super().get_queryset(request).prefetch_related('tags')

    def tag_list(self, obj):
        return u", ".join(o.name for o in obj.tags.all())

    pass

admin.site.register(Todo,TodoAdmin)
admin.site.register(Activate)


このようにtodoアプリケーション側でもActivateモデルは管理できますが、リレーション関係でありかつUserモデルで管理した方が都合がよいのでfrom todo.models.account import Activateでモデルをインポートして、
Userクラス側にInlineクラスを作っておくということですね。
ひとまずあとは作業を進めていく上でもしUSERNAME_FIELD = 'email'他でエラーが出たらその都度対処をしていきます。

参考

DjangoでUserモデルのカスタマイズ
Django、Userモデルのカスタマイズ(OneToOne)
Djangoのカスタムユーザーモデルでサインアップできるようにする
Django AbstractBaseUserでカスタムユーザー作成
公式ドキュメントより認証方法のカスタマイズ
公式ドキュメントよりdjango.contrib.auth

25
39
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
25
39