Email Only Django Custom User Model
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 -
- Most projects want to use
email
login withoutusername
- 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.
- We've also got problems with the
first_name
andlast_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:
- Information about how to create a case insensitive field for other databases.
- Tests for the forms.
- Tests for the admin configuration.
- 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)
- 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.
- For a small app, I don't think it's worth hassle and room for error to stray from Django's battle-tested user model.
- For a large app, why bother if you're going to throw django-allauth over the top of it?
Other miscellaneous thoughts:
- I learned the user model isn't quite as scary as I thought it was.
- TDD was helpful for finding a few bugs and validating the case insensitive email field worked.
- In the custom user model vs user profile model debate... This answers nothing. The best answer is probably a balance between the two.
-
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?? ↩