Search code examples
pythondjango-modelsdjango-usersdjango-related-manager

Django: Programmatically add Groups on User save


After a user is saved, I need to make sure that its instance is associated with a group by default.

I have found two ways to achieve that:

  • Overriding the model's save() method

    models.py:

    from django.contrib.auth.models import AbstractUser, Group
    
    
    class Person(AbstractUser):
    
        def save(self, *args, **kwargs):
            super().save(*args, **kwargs)
            to_add = Group.objects.get(id=1)  # get_or_create is a better option
            instance.groups.add(to_add)
    
  • Capturing a post_save signal:

    signals.py:

    from django.conf import settings
    from django.contrib.auth.models import Group
    from django.db.models.signals import post_save
    from django.dispatch import receiver
    
    
    @receiver(
        post_save,
        sender=settings.AUTH_USER_MODEL,
    )
    def save_the_group(instance, raw, **kwargs):
        if not raw:
            to_add = Group.objects.get(id=1)  # get_or_create is a better option
            instance.groups.add(to_add)
    

Are these methods equal in achieving their goal?

Is there a better one in Django terms of "Good Practice"?


Solution

  • Update

    Acquiring a better understanding of how Django works, I see that the confusion and also the solution lie in BaseModelForm.save():

        ...
        if commit:
            # If committing, save the instance and the m2m data immediately.
            self.instance.save()
            self._save_m2m()
        ...
    

    and in BaseModelForm._save_m2m():

        ...
        if f.name in cleaned_data:
            f.save_form_data(self.instance, cleaned_data[f.name])
        ...
    

    The instance is first saved to acquire a primary key (post_save signal emmited) and then all its many to many relations are saved based on ModelForm.cleaned_data.

    If any m2m relation has been added during the post_save signal or at the Model.save() method, it will be removed or overridden from BaseModelForm._save_m2m(), depending on the content of the ModelForm.cleaned_data.

    The transaction.on_commit() -which is discussed as a solution in this asnwer later on and in a few other SO answers from which I was inspired and got downvoted- will delay the changes in the signal until BaseModelForm._save_m2m() has concluded its operations.

    Although, in some special cases the transaction.on_commit() is very useful, in this case is an overkill, not only because it is complexing the situation in an awkward manner (the most suitable signal is m2m_changed as explained here) but because avoiding signals altogether, is rather good.

    Therefore, I will try to give a solution that caters for both occasions:

    1. If the instance is saved from Django Admin (ModelForm)
    2. If the instance is saved without using a ModelForm

    models.py

    from django.contrib.auth.models import AbstractUser, Group
    
    
    class Person(AbstractUser):
       def save(self, *args, **kwargs):
            super().save(*args, **kwargs)
            if not getattr(self, 'from_modelform', False):  # This flag is created in ModelForm
                <add - remove groups logic>
    

    forms.py

    from django import forms
    from django.contrib.auth.forms import UserChangeForm
    from django.contrib.auth.models import Group
    from my_app.models import Person
    
    
    class PersonChangeForm(UserChangeForm):
        def clean(self):
            cleaned_data = super().clean()
            if self.errors:
                return
            group = cleaned_data['groups']
            to_add = Group.objects.filter(id=1)
            to_remove = Group.objects.filter(id=2)
            cleaned_data['groups'] = group.union(to_add).difference(to_remove)
            self.instance.from_modelform = True
            return cleaned_data
    
        class Meta:
            model = Person
            fields = '__all__'
    

    This will either work with:

    >>> p = Person()
    >>> p.username = 'username'
    >>> p.password = 'password'
    >>> p.save()
    

    or with:

    from django.contrib.auth.forms import UserCreationForm
    from django.contrib.auth import get_user_model
    from django.forms.models import modelform_factory
    
    user_creationform_data = {
        'username': 'george',
        'password1': '123!trettb',
        'password2': '123!trettb',
        'email': '[email protected]',
    }
    
    user_model_form = modelform_factory(
        get_user_model(),
        form=UserCreationForm,
    )
    user_creation_form = user_model_form(data=user_creationform_data)
    new_user = user_creation_form.save()
    

    Old answer

    Based on either this or that SO questions along with an article titled "How to add ManytoMany model inside a post_save signal" the solution I turned to, is to use on_commit(func, using=None):

    The function you pass in will be called immediately after a hypothetical database write made where on_commit() is called would be successfully committed.

    from django.conf import settings
    from django.contrib.auth.models import Group
    from django.db import transaction
    from django.db.models.signals import post_save
    from django.dispatch import receiver
    
    
    def on_transaction_commit(func):
        ''' Create the decorator '''
        def inner(*args, **kwargs):
            transaction.on_commit(lambda: func(*args, **kwargs))
    
        return inner
    
    
    @receiver(
        post_save,
        sender=settings.AUTH_USER_MODEL,
    )
    @on_transaction_commit
    def group_delegation(instance, raw, **kwargs):
            to_add = Group.objects.get(id=1)
            instance.groups.add(to_add)
    

    The above code does not take into account that every login causes a post_save signal.

    Digging Deeper

    A crucial point made in the relevant Django ticket is that the above code will not work if a save() call is made inside an atomic transaction together with a validation that depends on the result of the group_delegation() function.

    @transaction.atomic
    def accept_group_invite(request, group_id):
        validate_and_add_to_group(request.user, group_id)
        # The below line would always fail in your case because the
    

    on_commit # receiver wouldn't be called until exiting this function. if request.user.has_perm('group_permission'): do_something() ...

    Django docs describe in more details the constraints under which on_commit() successfully works.

    Testing

    During testing, it is crucial to use the TransactionTestCase or the @pytest.mark.django_db(transaction=True) decorator when testing with pytest.

    This is an example of how I tested this signal.