Django: Programmatically add Groups on User save

2019-04-13 21:02发布

问题:

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"?

回答1:

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.

This is an overkill, not only because it is complexing the situation in an awkward manner 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)
        try:
            if not self.from_modelform:  # This flag is created in ModelForm
                <add - remove groups logic>
        except AttributeError:
            pass

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@yo.gr',
}

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.