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 django-developers+unsubscr...@googlegroups.com.
To post to this group, send email to django-developers@googlegroups.com.
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.

Reply via email to