Stephen Gilmore

Email Only Django Custom User Model

Django September 11th, 2024 12 minute read.

I read an article by Carlton Gibson the other day titled Evolving Django's auth.User. It's quite long, but worth the read. My tl;dr -

  1. Most projects want to use email login without username
  2. There are a lot of good reaons to extend the build-in user model with a profile model instead of overloading a custom user model for your project.
  3. We've also got problems with the first_name and last_name fields. See Falsehoods Programmers Believe About Names

So down the rabbit hole I went to see what I could do to make a custom user model without username, first_name, or last_name. The rest of this blog post assumes you're using Django 5.1 with Postgres 15 and starting a project from scratch. Also, my user "stuff" is all in a Django app named users.

Step 1: Start a custom user model

# users/models.py
from django.contrib.auth.models import AbstractUser

class CustomUser(AbstractUser):
    pass

Step 2: Create a collation for case insensitive lookups

For Postgres, we need a thing called a "collation" to have a case insensitive email field. (There used to be a field called CIEmail that was deprecated in django 5.0). I learned how to do this from an adamj.eu post.

Create an empty migration for the case insensitive collation.

python manage.py makemigrations  users --empty --name case_insensitive_collation

Then in that migration file you're going to add the CreateCollation object.1

# users/migrations/0001_case_insensitive_collation.py
from django.contrib.postgres.operations import CreateCollation
from django.db import migrations

class Migration(migrations.Migration):
    dependencies = []
    operations = [
        CreateCollation(
            "case_insensitive",
            provider="icu",
            locale="und-u-ks-level2",
            deterministic=False,
        ),
    ]

Step 3: The model manager

I took a lot of inspiration from a testdrive.io post about Django custom users for creating the model manager. There are some deviations in my version where I tried to stay closer to the default user manager Django's source code.

# users/models.py
import uuid

from django.conf import settings
from django.contrib import auth
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db import models
from django.utils.translation import gettext_lazy as _


class UserManager(BaseUserManager):
    """A Manager for the User model. This mimics the UserManager in django.contrib.auth.models.UserManager"""

    use_in_migrations = True

    def _create_user(self, email, password, **extra_fields) -> "User":
        """
        Create and save a user with the given email, and password.
        """

        # Validate the email
        if not email:
            raise ValueError("The email must be set")
        try:
            validate_email(email)
        except ValidationError:
            raise ValueError("Invalid email address")

        # Create the user
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save()
        return user

    def create_user(self, email, password=None, **extra_fields) -> "User":
        # Set default permissions (None)
        extra_fields.setdefault("is_staff", False)
        extra_fields.setdefault("is_superuser", False)
        # Create the and return the user
        return self._create_user(email, password, **extra_fields)

    def create_superuser(self, email, password=None, **extra_fields):
        # Set the default permissions for a superuser
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)

        # Ensure that the is_staff and is_superuser attributes are 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.")

        # Create and return the user
        return self._create_user(email, password, **extra_fields)

    def with_perm(self, perm, is_active=True, include_superusers=True, backend=None, obj=None):
        if backend is None:
            backends = auth._get_backends(return_tuples=True)
            if len(backends) == 1:
                backend, _ = backends[0]
            else:
                raise ValueError(
                    "You have multiple authentication backends configured and "
                    "therefore must provide the `backend` argument."
                )
        elif not isinstance(backend, str):
            raise TypeError("backend must be a dotted import path string (got %r)." % backend)
        else:
            backend = auth.load_backend(backend)
        if hasattr(backend, "with_perm"):
            return backend.with_perm(
                perm,
                is_active=is_active,
                include_superusers=include_superusers,
                obj=obj,
            )
        return self.none()

Step 4: Update the custom user model

I'm actually kind of proud of myself for figuring this bit out on my by digging through the source code. (This also means it's more likely to have bugs.)

Note: all the imports for this model are included in the code snippet from the previous step.

# users/models.py

class User(AbstractUser):
    """
    A custom user with email and no username, first_name, or last_name.
    """

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    username = None
    email = models.EmailField(_("email address"), db_collation="case_insensitive", unique=True)
    first_name = None
    last_name = None

    objects = UserManager()

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []

    class Meta(AbstractUser.Meta):
        swappable = "AUTH_USER_MODEL"

    def __str__(self):
        return self.email

    def save(self, *args, **kwargs):
        self.email = self.email.lower().strip()
        return super().save(*args, **kwargs)

    def get_full_name(self):
        return self.email

    def get_short_name(self):
        return self.email

Step 5: Update user forms

We'll need new forms to create new users without a username.

# users/forms.py
from django import forms
from django.contrib.auth.forms import ReadOnlyPasswordHashField
from django.core.exceptions import ValidationError

from .models import User


class UserCreationForm(forms.ModelForm):
    """A form for creating new users. Includes all the required
    fields, plus a repeated password."""

    password1 = forms.CharField(label="Password", widget=forms.PasswordInput)
    password2 = forms.CharField(label="Password confirmation", widget=forms.PasswordInput)

    class Meta:
        model = User
        fields = ["email"]

    def clean_password2(self):
        # Check that the two password entries match
        password1 = self.cleaned_data.get("password1")
        password2 = self.cleaned_data.get("password2")
        if password1 and password2 and password1 != password2:
            raise ValidationError("Passwords don't match")
        return password2

    def save(self, commit=True):
        # Save the provided password in hashed format
        user = super().save(commit=False)
        user.set_password(self.cleaned_data["password1"])
        if commit:
            user.save()
        return user


class UserChangeForm(forms.ModelForm):
    """A form for updating users. Includes all the fields on
    the user, but replaces the password field with admin's
    disabled password hash display field.
    """

    password = ReadOnlyPasswordHashField()

    class Meta:
        model = User
        fields = ["email", "password", "is_active", "is_staff"]

Step 6: Admin configuration

# users/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin

from .forms import UserChangeForm, UserCreationForm
from .models import User


@admin.register(User)
class UserAdmin(BaseUserAdmin):
    # The forms to add and change user instances
    form = UserChangeForm
    add_form = UserCreationForm

    list_display = ["email", "is_staff"]
    list_filter = ["is_staff"]
    fieldsets = [
        (None, {"fields": ["email", "password"]}),
        ("Personal info", {"fields": []}),
        ("Permissions", {"fields": ["is_staff"]}),
    ]
    # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin
    # overrides get_fieldsets to use this attribute when creating a user.
    add_fieldsets = [
        (
            None,
            {
                "classes": ["wide"],
                "fields": ["email", "password1", "password2"],
            },
        ),
    ]
    search_fields = ["email"]
    ordering = ["email"]
    filter_horizontal = []

Step 7: Model tests

I use pytest as the test runner, so sorry in advance if it's not 100% compatible with Django's unittest framework.

# users/tests/models_tests.py
import pytest

from users.models import User
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from django.test import TestCase

User: User = get_user_model()


@pytest.mark.django_db
class TestCustomUserModel(TestCase):
    def test_create_user(self):
        # Create a user with email only
        user = User.objects.create_user(email="[email protected]", password="testpass123")

        # Check email and password
        self.assertEqual(user.email, "[email protected]")
        self.assertTrue(user.check_password("testpass123"))

        # Check default permissions
        self.assertFalse(user.is_staff)
        self.assertFalse(user.is_superuser)

        # Check creating a user without any data raises an error
        with self.assertRaises(
            TypeError,
            msg="UserManager.create_user() missing 1 required positional argument: 'email'",
        ):
            User.objects.create_user()

        # Check creating a user with a blank email and password raises an error
        with self.assertRaises(ValueError, msg="The email must be set"):
            User.objects.create_user(email="", password="testpass123!")

        # Check creating a user with a blank email and no password raises an error
        with self.assertRaises(ValueError, msg="The email must be set"):
            User.objects.create_user(email="")

        # Check creating a user with spaces for the email
        with self.assertRaises(ValueError, msg="Invalid email address"):
            User.objects.create_user(email=" ", password="testpass123!")

        # Check creating a user with an email with different capitalization
        with atomic(), self.assertRaises(
            IntegrityError,
            msg='duplicate key value violates unique constraint "users_email_key"',
        ):
            User.objects.create_user(email="[email protected]", password="testpass123")

        # Check creating a user with an email with different domain capitalization
        with atomic(), self.assertRaises(
            IntegrityError,
            msg='duplicate key value violates unique constraint "users_email_key"',
        ):
            User.objects.create_user(email="[email protected]", password="testpass123")

    def test_user_email_case_insensitive(self):
        user = User.objects.create_user(email="[email protected]")

        # Check email saved as lowercase
        self.assertEqual(user.email, "[email protected]")

        # Check query lowercase email returns the same user
        self.assertEqual(user.pk, User.objects.get(email="[email protected]").pk)

        # Check query random casing email returns the same user
        self.assertEqual(user.pk, User.objects.get(email="[email protected]").pk)

    def test_create_superuser(self):
        # Most functionality is tested on the test_create_user(self) test case

        # Create a user with email only
        user: User = User.objects.create_superuser(
            email="[email protected]", password="testpass123"
        )

        # Check email and password
        self.assertEqual(user.email, "[email protected]")
        self.assertTrue(user.check_password("testpass123"))

        # Check default permissions
        self.assertTrue(user.is_staff)
        self.assertTrue(user.is_superuser)

    def test__str__method_and_name_methods(self):
        user: User = User.objects.create_user(email="[email protected]")
        self.assertEqual(f"{user}", "[email protected]")
        self.assertEqual(user.get_short_name(), "[email protected]")
        self.assertEqual(user.get_full_name(), "[email protected]")

Step 9: Extend the user model with a profile

You probably don't want to stuff everything into your custom user model. Say you have a timezone field in your project, the timezone would be accessible through user.profile.timzeone.

# users/models.py
class UserProfile(models.Model):
    user = models.OneToOneField(
        "project.User",
        on_delete=models.CASCADE,
        primary_key=True,
        related_name="profile",
    )

    def __str__(self):
        return f"{self.user}"

    class Meta:
        verbose_name = "profile"
        verbose_name_plural = "profiles"
        db_table = "user_profiles"
  ```

And if you have a profile, a nice way to add it to the admin is with an `inline`

```python
# users/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin

from .forms import UserChangeForm, UserCreationForm
from .models import User, UserProfile


class UserProfileInline(admin.StackedInline):
    model = UserProfile
    can_delete = False
    verbose_name_plural = "User Profile"

admin.site.register(UserProfile)

@admin.register(User)
class UserAdmin(BaseUserAdmin):
    # The forms to add and change user instances
    form = UserChangeForm
    add_form = UserCreationForm

    inlines = [UserProfileInline] # new
    ... # all the content from before

Step 8: Further development

A few things I thought of that could be improved in the future:

  1. Information about how to create a case insensitive field for other databases.
  2. Tests for the forms.
  3. Tests for the admin configuration.
  4. Is there any other "cleaning" or validation that should be performed on email addressees? (I could probably dig through the django-allauth source code to check)
  5. I'm a bit terrified there's a catastraphic bug in a blindspot somewhere that I'm not seeing.

Concluding thoughts

I think the number one question is... Would I use this in production? Honestly, I don't think so.

Other miscellaneous thoughts:


  1. My goodness, how on earth would a hack like me figure out how to do this without copying it out of someone else's blog post??