Proper way of creating custom relational serializers

Hey guys it’s my first time using Django and I’m working on a with the following relationship:

User > UserWorkspace < Workspace

So every user that is created is associated with a workspace and that user can only be associated with 1 Workspace. The only person allowed to create users and workspaces is the superuser, and he has an interface to do so.

I’m working on the API for such a platform, so I need to create an endpoint that will receive the workspace_name and a list of users to create said relationship. I managed to get it working but I feel like it’s very hacky so I would like to get some input on things that I could improve.

UserSerializer

from rest_framework import serializers
from rest_framework.validators import UniqueValidator
from rest_framework.authtoken.models import Token

from trends.models.user import UserModel


class UserSerializer(serializers.ModelSerializer):
    email = serializers.CharField(
        validators=[
            UniqueValidator(
                queryset=UserModel.objects.all(),
                message="userAlreadyExists",
            )
        ],
    )
    password = serializers.CharField(write_only=True)
    token_key = serializers.ReadOnlyField()

    def create(self, validated_data):
        user = UserModel.objects.create(**validated_data)
        Token.objects.create(user=user)
        user.set_password(validated_data["password"])
        user.save()

        return user

    class Meta:
        model = UserModel
        fields = (
            "id",
            "email",
            "first_name",
            "last_name",
            "role",
            "password",
            "token_key",
        )


class UserListSerializer(UserSerializer):
    workspace_role = serializers.CharField(write_only=True)

    class Meta(UserSerializer.Meta):
        fields = UserSerializer.Meta.fields + ("workspace_role",)

UserWorkspaceSerializer

class UserWorkspaceSerializer(serializers.ModelSerializer):

    name = serializers.CharField()
    users = UserListSerializer(many=True, allow_empty=False)

    def create(self, validated_data):
        users = []
        workspace = WorkspaceModel.objects.create(name=validated_data["name"])

        for user_data in validated_data["users"]:
            workspace_role = user_data.pop("workspace_role")

            user_serializer = UserSerializer(data=user_data)
            user_serializer.is_valid(raise_exception=True)

            workspace.save()

            user = user_serializer.save()

            user_workspace = UserWorkspaceModel.objects.create(
                user=user, workspace=workspace, role=workspace_role
            )

            user_workspace.save()
            users.append(user)

        self.name = validated_data["name"]
        self.users = users

        return self

    class Meta:
        model = UserWorkspaceModel
        fields = ["name", "users"]

WorkspaceView

class WorkspaceView(mixins.CreateModelMixin, viewsets.GenericViewSet):

    permission_classes = [IsAuthenticated, IsAdminUser]
    authentication_classes = [TokenAuthentication]
    serializer_class = UserWorkspaceSerializer

    def create(self, request):
        serializer = UserWorkspaceSerializer(data=request.data)

        if serializer.is_valid():
            serializer.save()

            return Response(serializer.data)

        return Response(serializer.errors)

I don’t know if you guys also need the models I’ll skip them since this post is already very long but I can add later if you guys want.

Key Changes IMO:

  • Transactions: Using @transaction.atomic to ensure that all db operations are rolledback on failure.
  • Validation and Error Handling: validate method can be customized to add any specific validations that you might need.
  • Optimized User Creation: Get rid of redundant/unnecessary save calls.
  • Field Handling: Simplifying how you reference fields in your nested serializers for clarity.
UserModel = get_user_model()

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = UserModel
        fields = ('username', 'email', 'password')  # No idea, adjust fields here

class UserWorkspaceSerializer(serializers.ModelSerializer):
    name = serializers.CharField(source='workspace.name')
    users = UserSerializer(many=True)

    class Meta:
        model = UserWorkspaceModel
        fields = ["name", "users"]

    def validate(self, data):
        # any custom validation here
        return data

    @transaction.atomic
    def create(self, validated_data):
        workspace_data = validated_data.pop('workspace')
        workspace_name = workspace_data.get('name')
        workspace = WorkspaceModel.objects.create(name=workspace_name)
        
        users_data = validated_data.pop('users')
        users = []
        for user_data in users_data:
           # Default to None and maybe raise something, dunno if that's allowed by your model ? 
            role = user_data.pop('workspace_role', None)  
            user_serializer = UserSerializer(data=user_data)
            user_serializer.is_valid(raise_exception=True)
            user = user_serializer.save()

            user_workspace = UserWorkspaceModel.objects.create(
                user=user, workspace=workspace, role=role
            )
            users.append(user)

        # assigning just to mimic your original code's returns
        # not actually necessary
        self.instance = {'workspace': workspace, 'users': users}
        return self.instance
1 Like

Thank you for the help!

I learned a lot from it, just one small detail that I think you might have missed.

My UserModel and Serializer do not have a workspace_role field, but since the user_workspace model requires that field I created a new serializer called UserListSerializer that inherits from the UserSerializer and adds this field.

By doing that I managed to get things running and have a validation for that field, but I don’t know if this is the correct approach. All I wanted was to receive a UserList with this extra field for each User.

Was my approach correct? Would there be a better way of solving that?

short answer:
Yes, your approach is correct, there is nothing “wrong” with this. As long as it works it is correct.

a better solution will require knowledge about the application, but if I had to guess:

  • avoid cramming logic into a single view, or in this case, serializer, and always try to keep your endpoint simple with a single purpose for instance you can split creating the workspace into one endpoint and creating/adding a user to it into another endpoint.
  • building on the previous point, you could make use of generics like: from django.views.generic.edit import CreateView so you won’t have to write that much code for doing typical things usually covered by Django…
  • for working with the database/user-input or any other dependencies outside the app, in real-life scenarios, always plan for things to go wrong AND handle it…
1 Like