Extending Django's User Model with OneToOneField

Crunchy Data
django

This blog post is authored by Kat Batuigas.

This post is the second in a two-part series on my experience with adding a user registration system to a simple demo app built in Django. In my first post, I talk about how Django's built-in authentication system can do some of the heavy lifting for your registration setup. In this post, I'll walk you through how we tied our data models and authentication together by extending Django's User model.

You may recall from the first post that there wasn't a pure out-of-the-box solution for user account registration, even in Django's robust built-in authentication. This is especially true if your app users don't look or behave exactly like the User model in Django. In our case, I want to store custom attributes that don't come with the User model by default. I also want our players to provide those custom values upon registration. So I started to dig a bit deeper into customizing authentication.

Extending the User model

For customizing authentication, we have three options:

  1. Create a proxy model based on the Django User model.
  2. Use a OneToOneField that links the User model to another model that contains additional fields (this can also be referred to as a User Profile).
  3. Create a custom User model.

You can say that the first two options "extend" the User model because they do still use the built-in model. User data is still stored in the same database tables. On the other hand, creating a custom User model replaces the provided model entirely, and it also means you'd create new database tables to store user data.    

As to whether we should completely replace the Person model: for players (Persons) to log in to the app, they should be Users as well, but the third option seemed like more than we needed. The proxy model is suitable for when you need to customize only the behavior of the User model⁠—for instance, if you want to create a new method. Since our requirement is for the app to also be able to save some additional fields for players, I went with the User Profile option.

OneToOneField or ForeignKey?

The first thing we need to do at this point is change our data model. My colleague Steve Pousty describes in his blog post how we went from designing the database to setting up our Django models. This time, we completely threw out our database (which was fine since it was pretty early on in development), and then exported our model again to PostgreSQL and used the inspectdb command to update models.py.

We removed the username, password, and email fields. Since we're extending the existing User model, this means we're letting Django handle these "standard" fields, while we use the Person model to store the extra attributes that don't already come with User. I should note that at this point we still had an autogenerated primary key for the underlying person table, and I'll talk about what we ended up doing on the database side.

How do you actually implement the Profile model? This primarily involves using the OneToOneField option for the model that links back to User. 

Now, Django also has the ForeignKey option to represent a relationship between two models. If I were to use this option with our Person model, I should theoretically also expect to be able to query the User attributes from Person. However, you'll note in the docs that Django specifically states that it is used for many-to-one relationships. I know that I don't want a many-to-one relationship from Person to User, so this was a pretty easy decision to make. 

With that said, I did find some examples of ForeignKey being used with the User model. They appear to be discussed within the context of using an entirely custom User model. As a disclaimer, the official Django docs do also mention that it's recommended to set up a custom user model for a new project, even if the default User model might suffice. This is not the route I had gone, so perhaps we didn't even need to worry about the ForeignKey option anyway, but I wanted to walk through what our thought process was in case this is helpful for anyone else. 

I do think this was one area in which it wasn't immediately clear what the better practice was for our case. The same page of the docs on custom user models goes on to say in a later section: "When you start your project with a custom user model, stop to consider if this is the right choice for your project." To me that sounds pretty contradictory to the earlier recommendation and so it's confusing.

Syncing our database with Django models

I attempted to replace the existing primary key, an AutoField called person_id, with a new OneToOneField for our autogenerated Person model:

user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True,)

However, when I tried to run the migration, it errored out because of the existing person_id primary key in the database, instead of just deleting that primary key and creating a new one with user like I had thought would happen. After some trial and error I decided to just remove person_id directly in Postgres:

DROP SEQUENCE person_person_id_seq;
ALTER TABLE person DROP COLUMN person_id;   

Our Person model definition in models.py that finally got migrations to work and the database synced now looked like this:

class Person(models.Model):
   user = models.OneToOneField(
       User,
       on_delete=models.CASCADE,
       primary_key=True,
   )
   discord_id = models.TextField(blank=True, null=True)
   zoom_id = models.TextField(blank=True, null=True)
   birthdate = models.DateField(blank=True, null=True)

   class Meta:
       managed = False
       db_table = 'person'

Again, you may have previously read about our experience with the managed Meta attribute in Steve's post. It would have probably saved us some time if we had tried that first. In any case, this is what the person table in our database model ended up looking like:

pasted image 0 (4)

We don't need to create a User model so I left that table out in the design.

So, as a heads up, you might run into this same back-and-forth of trying to get migrations in Django to work, and having to fiddle around on the database level as well to get everything to look the way it should. Once we got our database to a state that we wanted, we could move on to figuring out how the registration form should update both Users as well as the corresponding Persons.

Using a signal to create a Person for the new User

I found two blog posts that use signals with the Profile model. The way I understand signals is that they allow a part of a Django app or project to know when some event or action has taken place elsewhere in the app that it isn't necessarily directly "listening" to. When a signal is sent from a sender to a receiver, the receiver can then carry out a particular action depending on what kind of signal it is.

In our case, I want the User model to be the sender - specifically, when a new user registers for the first time - and the Person model to be the receiver. This should result in a new Person object also being created with the creation of a new User. In our models.py, I added this code directly below our Person definition:

@receiver(post_save, sender=User)
def update_profile_signal(sender, instance, created, **kwargs):
   if created:     
       Person.objects.create(user=instance)
   instance.person.save()

Because I use the post_save signal, this should also work if the action is just an update on an existing User. I haven't actually built a way for our D&D players to update their profiles in the app yet, so we have yet to see whether this exactly handles that scenario, but for now it works great for user registration.

Wrapping up with the view

In my views.py, following the blog examples linked above, I could finally put together a register view to process our form data:

def register(response):
   if response.method == 'POST':
       form = RegisterForm(response.POST)
       if form.is_valid():
           user = form.save()
           user.refresh_from_db()
           user.person.birthdate = form.cleaned_data.get('birthdate')
           user.person.discord_id = form.cleaned_data.get('discord_id')
           user.person.zoom_id = form.cleaned_data.get('zoom_id')
           user.save()
           username = form.cleaned_data.get('username')
           password = form.cleaned_data.get('password1')
           user = authenticate(username=username, password=password)
           login(response, user)

            return redirect('/')    

else:
        form = RegisterForm()

    return render(response, 'manager/register.html', {'form': form})

The refresh_from_db() method is absolutely handy - before I attempt to set the Person fields with values from the form, I have to make sure to refresh the User object so we can grab the related Person instance.

We had already set up our registration template (this would be the manager/register.html argument in the return statement above) from Part 1, and I didn't need to make any changes there. 

...And we're off and running!

Our app now has a registration form that sets our new DnD player signups as users, authenticates them, and also allows them to save some other personal info as well. To recap, here are some of the things we learned:

  • Someone who has to log in to and authenticate in a Django-powered app is a User.
  • If you want to store additional attributes with each user, you have the option of extending the User model.
  • A built-in view or form like UserCreationForm can save you at least some work. Django has so many of these since it's a framework that's been around for a while. You may still have some further tweaking to do, but it's probably worth digging to see if a common pattern or use case has already been addressed by Django.
  • OneToOneField establishes the relationship between User and your "profile" model, allowing you to traverse, query, and work with data on either side of the relationship.

Being new to Django, it took some trial and error to get our models working with our database in the way we wanted. That said, this was a fun little learning exercise, and I hope my experience also helps some of you out there.

Join the Discussion

Newsletter