Instance version control in DRF with Django Reversion
Tags: django, drf, djangoreversion, python
Published: 2023-04-22 23:23:41

**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. 

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.")
```

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
``` 

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`.

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
```

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']
```

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)
```

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})
```

`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`.

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
```

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