Updating A Many-To-Many Relationship In Django

danielcoker

Daniel Coker

Posted on February 24, 2022

Updating A Many-To-Many Relationship In Django

There is this feeling you get when you're stuck on a problem and you search Google for an answer but don't find it. It’s even more frustrating when it is a seemingly common problem. That was me recently when I was attempting to update a many-to-many relationship in Django

When developing a Django app, many-to-many relationship use cases will arise at various points. Blog posts and tags, books and authors, and so on are examples of many-to-many relationships. A many-to-many relationship is one where multiple records in one table can be related to multiple records of another table. A book, for example, can have multiple authors, and an author can write multiple books.

The relationship between blog posts and tags will be used in this article. Consider a blog app with multiple posts. Multiple tags can be attached to a post, and a tag can belong to multiple posts. Adding tags to the post was simple for me; I simply needed to use the add method. Where I struggled was in keeping their relationship up to date. I'd like to be able to remove any tags associated with the post that isn't in the list of tags sent with the update post request and then create new associations with new tags in the list of tags.

There are several approaches that can be taken to accomplish this. One approach is to delete all of the tags associated with the post and then re-add them. However, in this article, we'll use the set method to update their relationship.

To follow this article, I'm assuming you have a Django project with a Django Rest Framework installed. Let's get started.


Setting Up Models and Migrations

To begin, we'll need to create the two models that will be required for this project—The Tag and the Post models. The Tag model only contains the tag's title. While the Post model contains information about the post. The Tag and the Post have a many-to-many relationship.

# blog/models.py

from django.db import models

class Tag(models.Model):
    title = models.CharField(max_length=150)

    def __str__(self):
        return self.title

class Post(models.Model):
    title = models.CharField(max_length=150)
    body = models.TextField()
    tags = models.ManyToManyField(Tag)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title
Enter fullscreen mode Exit fullscreen mode

Run the following commands to create and run the migrations.

>>> python manage.py makemigrations
>>> python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

Adding Serializers

We'll add serializers for the models we've created.

# blog/serializers.py

from rest_framework import serializers

from .models import Post, Tag

class TagSerializer(serializers.ModelSerializer):
    class Meta:
        model = Tag
        fields = ('id', 'title',)

class PostSerializer(serializers.ModelSerializer):
    tags = TagSerializer(many=True, read_only=True)

    class Meta:
        model = Post
        fields = ('id', 'title', 'body', 'tags', 'created_at', 'updated_at',)
Enter fullscreen mode Exit fullscreen mode

Creating A Post With Tags

Before we go on to add the view function to create a post, we will need to add tags to the database using the shell. Run:

>>> python manage.py shell
>>> from blog.models import Tag
>>> Tag.objects.create(title='Django')
>>> Tag.objects.create (title='Database')
Enter fullscreen mode Exit fullscreen mode

To make a post with tags, we must first create a new view function called create_post.

# blog/views.py

@api_view(['POST'])
def create_post(request):
    ...
Enter fullscreen mode Exit fullscreen mode

We're making a post with tags in this view function. We begin by passing the request data to the PostSerializer and checking to see if it is valid.

serializer = PostSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
Enter fullscreen mode Exit fullscreen mode

Once the data is set and valid, we save the post.

post = serializer.save()
Enter fullscreen mode Exit fullscreen mode

The following step is to include the tags in the post. Before they can be related in a many-to-many relationship, both records must exist in the database. That is why we had to first create the tags and post.

By looping through the tag ids in the request data, we add the tag to the post. To avoid errors, we check to see if the tag already exists in the database, and then we add the tag to the post using the add method. We raise a NotFound exception if a tag does not exist in the database.

for tag_id in request.data.get('tags'):
    try:
        tag = Tag.objects.get(id=tag_id)
        post.tags.add(tag)
    except Tag.DoesNotExist:
        raise NotFound()
Enter fullscreen mode Exit fullscreen mode

After adding the tags to the post in the above snippet, we will return to the client the response containing the newly created post.

return Response(data=serializer.data, status=status.HTTP_201_CREATED)
Enter fullscreen mode Exit fullscreen mode

Updating A Post With Tags

This is the primary purpose of this article—updating the many to many relationship between the post and tags. To update the post along with the tags, we need to add another view function called update_post.

# blog/views.py

@api_view(['PUT'])
def update_post(request, pk=None):
    ...
Enter fullscreen mode Exit fullscreen mode

In this view function, we begin by retrieving the post using the primary key. If the post does not exist, we raise a NotFound exception.

try:
    post = Post.objects.get(id=pk)
except Post.DoesNotExist:
    raise NotFound()
Enter fullscreen mode Exit fullscreen mode

The post and request data are then passed to the PostSerializer, and the request data is validated. We save the post to the database if it is valid.

serializer = PostSerializer(post, data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
Enter fullscreen mode Exit fullscreen mode

We'll need to use the set method to sync the relationship. However, the set method accepts a list of objects. As a result, we must first generate a list of tag objects and then pass it to the set method. To generate this list of objects, we'll loop through the tag ids in the request data, just like we did when we made the post. The tag is then checked to see if it exists in the database (to avoid any errors). If the tag already exists, we add it to the tags list. We raise a NotFound exception if a tag does not exist in the database.

tags = []

for tag_id in request.data.get('tags'):
    try:
        tag = Tag.objects.get(id=tag_id)
        tags.append(tag)
    except:
        raise NotFound()
Enter fullscreen mode Exit fullscreen mode

We can now set the posts after we've finished creating the list of tags. The set method deletes the association for tags that are no longer in the list and creates new associations for tags that are added to the list.

post.tags.set(tags)
Enter fullscreen mode Exit fullscreen mode

After using the set method to sync the posts and tags. We can send the client the response containing the updated post.

return Response(data=serializer.data, status=status.HTTP_200_OK)
Enter fullscreen mode Exit fullscreen mode

Add Tests

Let's add some test cases to ensure that everything works as expected. First, include the required imports, define the test class, and add a setup to create three tags for us when each test case is run.

# blog/tests.py

import json

from django.urls import reverse

from rest_framework import status
from rest_framework.test import APITestCase

from .models import Post, Tag

class PostTest(APITestCase):
    def setUp(self):
        self.tag1 = Tag.objects.create(title='Django')
        self.tag2 = Tag.objects.create(title='Database')
        self.tag3 = Tag.objects.create(title='Relationship')
Enter fullscreen mode Exit fullscreen mode

This test case asserts that the create post endpoint is able to create a new post.

def test_can_create_post(self):
    url = reverse('posts-create')
    data = {
        'title': 'Test Post',
        'body': 'The body of the test post.',
        'tags': [self.tag1.id, self.tag2.id],
     }

     response = self.client.post(url, data, format='json')

     self.assertEqual(response.status_code, status.HTTP_201_CREATED)
Enter fullscreen mode Exit fullscreen mode

This test case ensures that the update post endpoint updates the post details while also establishing the proper relationship between the tags and the updated post.

def test_can_update_post(self):
    post = Post.objects.create(
    title='Test Post', body='The body of the test post.')
    post.tags.set([self.tag1, self.tag3])

    url = reverse('posts-update', kwargs={'pk': post.id})
    data = {
        'title': 'Updated Test Post',
        'body': 'The body of the updated test post.',
        'tags': [self.tag2.id, self.tag3.id],
     }

     response = self.client.put(url, data, format='json')
     response_data = json.loads(response.content)

     post = Post.objects.get(id=post.id)

     self.assertEqual(response.status_code, status.HTTP_200_OK)
     self.assertEqual(response_data['title'], post.title)
     self.assertEqual(response_data['body'], post.body)

     self.assertEqual(len(response_data['tags']), 2)
     self.assertEqual(response_data['tags'][0]['id'], self.tag2.id)
     self.assertEqual(response_data['tags'][1]['id'], self.tag3.id)
Enter fullscreen mode Exit fullscreen mode

Run python manage.py test to execute tests.


Conclusion

In this article, we discussed what a many-to-many relationship is and how to update a many-to-many relationship using Django. We also added tests to ensure that the implementation works properly. The complete code is available in the GitHub repo here. View the API documentation for sample requests here.

💖 💪 🙅 🚩
danielcoker
Daniel Coker

Posted on February 24, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related