Search code examples
djangodjango-modelsdjango-rest-frameworkdjango-rest-viewsets

Best method to create an object with many to many field using Django RF API


I have started a project with an idea that's more than I know I can handle but hey... some of us learn the dumb way right?

Currently have 2 models: User model that uses the AbstractUser class and then I have a Role model that provides many roles that may change over time so I need it to be dynamic.

This is what my api/models.py looks like:

from django.db import models
from django.contrib.auth.models import AbstractUser
from django import forms    

class Role(models.Model):
    '''
    The Role entries are managed by the system,
    automatically created via a Django data migration.
    '''
    CABLER = 1
    CLIENT = 2
    ENGINEER = 3
    PROJECTMANAGER = 4
    SALES = 5
    PARTNER = 6
    ADMIN = 6

    ROLE_CHOICES = (
        (CABLER, 'cabler'),
        (CLIENT, 'client'),
        (ENGINEER, 'engineer'),
        (PROJECTMANAGER, 'project manager'),
        (SALES, 'sales'),
        (PARTNER, 'partner'),
        (ADMIN, 'admin'),

    )

    id = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, primary_key=True)

    def __str__(self):
        return self.get_id_display()   

class User(AbstractUser):
    roles = models.ManyToManyField(Role, related_name='roles')

I'm running into several problems but currently when I use postman or The DRF API views I can't create a user with roles or without roles.

Attempting with roles:

Direct assignment to the forward side of a many-to-many set is prohibited. Use roles.set() instead.

Attempting without roles:

{
    "roles": [
        "This list may not be empty."
    ]
}

Now, I know I have to first create the User and then assign the role, however, I cannot seem to find a way to do it through the API.

Here's my UserSerializer class:

class UserSerializer(serializers.ModelSerializer):
    # roles = serializers.CharField(source='get_roles_display', )
    # roles = RoleSerializer(many=True, read_only=False, )

    class Meta:
        model = User
        fields = ('url', 'username', 'password', 'email', 'roles')

        extra_kwargs = {'password': {'write_only': True}}

    def create(self, validated_data):
        password = validated_data.pop('password')
        instance = User(**validated_data)
        if password is not None:
            instance.set_password(password)
            instance.save()

        return instance

I have seen recommendations to create a form.py file and add a save method but I'm not certain that it applies as this will be strictly an API as my backend and will be implementing AngularJS for my front end.

Also any time I try to save or update the user under the serializer User.roles.id returns a User does not have an attribute named roles...

So I'm hoping someone may be able to help. Thanks.


Solution

  • First and unrelated to your question , your roles m2m field should be updated to:

    roles = models.ManyToManyField(Role, related_name='users')
    

    The related_name is how you reference the current model (User) from the model passed into ManyToManyField (Role). With my suggestion you'd write Role.objects.first().users rather than Role.objects.first().roles.

    Secondly, if it's possible I'd recommend passing the roles to create/update a user as a list of primary keys. Even if this means creating a second serializer property that's only for writes. For example:

    class UserSerializer(serializers.ModelSerializer):
        roles = RoleSerializer(many=True, read_only=True)
        role_ids = serializers.PrimaryKeyRelatedField(
            queryset=Role.objects.all(),
            many=True, write_only=True)
    
        class Meta:
            model = User
            fields = ('url', 'username', 'password', 'email', 'roles', 'role_ids')
            extra_kwargs = {'password': {'write_only': True}}
    
        def create(self, validated_data):
            password = validated_data.pop('password')
            # Allow the serializer to handle the creation. Don't do it yourself.
            instance = super().create(**validated_data)
            if password is not None:
                instance.set_password(password)
                instance.save()
    
            return instance
    

    When your API spits out users it should look like:

    {
        'url': '...',
        'username': '...',
        'email': '...',
        'roles': [{'id': 1, 'name': 'client'}],
    }
    

    When you POST to it you should use:

    {
        'url': '...',
        'username': '...',
        'email': '...',
        'password': '...',
        'role_ids': [1, 2],
    }
    

    As an aside, the reason you were getting the error User does not have an attribute named roles. is because you can't pass roles into User's constructor. User.roles needs to be set after you have an instance. You should have done:

    user = User(**validated_data)
    user.roles.set(roles) # If you need to re-write the entire list.
    

    Although off the top of my head, I'm not sure it would work. You might have to save it beforehand. That's why I suggest you use super().create instead.