Django ManyToMany with extra fields not working

Hi! How are you guys? I’m kinda new here, but I came to see if anyone could help me with something i’m struggling with… My problem is very simple. I’ll describe the scenario: My intermediate model that joins User and Permission models:

class UsersPermissions(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='ID Usuário')
    permission = models.ForeignKey(Permission, on_delete=models.CASCADE, verbose_name='ID Permissão')
    client = models.ForeignKey(Client, on_delete=models.CASCADE, verbose_name='ID Cliente')


    class Meta:
        db_table = 'users_permissions'
        constraints = [
            UniqueConstraint(fields=['user', 'permission', 'client'], name='unique_user_permission_client')
        ]

Just it. I just included a third foreign key to link with the Client model. The code that simulates the problem:

u = User.objects.get(pk=1)
p = Permission.objects.get(pk=56)
c = Client.objects.get(pk=2)
res = u.permissions.add(p, through_defaults={'client': c})
u.save()

Scenario:

  • I try to create a UsersPermissions register on database, where user_id = 1, permission_id = 56 and client_id = 2. The register is created successfully.

  • Than, I try to create a UsersPermissions register again, but with a different client_id. Example: user_id = 1, permission_id = 56 and client_id = 1. The register is not created, and no error or messages show up.

Thats it. This is the error. I can’t find a solution to this, as it is so simple and I get no errors. Notes:

  • Is valid to remember that my constraint allow the duplicity of user_id and permission_id. It just locks the duplicity of the 3 foreign keys.
  • I have no more rules beyond that scenario. You can replicate it at your own code if you want. I’m using the through prop on my User model to link to the UsersPermissions model.

You still may want to check your database directly to see if there’s a constraint at the database level on the pair of (user, permission).

I’ve checked it. If I execute raw SQL it works perfectly. The problem just occours when I use the Django ORM. The strange thing is that no errors or messages appears. I just can’t go anywhere…

I’m guessing you’re using a custom User model, with a ManyToManyField defined to Permission? Can you post that model?

It might be instructive to examine the actual queries being issued.

At a minimum, you could rewrite the statement to act on the UsersPermissions model directly rather than trying to do it through the User model.

UsersPermissions.objects.update_or_create(
  user=u, permission=p, defaults={'client': c}
)

Sure! Here are the models:

User model:

class User(models.Model):
    USER_STATUS_CHOICES = [
        ('A', 'Ativo'),
        ('I', 'Inativo'),
        ('P', 'Pendente'),
        ('B', 'Banido')
    ]

    applications = models.ManyToManyField(Application, verbose_name='Aplicações', through='UsersApplications')
    clients = models.ManyToManyField(Client, verbose_name='Aplicações', through='UsersClients')
    email = models.CharField(verbose_name='Email', unique=True, max_length=160)
    password = models.CharField(verbose_name='Senha', max_length=120, null=True, blank=True)
    first_name = models.CharField(verbose_name='Primeiro nome', max_length=50)
    last_name = models.CharField(verbose_name='Último nome', max_length=50)
    is_internal_user = models.BooleanField(verbose_name='É um colaborador interno', default=False)
    phone = models.CharField(verbose_name='Telefone', max_length=13, null=True)
    document = models.CharField(verbose_name='Documento CPF/CNPJ', max_length=14, unique=True, null=True)
    status = models.CharField(verbose_name='Status', max_length=3, choices=USER_STATUS_CHOICES)
    permissions = models.ManyToManyField(Permission, verbose_name='Permissões', blank=True, through='UsersPermissions')
    permission_groups = models.ManyToManyField(PermissionGroup, verbose_name='Grupos', blank=True)
    last_login = models.DateTimeField(verbose_name='Último login', blank=True, null=True, default=None)
    password_redefined = models.BooleanField(verbose_name='Usuário já redefiniu sua senha', default=False)
    created_at = models.DateTimeField(verbose_name='Criado em', auto_now_add=True)


    class Meta:
        db_table = 'users'
        ordering = ('-created_at',)
        

    def __str__(self):
        return self.email

Permissions model:

class Permission(models.Model):
    name = models.CharField(verbose_name='Título', max_length=70)
    description = models.TextField(verbose_name='Descrição', blank=True, default='')
    is_administrative = models.BooleanField(verbose_name='Permissão interna no servidor', default=False)
    code = models.CharField(verbose_name='Código', max_length=60)
    is_active = models.BooleanField(verbose_name='Ativo', default=True)
    view = models.ForeignKey(View, verbose_name='Tela', on_delete=models.CASCADE)


    class Meta:
        db_table = 'permissions'
        constraints = [
            models.UniqueConstraint(
                fields=['code', 'view'],
                name='unique_view_code'
            )
        ]
        

    def __str__(self):
        return self.name

And yes, despite the problem I was facing, I found some way to get around it:

user = User.objects.get(pk=user_id)
client = Client.objects.get(pk=client_id)
permissions = Permission.objects.filter(pk__in=permissions_to_add_ids)

users_permissions = [UsersPermissions(user=user, permission=permission, client=client) for permission in permissions]
UsersPermissions.objects.bulk_create(users_permissions)

Not the most performant way, but it works. I just can’t understand the behavior of this add() method using the through_defaults, as it should work according to the documentation, or even throw some error message.

I’m not sure why you would think that is any less performant. I don’t see how this is going to create more database activity than the u.permissions.add() process would.

I agree, which is why I would recommend looking at the actual queries being generated and sent to the database for it.