Is this a good use case for the `post_save` signal?

Context
I have working code copied from a tutorial a while back. Through a post_save signal, it fulfills its purpose which is to automatically create a Profile object when a CustomUser object is created. The tutorial helped get it up and running, but I would like to understand what’s happening more fully and know if there’s a better way to go about it because I’ve come across several more knowledgable people saying or writing about in passing how signals are really good for specific use cases but can sometimes be overused. It’s unclear to me what those specific use cases are, so I don’t know if this is a good use of signals or there’s a better way. Additionally, the tutorial also did not include tests, so I added a test for the create_profile() myself and welcome feedback.

Questions:

  1. Is this example a good use case for when to use a post_save signal?
    1a. If no, what would you recommend I use instead of the signal to achieve the same goal?
  2. The tutorial showed what to write for create_profile() and post_save.connect() but didn’t explain what was being written. I found the Django docs sections post_save signal and signal.connect() which helped me understand what’s going on (at least at a high level), however one part I still don’t understand is why **kwargs is there as a parameter. What is that part doing to help in this case? For context, I still have a rudimentary understanding of key word arguments as I’m quite new to programming.

Bonus Questions:
3. Do you have any broad rules of thumb for knowing when is a good time to use a signal and when is not a good time to use a signal?
4. Is this test the way you would’ve written it? (I’m not well-versed in writing tests on my own and, while this seems to do what I expect it to, I’m curious if this is how others would approach testing this sort of thing.)

Code excerpts:

Models

# accounts/models.py
from django.contrib.auth.models import AbstractUser, PermissionsMixin


class CustomUser(AbstractUser, PermissionsMixin):
    pass

# profiles/models.py
import uuid

from django.contrib.auth import get_user_model
from django.db import models
from django.db.models.signals import post_save
from django.urls import reverse

CustomUser = get_user_model()

class Profile(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    user = models.OneToOneField(CustomUser, on_delete=models.CASCADE)

    def __str__(self):
        return self.user.username

# Create Profile When New User Signs Up
def create_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)


post_save.connect(create_profile, sender=CustomUser)

Test

from django.contrib.auth import get_user_model
from django.test import TestCase

from .models import Profile

class ProfileTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = get_user_model().objects.create_user(
            username="testuser",
            email="test@email.com",
            password="secret",
        )

    def test_profile_created(self):
        self.assertEqual(self.user.profile, Profile.objects.get(user=self.user))

Thank you in advance.

1 Like

I’m probably one of the most vocal people when it comes to expressing opinions against signals.

So, with that in mind:

<opinion>
Signals should only be used when you have no reasonable alternative.

This is typically the case when you’re using or integrating apps from external sources.

What this translates to are two specific situations.

  1. You’re using a third-party app, and that app uses signals to trigger events in code you need to write.
  2. You’re writing an app that is going to be considered a “third-party app” from the perspective of the people using your app.

In all other situations, I avoid signals.

If you (the collective / generic “you”) have the responsibility or control over the code in your project on both sides for which you’re thinking you may want to use a signal, then you don’t need to use signals, so don’t.

</opinion>

What this translates into, relative to your code above, is that by creating a CustomUser model, you now have control over what happens when that model is being created or saved - and you no longer need to use a signal to trigger the creation of the Profile.

As an alternative, you can override the save method of CustomUser to create the profile there. (Or in your CustomUserManager if you’re using one, or in any of the other mechanisms available to you depending upon how you’re creating these instances.)
Or, if profiles are optional, you could wait and create one when the profile is to be edited, using the get_or_create method in the edit_profile view.

This is different than if you were relying solely upon the built-in User model. In that situation, you don’t have these options available, and so you would more likely need to use signals in that case. It’s still not, strictly speaking, necessary, but I can understand wanting to do it that way.

Side note: As a digression - recognizing that you did not ask for anything along these lines - one of your options with a custom user model is to avoid the need for a separate profile model by including that data as part of the custom user. (This isn’t an option if people can have multiple profiles on their user object.)

It’s to accept any parameters being passed by the caller. You, writing the implementation of this function, may not “know” or “control” how it’s being called. Accepting all keyword parameters, especially ones that might be defined but that you do not need to use, avoids creating an error for those calling your function with those parameters defined.

For example, the post_save signal is going to use the following parameters:

  • sender
  • instance
  • created
  • raw
  • using
  • update_fields

This means you either need to define your handler as:
def my_post_save(sender, instance, created, raw, using, update_fields)
or you can define it as:
def my_post_save(sender, **kwargs)

The first form is preferred if you’re actually doing something with those parameters. I prefer the second if you aren’t.

1 Like

Thanks for writing your perspective out with detail; it’s really helpful.

More context for clarity:

  • re: “…if profiles are optional…” — in this case, profiles aren’t optional
  • re: “…if people can have multiple profiles on their user object…” — at present, people can’t have multiple profiles, however I would like to keep the Profile and CustomUser model separate in this case

I made an attempt to do this; below is the updated code (everything else is the same except I deleted the old code that was using the post_save signal from profiles/models.py).

CustomUser model

from django.contrib.auth.models import AbstractUser, PermissionsMixin
from profiles.models import Profile


class CustomUser(AbstractUser, PermissionsMixin):
    pass

    def save(self, *args, **kwargs):
        self.create_profile()
        return super().save(*args, **kwargs)

    def create_profile(self):
        Profile.objects.create(user=CustomUser.objects.get(self.username))

In this new create_profile(), there’s a problem I think because I’m trying to get the new CustomUser object before the object is actually saved. How do I set the 1:1 user field on the Profile object that’s being created as the new CustomUser object that the save method is saving but before it’s actually saved?

I don’t know much of anything about managers yet (or what the other “mechanisms” would be in this case) and haven’t created any myself to-date. I found/read some of this Django docs Manager page which I assume is what you’re referring to here. Based on that Django doc and some searching (finding articles like this), it seems like managers are mainly used for querying, not for creating objects. Can you elaborate a little more on how managers are a potential option for creating instances? / What would be some of the “other mechanisms available”?

Note I’m asking these questions more to fill out my mental map of “things to ask about if/when it’s needed” and less in order to use in this specific example, so just high-level info and related terms I can look up on my own are definitely fine/welcome.

Lastly, re: **kwargs — I see it now; thank you for showing the either/or scenario (that made it so clear).

Can be rewritten this way:

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        self.create_profile()

You’re now creating the instance before creating the profile.

Note: As documented at Creating objects

The save() method has no return value.

There’s no need to return a value from this method.

Also -

This isn’t the proper syntax of a get call - but aside from that, it’s not necessary. The CustomUser being assigned here is self. So this can be replaced by:

I can understand the perspective - the docs to tend to lean more in that direction, but that’s not the complete picture.

The best example that I would suggest for you to look at would be the UserManager class in django.contrib.auth.models. The first method in that class is _create_user, which is a manager method used by both create_user and create_superuser. (You can see an example of create_superuser being used in the createsuperuser management command in django.contrib.auth.management.commands.) This shows a method in a manager that creates rows in a model.

Regarding “other mechanisms”, I’m thinking in general terms such as a serializer in a REST / API - type situation, or a “bulk_create” call with your own code taking a list of newly created users and creating the profile objects for them. (I didn’t really have anything specific in mind, but I didn’t want you thinking these are the only two ways of handling this.)

No worries - we’re here to help where we can.

Re: managers creating & other mechanisms - :+1: helpful, thanks and here’s a link to see _create_user, if helpful for anyone else reading this later

Re: save method/create_profile - oops on the syntax :sweat_smile: I made the below updates but am confused by an error message.

When I run tests with the updated code, the traceback seems to be saying that my CustomUser isn’t installed/doesn’t exist, but I’m not sure why this is coming up. After experimenting a bit, I discovered that if I run tests with everything the same except commenting out the two lines referring to the Profile model, then the tests do run (I put a comment next to the lines below). But I’m not sure why this is / how to fix it:

accounts/models.py

from django.contrib.auth.models import AbstractUser, PermissionsMixin

from profiles.models import Profile # if I remove this and edit the other line with a comment, python manage.py test works

class CustomUser(AbstractUser, PermissionsMixin):

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        self.create_profile()

    def create_profile(self):
        Profile.objects.create(user=self) # if I replace this with pass and remove the other line with a comment, python manage.py test works

Error message

Traceback (most recent call last):
  File "/Users/stephaniegoulet/Desktop/ProjectA/.venv/lib/python3.10/site-packages/django/apps/config.py", line 235, in get_model
    return self.models[model_name.lower()]
KeyError: 'customuser'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/stephaniegoulet/Desktop/ProjectA/.venv/lib/python3.10/site-packages/django/contrib/auth/__init__.py", line 188, in get_user_model
    return django_apps.get_model(settings.AUTH_USER_MODEL, require_ready=False)
  File "/Users/stephaniegoulet/Desktop/ProjectA/.venv/lib/python3.10/site-packages/django/apps/registry.py", line 213, in get_model
    return app_config.get_model(model_name, require_ready=require_ready)
  File "/Users/stephaniegoulet/Desktop/ProjectA/.venv/lib/python3.10/site-packages/django/apps/config.py", line 237, in get_model
    raise LookupError(
LookupError: App 'accounts' doesn't have a 'CustomUser' model.

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/stephaniegoulet/Desktop/ProjectA/manage.py", line 22, in <module>
    main()
  File "/Users/stephaniegoulet/Desktop/ProjectA/manage.py", line 18, in main
    execute_from_command_line(sys.argv)
  File "/Users/stephaniegoulet/Desktop/ProjectA/.venv/lib/python3.10/site-packages/django/core/management/__init__.py", line 442, in execute_from_command_line
    utility.execute()
  File "/Users/stephaniegoulet/Desktop/ProjectA/.venv/lib/python3.10/site-packages/django/core/management/__init__.py", line 416, in execute
    django.setup()
  File "/Users/stephaniegoulet/Desktop/ProjectA/.venv/lib/python3.10/site-packages/django/__init__.py", line 24, in setup
    apps.populate(settings.INSTALLED_APPS)
  File "/Users/stephaniegoulet/Desktop/ProjectA/.venv/lib/python3.10/site-packages/django/apps/registry.py", line 116, in populate
    app_config.import_models()
  File "/Users/stephaniegoulet/Desktop/ProjectA/.venv/lib/python3.10/site-packages/django/apps/config.py", line 269, in import_models
    self.models_module = import_module(models_module_name)
  File "/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1050, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1027, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1006, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 688, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 883, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/Users/stephaniegoulet/Desktop/ProjectA/accounts/models.py", line 3, in <module>
    from profiles.models import (
  File "/Users/stephaniegoulet/Desktop/ProjectA/profiles/models.py", line 7, in <module>
    CustomUser = get_user_model()
  File "/Users/stephaniegoulet/Desktop/ProjectA/.venv/lib/python3.10/site-packages/django/contrib/auth/__init__.py", line 194, in get_user_model
    raise ImproperlyConfigured(
django.core.exceptions.ImproperlyConfigured: AUTH_USER_MODEL refers to model 'accounts.CustomUser' that has not been installed

In what file does the definition of your Profile model reside? Please post that file.

profiles/models.py

import uuid

from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse

CustomUser = get_user_model()


class Profile(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    user = models.OneToOneField(CustomUser, on_delete=models.CASCADE) 

    def __str__(self):
        return self.user.username

My gut reaction to this is that you have a circular import situation.

Each of these two models.py files needs to import a reference from the other.

You could try moving the from profiles.models ... line to be inside the create_profile method.

Another would be to use the “string” version of the reference to CustomUser in the profile model. (See the example at get_user_model)

I moved the import statement inside create_profile

It worked :grin: thanks again

Good discussion!

I basically subscribe to Ken’s position. Signals are fine in theory but they lead to hard to follow code. Much better to keep that in-line where you can.

Just one tweak to what Ken said. I use the built-in User model all the time plus a Profile model (or several, but that’s a tangent) and go with the get-or-create option when fetching the Profile, so even though it’s not really optional, it’s always there when needed. (The Happy Path here is just a get() so there’s no cost to this.) It works very well. No need to do otherwise.

1 Like

Thanks, Carlton.

I’m glad you both mentioned get_or_create because I discovered some other tests that previously passed stopped passing after I had changed this before (they gave a unique constraint integrity error).

But when I tried using get_or_create instead of just create, all of the tests passed again.

As always, appreciate the help :grin: