Instance version control in DRF with Django Reversion

saruar999

Saruar Star

Posted on April 22, 2023

Instance version control in DRF with Django Reversion

Instance Logging

Instance logging or Model logging is a widely used practice that stores all instance updates of a model, it ensures that each instance within that particular model has it's own version history that can be tracked as the instance undergoes several updates.

Consider a simple blog application where a blog is an instance of the blog model, when interacting with this blog instance, we retrieve the current blog's attributes, however, this blog instance might have been updated several times until it has reached its current state, in order to ensure the completeness of the instance's data, we would need to save all the past states of the instance since its creation, it would also make sense to include some metadata for each logged state, including the system user that has performed the update, and a date-time indicating when this update was performed.

Django Reversion

Django Reversion is a powerful module that allows us to keep track of an instance's version history, it also provides several operations that can be used to create "version control" like functionality for each and every instance of any model, including reverting an instance to an earlier state.

Tutorial

In this article we will create a simple blog application with DRF and integrate Django Reversion, we will create corresponding views which will be responsible retrieving an instance's version history and reverting the instance to an earlier state.

First, let's create a simple DRF application, we will start by defining a simple Blog model inside our models.py file of our blog app.

blog/models.py

from django.db import models


class Blog(models.Model):

    title = models.CharField(max_length=50, help_text="A title for the blog instance.")

    description = models.CharField(
        max_length=150, null=True, help_text="An optional short description of the blog instance."
    )

    content = models.TextField(help_text="The blog instance's content body.")
Enter fullscreen mode Exit fullscreen mode

Next, we will create a serializer for our blog app that will be used for simple CRUD operations, for this application, here we will use DRF's ModelSerializer, this will be done inside our serializers.py file.

blog/serializers.py

from rest_framework import serializers
from blog.models import Blog


class BlogSerializer(serializers.ModelSerializer):

    class Meta:
        model = Blog
        fields = '__all__'
Enter fullscreen mode Exit fullscreen mode

Now let's define a view inside our views.py file, that will handle different operations on the Blog model, in this example we will use DRF's GenericViewSet and add Mixins based on our needs.

blog/views.py

from rest_framework.viewsets import GenericViewSet
from rest_framework.mixins import CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, ListModelMixin
from blog.models import Blog
from blog.serializers import BlogSerializer


class BlogViewSet(UpdateModelMixin, RetrieveModelMixin, ListModelMixin, CreateModelMixin, GenericViewSet):

    queryset = Blog.objects.all()
    serializer_class = BlogSerializer
Enter fullscreen mode Exit fullscreen mode

After that we just need to register our ViewSet inside our urls.py file.

blog/urls.py

from .views import BlogViewSet
from rest_framework.routers import DefaultRouter

router = DefaultRouter()
router.register('blog', BlogViewSet, basename='blog')
url_patterns = router.urls
Enter fullscreen mode Exit fullscreen mode

Make sure to import and add all the urlpatterns of the blog app inside the main urls.py file as well.

djangoProject/urls.py

from blog.urls import url_patterns as blog_url_patterns

urlpatterns = []
urlpatterns += blog_url_patterns
Enter fullscreen mode Exit fullscreen mode

Now that everything is set up, we are ready to implement the Django Reversion module into our app.

First, we have to install the Django Reversion module.

  1. Install the Django Reversion module with the following command.
    pip install django-reversion

  2. Add reversion to INSTALLED_APPS of your settings.py

  3. Migrate your application with python manage.py migrate

Now that we have installed Django Reversion, we are going to register our Blog model for reversion.

  1. Inside our models.py file, import the register decorator from the reversion module.

  2. Add the decorator on top of our model class.

The updated models.py file should look like this.

blog/models.py

from django.db import models
from reversion import register


@register()
class Blog(models.Model):

    title = models.CharField(max_length=50, help_text="A title for the blog instance.")

    description = models.CharField(
        max_length=150, null=True, help_text="An optional short description of the blog instance."
    )

    content = models.TextField(help_text="The blog instance's content body.")
Enter fullscreen mode Exit fullscreen mode

Next, we are going to implement the reversion module into our viewset, there are many ways for us to do that, including using the context API manually, using the reversion decorator, or using the reversion view mixin, in this tutorial I will be using the mixin.

The RevisionMixin will automatically create revisions for each update inside our viewset class, which is great, but we want to be able to retrieve the version history of each instance, also we want to be able to revert the instance to an older version.

Therefore we will be overriding the RevisionMixin, and adding two actions to it, one for retrieving a list of older versions, and one for reverting the instance to an older version.

Let's create a customized version of the RevisionMixin, that can be reused for any viewset, by simply adding it to the list of inherited classes.

  1. Create a new directory called revision inside our blog directory.

  2. Add a views.py file to the newly created directory.

  3. Inside this file, we import the RevisionMixin from reversion.views.

  4. Create a new class, that is going to be our customized version of the mixin, I'm going to name it CustomRevisionMixin.

  5. Add the reversion module's RevisionMixin inside the CustomRevisionMixin parameters, in order to inherit the current reversion module's functionality.

blog/revision/views.py

from reversion.views import RevisionMixin
class CustomRevisionMixin(RevisionMixin):
    pass
Enter fullscreen mode Exit fullscreen mode

Now we can add our own functionality, we are going to create the two extra actions here.

Import the action decorator from rest_framework.decorators.

from rest_framework.decorators import action
Enter fullscreen mode Exit fullscreen mode

Create an action for retrieving the instance's logs, with the methods parameter set to ['GET'], and detail parameter set to True.

@action(methods=['GET'], detail=True)
def instance_logs(self, request, **kwargs):
    pass
Enter fullscreen mode Exit fullscreen mode

Next, let's create a function that will retrieve the current instance's version history, we will call the function get_versions.

  1. Import the Version model from reversion.models.

  2. Import the RegistrationError exception class from reversion.errors.

  3. Retrieve the current instance by calling the viewset's get_object method.

  4. Call the get_for_object method of the Version model's manager, since in the docs it is stated that this method might throw a RegistrationError if called for an unregistered model, we are going to wrap it inside a try except clause, and throw a standard APIException (from rest_framework.exceptions) for this case.

  5. Filter the queryset by taking out the last version object, since it's ordered in a descending matter, this object will be the current instance's version, which we won't need in our version history.

  6. Return the instance and the version queryset as a tuple.

def get_versions(self):
    instance = self.get_object()
    try:
        versions = Version.objects.get_for_object(instance)
    except RegistrationError:
        raise APIException(detail='model has not been registered for revision.')
    current_version = versions[0]
    versions = versions.exclude(pk=current_version.id)
    return instance, versions
Enter fullscreen mode Exit fullscreen mode

The next step is to create a serializer for the Version model, as usual, we will be using a ModelSerializer to achieve this.

  1. Create a serializers.py file inside the revision directory.

  2. Import serializers from rest_framework, and the Version model from reversion.models.

  3. Define a serializer with the model set to Version and define fields based on the available data on the Version model, for reference check the source code or documentation of the Django Reversion module, for this tutorial, I will be adding the following four fields:

  • version, primary key of the version instance.
  • updated_at, creation date of the version instance, which simultaneously indicates when the instance was updated.
  • updated_by, user id of the user that has performed the update, if authentication is implemented.
  • instance, a JSON object representation of the instance after the update.

Your serializer class should look like this.

blog/revision/serializers.py

from rest_framework import serializers
from reversion.models import Version


class RevisionSerializer(serializers.ModelSerializer):
    version = serializers.PrimaryKeyRelatedField(read_only=True, source='id')
    updated_at = serializers.DateTimeField(read_only=True, source='revision.date_created')
    updated_by = serializers.PrimaryKeyRelatedField(read_only=True, source='revision.user')
    instance = serializers.JSONField(read_only=True, source='field_dict')

    class Meta:
        model = Version
        fields = ['version', 'updated_at', 'updated_by', 'instance']
Enter fullscreen mode Exit fullscreen mode

Now we finalize our instance_logs action.

  1. Import the newly created RevisionSerializer into our views.py file.

  2. Import Response from rest_framework.response.

  3. Call the get_versions function.

  4. Pass the versions as the serializer instance, don't forget to add many=True kwarg.

  5. Return the serialized data.

@action(methods=['GET'], detail=True)
def instance_logs(self, request, **kwargs):
    instance, versions = self.get_versions()
    serializer = RevisionSerializer(versions, many=True)
    return Response(serializer.data)
Enter fullscreen mode Exit fullscreen mode

Now we define our second action, which will handle the reversion of an instance to an older version.

  1. Create a revert_instance function and add the action decorator on top of it.

  2. The method for this action will be POST, detail should be set to True.

  3. Call the get_versions function.

  4. Define a second serializer called RevisionRevertSerializer which overrides the RevisionSerializer inside our serializers.py file.

  5. Override the __init__ function, to dynamically define the version field, this is needed because we want to pass the versions queryset as a context, in order to prevent the user from sending invalid version ids.

  6. Override the serializer's update function to extract the version instance from the validated_data.

  7. Call the revert method of the revision instance API, first, access the revision instance from the extracted version instance with dot notation, then, call the revert method.

  8. Since revert might throw a RevertError in case the database schema has been changed, we are going to wrap it inside a try except clause and throw a custom message for handling this case.

  9. Import the RevisionRevertSerializer serializer inside out views.py file.

  10. Validate and save the serializer, then return a success message as response.

The final code should look like this.

blog/revision/views.py

from reversion.models import Version
from reversion.views import RevisionMixin
from reversion.errors import RegistrationError
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.exceptions import APIException
from blog.revision.serializers import RevisionSerializer, RevisionRevertSerializer


class CustomRevisionMixin(RevisionMixin):

    def get_versions(self):
        instance = self.get_object()
        try:
            versions = Version.objects.get_for_object(instance)
        except RegistrationError:
            raise APIException(detail='model has not been registered for revision.')
        current_version = versions[0]
        versions = versions.exclude(pk=current_version.id)
        return instance, versions

    @action(methods=['GET'], detail=True)
    def instance_logs(self, request, **kwargs):
        instance, versions = self.get_versions()
        serializer = RevisionSerializer(versions, many=True)
        return Response(serializer.data)

    @action(methods=['POST'], detail=True)
    def revert_instance(self, request, **kwargs):
        instance, versions = self.get_versions()
        serializer = RevisionRevertSerializer(instance, data=request.data, context={'versions': versions})
        serializer.is_valid(raise_exception=True)
        serializer.save()
        version = serializer.validated_data.get('version')
        return Response({'message': 'instance reverted to version %d.' % version.id})
Enter fullscreen mode Exit fullscreen mode

blog/revision/serializers.py

from rest_framework import serializers
from rest_framework.exceptions import APIException
from reversion.models import Version
from reversion.errors import RevertError


class RevisionSerializer(serializers.ModelSerializer):
    version = serializers.PrimaryKeyRelatedField(read_only=True, source='id')
    updated_at = serializers.DateTimeField(read_only=True, source='revision.date_created')
    updated_by = serializers.PrimaryKeyRelatedField(read_only=True, source='revision.user')
    instance = serializers.JSONField(read_only=True, source='field_dict')

    class Meta:
        model = Version
        fields = ['version', 'updated_at', 'updated_by', 'instance']


class RevisionRevertSerializer(RevisionSerializer):

    def __init__(self, *args, **kwargs):
        super(RevisionSerializer, self).__init__(*args, **kwargs)
        version_queryset = self.context.get('versions')
        self.fields['version'] = serializers.PrimaryKeyRelatedField(write_only=True, queryset=version_queryset)

    def update(self, instance, validated_data):
        version = validated_data['version']
        try:
            version.revision.revert()
            return validated_data
        except RevertError:
            raise APIException(detail='can not revert instance.')

    class Meta(RevisionSerializer.Meta):
        fields = ['version']
Enter fullscreen mode Exit fullscreen mode

Finally, we can add this custom revision mixin to any viewset and it will automatically create revisions for any instance update, and receive both custom actions, instance_logs and revert_instance.

  1. Inside our blog/views.py file import the CustomRevisionMixin.

  2. Add the mixin to be inherited by the viewset.

blog/views.py

from rest_framework.viewsets import GenericViewSet
from rest_framework.mixins import CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, ListModelMixin
from blog.models import Blog
from blog.serializers import BlogSerializer
from blog.revision.views import CustomRevisionMixin


class BlogViewSet(CustomRevisionMixin, UpdateModelMixin, RetrieveModelMixin, ListModelMixin, CreateModelMixin, GenericViewSet):

    queryset = Blog.objects.all()
    serializer_class = BlogSerializer
Enter fullscreen mode Exit fullscreen mode

All done! Now we can test our code.

First, let's create a blog instance.

METHOD: POST 
URL: /blog/
BODY: {
    "title": "version 1 title",
    "description": "version 1 description",
    "content": "version 1 content"
}
Enter fullscreen mode Exit fullscreen mode

Next, let's update our created instance.

METHOD: PATCH
URL: /blog/1/
BODY: {
    "title": "version 2 title",
    "description": "version 2 description",
    "content": "version 2 content"
}
Enter fullscreen mode Exit fullscreen mode

After that, we are going to check the instance logs.

METHOD: GET
URL: /blog/1/instance_logs/
Enter fullscreen mode Exit fullscreen mode

Our response should contain the older version (prior to update).

[
    {
        "version": 1,
        "updated_at": "2023-04-22T22:55:29.235430Z",
        "updated_by": null,
        "instance": {
            "id": 1,
            "title": "version 1 title",
            "description": "version 1 description",
            "content": "version 1 content"
        }
    }
]
Enter fullscreen mode Exit fullscreen mode

Here we can see that the version id is 1, so we have to provide {"version": 1}, to the revert_instance endpoint in order to revert it.

METHOD: POST
URL: /blog/1/revert_instance/
BODY: {
    "version": 1
}
Enter fullscreen mode Exit fullscreen mode

Now let's check if the instance has been reverted by retrieving the instance.

METHOD: GET
URL: /blog/1/
Enter fullscreen mode Exit fullscreen mode

As expected, the instance has been successfully reverted.

{
    "id": 1,
    "title": "version 1 title",
    "description": "version 1 description",
    "content": "version 1 content"
}
Enter fullscreen mode Exit fullscreen mode

And another version has been added to the instance's logs.

[
    {
        "version": 2,
        "updated_at": "2023-04-22T23:00:44.489784Z",
        "updated_by": null,
        "instance": {
            "id": 1,
            "title": "version 2 title",
            "description": "version 2 description",
            "content": "version 2 content"
        }
    },
    {
        "version": 1,
        "updated_at": "2023-04-22T22:55:29.235430Z",
        "updated_by": null,
        "instance": {
            "id": 1,
            "title": "version 1 title",
            "description": "version 1 description",
            "content": "version 1 content"
        }
    }
]
Enter fullscreen mode Exit fullscreen mode

That's all for this article, I really hope it was helpful.

Source Code: https://github.com/saruar999/django-reversion-with-drf

💖 💪 🙅 🚩
saruar999
Saruar Star

Posted on April 22, 2023

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

Sign up to receive the latest update from our blog.

Related