Hello,
I spent a good part of today implementing what must be the most common scenario
for custom user models: case-insensitive email as username. (Yes. This horse
has been beaten to death. Multiple times.)
Since it was the first time I implemented a custom user model from scratch by
myself, I’d like to share my experience in case that’s useful to others. Do you
think there’s a better solution? Do you have concrete ideas for improving
Django in this area?
The main alternative I’m aware of is a custom email field based on PostgreSQL’s
citext type. Perhaps I’ll try that next time. Anyway, here’s what I did this
time.
1) The documentation is excellent
I know a lot of effort has been put into improving it and it shows.
Congratulations to everyone involved.
2) Custom indexes would be convenient
Since I want to preserve emails as entered by the users, I cannot simply
lowercase them. That would have been too easy.
I ended up with this migration to add the appropriate unique index on
LOWER(email). See the comments for details.
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True,
serialize=False, verbose_name='ID')),
# …
# unique=True was removed from the autogenerated line; a unique
index is created below.
('email', models.EmailField(error_messages={'unique': 'A user
with that email already exists.'}, max_length=254, verbose_name='email
address')),
# …
],
# …
),
migrations.RunSQL(
# Based on editor._create_index_sql(User,
[User._meta.get_field('email')], '_lower')
sql='CREATE UNIQUE INDEX "blabla_user_email_f86edd9d_lower" ON
"blabla_user" (LOWER("email"))',
reverse_sql='DROP INDEX "blabla_user_email_f86edd9d_lower"',
state_operations=[
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(error_messages={'unique': 'A user
with that email already exists.'}, max_length=254, unique=True,
verbose_name='email address')
),
],
),
]
It took me some time to get there. At first I tried simply removing unique=True
on the email field but that didn’t work well.
I know there’ve been discussions about custom indexes. They would make this use
case much easier.
3) Redefining forms isn’t too bad
I was getting quite bored of copy-paste-tweaking snippets (custom model, custom
manager, custom admin, …) when I got to defining custom forms. Fortunately, a
small mixin was all I needed.
(Read on for why this code uses `User.objects.get_by_natural_key(email)`.)
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
from django.contrib.auth.forms import UserCreationForm as BaseUserCreationForm
from django.core.exceptions import ValidationError
class CaseInsensitiveUniqueEmailMixin:
"""
ModelForm mixin that checks for email unicity, case-insensitively.
"""
def clean_email(self):
email = self.cleaned_data['email']
User = self._meta.model
field = User._meta.get_field('email')
try:
User.objects.get_by_natural_key(email)
except User.DoesNotExist:
return email
else:
raise ValidationError(
message=field.error_messages['unique'],
code='unique',
)
class UserChangeForm(CaseInsensitiveUniqueEmailMixin, BaseUserChangeForm):
pass
class UserCreationForm(CaseInsensitiveUniqueEmailMixin, BaseUserCreationForm):
pass
4) The ugly hack
My first ideas was to write a custom authentication backend to look up users by
email case-insensitively. But I was getting bored and I noticed that
django.contrib.auth uses `UserModel._default_manager.get_by_natural_key` to
look up users. So...
class UserManager(BaseUserManager):
"""
Manager for the User class defined below.
Quite similar to django.contrib.auth.models.UserManager.
"""
# ...
def get_by_natural_key(self, email):
qs = self.annotate(email_lower=Lower('email'))
return qs.get(email_lower=email.lower())
/!\ This is entirely dependent on implementation details of
django.contrib.auth. It can break when you upgrade Django; don’t blame it on
me. /!\
That said, the nice side effect of this implementation is that it makes the
unicity check in createsuperuser work as expected. I’m not aware of any other
way to fix it with the database schema I chose.
I suppose an implementation of custom unique indexes with support for checking
unicity constraints would make that point moot.
Best regards,
--
Aymeric.
--
You received this message because you are subscribed to the Google Groups
"Django developers (Contributions to Django itself)" group.
To unsubscribe from this group and stop receiving emails from it, send an email
to [email protected].
To post to this group, send email to [email protected].
Visit this group at http://groups.google.com/group/django-developers.
To view this discussion on the web visit
https://groups.google.com/d/msgid/django-developers/DFFA548E-F746-4123-AD50-4DBF8BDA3925%40polytechnique.org.
For more options, visit https://groups.google.com/d/optout.