Instance version control in DRF with Django Reversion
Saruar Star
Posted on April 22, 2023
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.")
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__'
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
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
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
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.
Install the Django Reversion module with the following command.
pip install django-reversion
Add
reversion
toINSTALLED_APPS
of yoursettings.py
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.
Inside our
models.py
file, import the register decorator from the reversion module.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.")
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.
Create a new directory called revision inside our blog directory.
Add a
views.py
file to the newly created directory.Inside this file, we import the
RevisionMixin
fromreversion.views
.Create a new class, that is going to be our customized version of the mixin, I'm going to name it
CustomRevisionMixin
.Add the reversion module's
RevisionMixin
inside theCustomRevisionMixin
parameters, in order to inherit the current reversion module's functionality.
blog/revision/views.py
from reversion.views import RevisionMixin
class CustomRevisionMixin(RevisionMixin):
pass
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
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
Next, let's create a function that will retrieve the current instance's version history, we will call the function get_versions
.
Import the
Version
model fromreversion.models
.Import the
RegistrationError
exception class fromreversion.errors
.Retrieve the current instance by calling the viewset's
get_object
method.Call the
get_for_object
method of theVersion
model's manager, since in the docs it is stated that this method might throw aRegistrationError
if called for an unregistered model, we are going to wrap it inside atry except
clause, and throw a standardAPIException
(fromrest_framework.exceptions
) for this case.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.
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
The next step is to create a serializer for the Version
model, as usual, we will be using a ModelSerializer to achieve this.
Create a
serializers.py
file inside therevision
directory.Import
serializers
fromrest_framework
, and theVersion
model fromreversion.models
.Define a serializer with the model set to
Version
and define fields based on the available data on theVersion
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']
Now we finalize our instance_logs
action.
Import the newly created
RevisionSerializer
into ourviews.py
file.Import
Response
fromrest_framework.response
.Call the
get_versions
function.Pass the versions as the serializer instance, don't forget to add
many=True
kwarg.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)
Now we define our second action, which will handle the reversion of an instance to an older version.
Create a
revert_instance
function and add theaction
decorator on top of it.The method for this action will be POST, detail should be set to True.
Call the
get_versions
function.Define a second serializer called
RevisionRevertSerializer
which overrides theRevisionSerializer
inside ourserializers.py
file.Override the
__init__
function, to dynamically define theversion
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.Override the serializer's
update
function to extract the version instance from thevalidated_data
.Call the
revert
method of the revision instance API, first, access the revision instance from the extracted version instance with dot notation, then, call therevert
method.Since
revert
might throw aRevertError
in case the database schema has been changed, we are going to wrap it inside atry except
clause and throw a custom message for handling this case.Import the
RevisionRevertSerializer
serializer inside outviews.py
file.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})
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']
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
.
Inside our
blog/views.py
file import theCustomRevisionMixin
.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
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"
}
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"
}
After that, we are going to check the instance logs.
METHOD: GET
URL: /blog/1/instance_logs/
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"
}
}
]
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
}
Now let's check if the instance has been reverted by retrieving the instance.
METHOD: GET
URL: /blog/1/
As expected, the instance has been successfully reverted.
{
"id": 1,
"title": "version 1 title",
"description": "version 1 description",
"content": "version 1 content"
}
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"
}
}
]
That's all for this article, I really hope it was helpful.
Source Code: https://github.com/saruar999/django-reversion-with-drf
Posted on April 22, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.