Creating a custom user model in Django 1.6 - Part 4

Published byGoogle on

Hi everyone and welcome back to this Django tutorial. If you haven't done so yet, make sure to check out the first, second and third part of this tutorial before getting started today.

Once again, we are sorry about the partial posting of this article earlier this week.

This week we will start by covering the admin login form and allowing users to log into the admin site using their e-mail address or their username instead of just their username.
Then we will go through a couple more advanced cases of custom user model in Django, including expirable users.

Allowing users to log into the admin site using exclusively their email is quite easy and straight-forward. This is not what we'd like to do (we want to be able to log in both with an e-mail and and with a username), but I'd like to show you how to do it anyways, just for the sake of it, since all it takes is just a few trivial changes to the model.

By the way, if we were trying to make those simple changes after inheriting directly from AbstractUser (to avoid redefining the fields already defined there) instead of BaseAbstractUser, Django would crash with a FieldError exception because we are trying to redefine email which is already defined in AbstractUser but is not unique (it also has a max_length of 75 which is not RFC-compliant)

So, back to our simple changes: all you have to do is change 3 things in your model.

  1. Make the email field unique (which is actually something we should have done earlier too) and while we are editing it, let's also make it longer.
      email = models.EmailField(_('email address'), max_length=255, unique=True)
  2. Change the USERNAME_FIELD property to 'email'
      USERNAME_FIELD = 'email'
  3. Remove 'email' from the REQUIRED_FIELDS (the field mentioned in USERNAME_FIELD is required be default and cannot be listed in REQUIRED_FIELDS)
      REQUIRED_FIELDS = []

Now, as I said, we don't want to be able to login with email but with either email or username. To do so we will have to plug a custom form into admin.site.login_form (we will do that inside urls.py)

This custom form has to follow 2 simple requirements:

  1. it has to extend django.contrib.auth.form.AuthenticationForm
  2. it needs a special field (the LOGIN_FORM_KEY field) which by default is named this_is_the_login_form

Django admin view will use this form and look for 2 fields (other than the LOGIN_FORM_KEY) which are the field referenced in the USERNAME_FIELD property of the model as well as a password field.
Now this is unfortunate since we have a custom form (LoginForm) which already has the correct authentication logic but which uses login as the "username" field (<-- this was done for clarity's sake since that field can be filled by either a username or an email)

So we will be doing some refactoring and introduce Mixins (a.k.a. multiple inheritance). Let's first update our myauth/forms.py so it looks liks this:

#update this line
from django.contrib.auth.forms import UserCreationForm as AuthUserCreationForm, UserChangeForm as AuthUserChangeForm, AuthenticationForm

.
.  ## leave UserCreationForm and UserChangeForm unchanged
.

class LoginFormMixin(object):
  
  # This is where our logic will take place

  username = forms.CharField(label = 'Username or e-mail', required=True)
  password = forms.CharField(label = 'Password', widget = forms.PasswordInput, required = True)

  def clean(self):
    username = self.cleaned_data.get('username', '')
    password = self.cleaned_data.get('password', '')
    self.user = None
    users = User.objects.filter(Q(username=username)|Q(email=username))
    for user in users:
      if user.is_active and user.check_password(password):
        self.user = user
    if self.user is None:
      raise forms.ValidationError('Invalid username or password')
    # We are setting the backend here so you may (and should) remove the line setting it in myauth.views
    self.user.backend = 'django.contrib.auth.backends.ModelBackend'
    return self.cleaned_data
    
class LoginForm(LoginFormMixin, forms.Form):
    

  # Because of the way Python's MRO works we have to redefine username which has been overridden by AuthenticationForm
  username = forms.CharField(label = 'Username or e-mail', required=True)
  password = forms.CharField(label = 'Password', widget = forms.PasswordInput, required = True)
  this_is_the_login_form = forms.BooleanField(widget=forms.HiddenInput, initial=1, error_messages={'required': "Please log in again, because your session has expired."})

  def __init__(self, *args, **kwargs):
    super(LoginForm, self).__init__(*args, **kwargs)
    self.helper = FormHelper()
    self.helper.form_method = 'post'
    self.helper.form_action = 'login'
    self.helper.form_class = 'form-horizontal'
    self.helper.label_class = 'col-md-5'
    self.helper.field_class = 'col-md-6'
    # this line changed
    self.helper.layout = Layout(
     'username', 'password',
     HTML('<div class="form-group"><div class="col-md-5"> </div><div class="col-md-6">'),
     Submit('submit', 'Log in'),
     HTML('</div></div>'),
    )
  

class AuthLoginForm(LoginFormMixin, AuthenticationForm):

  # Because of the way Python's MRO works we have to redefine username which has been overridden by AuthenticationForm
  username = forms.CharField(label = 'Username or e-mail', required=True)
  password = forms.CharField(label = 'Password', widget = forms.PasswordInput, required = True)
  this_is_the_login_form = forms.BooleanField(widget=forms.HiddenInput, initial=1, error_messages={'required': "Please log in again, because your session has expired."})

  def clean(self):
    cleaned_data = super(AuthLoginForm, self).clean()
    if self.user is not None:
      self.user_cache = self.user
    return cleaned_data

Note that last week we set the authentication backend on the user object returned from the form inside the view. Since the admin also uses Django's contrib.auth we need to provide a backend also. So we moved the backend assignation to the form's clean method.

 Now that we have a suitable form to use with the admin login, we have to tell Django to use it. Update your urls.py in the following way:

from django.contrib import admin
from myauth.forms import AuthLoginForm
    
admin.site.login_form = AuthLoginForm
admin.autodiscover()

Note that we are overriding Django's admin.site login_form parameter directly in urls.py. This is a trivial change, for more complex changes, I would strongly advise subclassing django.contrib.admin.sites.AdminSite.

Now, we've already seen one reson why directly inheriting from django.contrib.auth.AbstractUser might not be a great idea, here are 2 more common use cases to illustrate it.

1. The first name/last name problem

In some parts of Western Europe, people usally go by a first name and last name. In Nothern America, people usually go by a first (sometimes also reffered to as christan), a middle name and a last name.
That one is easy, it's just an extra field.

But in the rest of the world, people are named using different patterns (that's why I only ask for a name on this site's conatct form).

Now, Django's user contract doesn't require a first or last name, it only implicitly requires get_short_name and get_full_name methods (used by the admin and other parts of Django's commonly used code). So let's update our user model and admin to only ask for a short and a full name (don't forget to create a schemamigration afterwards)

#myauth/models.py

class User(AbstractBaseUser, PermissionsMixin):
  #remove definition of first_name/last_name and add these two fields
  short_name = models.CharField(_('short name'), max_length=30, blank=True, null=True)
  full_name = models.CharField(_('full name'), max_length=255, blank=True, null=True)

 .
 .
 .

  def get_full_name(self):
    return self.full_name

  def get_short_name(self):
    return self.short_name


#myauth/admin.py
class UserAdmin(AuthUserAdmin):

  #change those definitions
  fieldsets = (
    (None, {'fields': ('username', 'password', 'receive_newsletter')}),
    ('Personal info', {'fields': ('short_name', 'full_name', 'email')}),
    ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser',
                    'groups', 'user_permissions')}),
    ('Important dates', {'fields': ('last_login', 'date_joined')}),
  )
  add_fieldsets = (
    (None, {'fields': ('username', 'password1', 'password2', 'receive_newsletter')}),
    ('Personal info', {'fields': ('short_name', 'full_name', 'email')}),
    ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser')}),
  )
  list_display = ('username', 'email', 'short_name', 'full_name', 'is_active', 'is_staff', 'receive_newsletter')
  list_filter = ('is_staff', 'is_superuser', 'is_active', 'groups', 'receive_newsletter')
  search_fields = ('username', 'email', 'short_name', 'full_name')

 2. Expirable users

Another common use case is expirable users (you know when you sign up for a service for which you have to pay a monthly/yearly fee). Even if it's not explicitly part of the user contract, most applications expect to find an is_active field inside the user model (we do too, re-read the authentication form's clean method if your d'ont remember).
An easy way to implement expirable user is to remove the is_active field and replace it by an is_active property which relies on an exipres field. Here is a sample implementation.

from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from dateutil.relativedelta import relativedelta


def one_year_from_now():
  return timezone.now() + relativedelta(years=1)

.
.
.

class User(AbstractBaseUser, PermissionsMixin):

  .
  .
  .
  expires = models.DateTimeField(_('expiration date'), default=one_year_from_now)

  def is_active(self):
    return timezone.now() <= self.expires

Note to the ones of you who feel like hacking Django: All along this week's post, I've been lying to you! You can do everything we have done today by having User inherit from AbstractUser instead of BaseAbstractUser, you just have to remove the existing field definitions that stand in your way. You can remove field definitions in a subclass of a model by removing them from Model._meta.fields inside the __init__ method of the subclass.

If this is really what you want to do I cannot prevent you from doing it, but I would strongly advise against it in this case (sometimes you don't have a choice, here you do) as it doesn't feel clean to me and might make things harder to debug if i.e. someone decides to subclass your User class and wonders where those fields went.

Well, I hope you all enjoyed this week's post and I'll see you in a few days for the next part of this tutorial in which we will be covering user registration. In the mean time: happy coding everyone Smile

Tutorial Django Custom user model

Comments

Hi,

thanks a lot for the tutorial. I have some troubles though with this part. After changing USERNAME_FIELD and REQUIRED_FIELDS I can no longer use 'python manage.py createsuperuser' as it asks only for email now no more for the username. So I reverted it back but now there is a type_error: 'is_active' is an invalid keyword argument for this function

How am I supposed to create a new super user now (I'm starting out with a new database). I tried to use the shell to create a new User Object and set is_staff to True but it still wouldn't let me log into the admin page.

By Tobias Dacoir - 2 years, 11 months ago Reply 

I'm sorry if it wasn't clear enough in the tutorial. Changing the USERNAME_FIELD to email will allow you to login using email ONLY.
This removes the need for a username, so the fact createadmin only asks you for an email is what is expected. A username field is not required for you to log in.

I'm not sure what you mean by "I reverted it back" but the error message probably comes from an un-migrated model.

With the USERNAME_FIELD set to email, all you have to do to log-in is use your e-mail and password (for the superuser) to log into the admin.

By Emmanuelle Delescolle - 2 years, 10 months ago Reply 

Also for some reason I need to overwrite password field in class LoginForm(LoginFormMixin, forms.Form) again, otherwise crispy throws an exception:
WARNING:root:Could not resolve form field 'password'.
Traceback (most recent call last):
File "/Library/Python/2.7/site-packages/crispy_forms/utils.py", line 74, in render_field
field_instance = form.fields[field]
KeyError: u'password'

By Tobias Dacoir - 2 years, 11 months ago Reply 

Yes, you are right, I guess that line somehow escaped when I pasted the code. It is correct in the repository though but I'll fix this post right now.

Thanks for the feedback

By Emmanuelle Delescolle - 2 years, 10 months ago Reply 

Post a comment