Implementing an Audit Trail Middleware in Django for Tracking User Actions in Django

This is part 2 of the series create and use custom signals in Django.

In this article, you will do some updates on the previously written code in the previous article and learn how to wrap the signal in middleware for it to be automatically fired up with every request.

The base application for this blog post is hosted on this github repository .

1 Update Signal definitions

In this section, you will update some deprecated methods in the code base. Head over to the `apps/blogs/signals.py` file and remove this block:


audit_trail_signal = django.dispatch.Signal(
     providing_args=['user', 'request', 'model', 'event_category', 'method', 'summary']
)

remove the providing_args argument from the django.dispatch.Signal method. The updated file should look like the one below.


import django.dispatch
from django.dispatch import receiver

from .models import AuditTrail
import logging
import datetime


# creates a custom signal and specifies the args required.
audit_trail_signal = django.dispatch.Signal(
    # providing_args=['user', 'request', 'model', 'event_category', 'method', 'summary']
)

logger = logging.getLogger(__name__)

# helper func that gets the client ip


def get_client_ip(request):
    x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
    if x_forwarded_for:
        ip = x_forwarded_for.split(',')[0]
    else:
        ip = request.META.get('REMOTE_ADDR')
    return ip


@receiver(audit_trail_signal)
def log_audit_trail(sender, user, request, model, event_category, method, summary, **kwargs):
    try:

        user_agent_info = request.META.get(
            'HTTP_USER_AGENT', '<unknown>')[:255],
        print(summary)
        auditTrail = AuditTrail.objects.create(
            user=user,
            user_agent_info=user_agent_info,
            changed_object=model,
            event_category=event_category,
            login_IP=get_client_ip(request),
            is_deleted=False,
            action=method,
            change_summary=summary
        )
        logger.info(
            f"Audit trail created {auditTrail.id}  for user {auditTrail.username} and object {auditTrail.changed_object}")
    except Exception as e:

        logger.error("log_user_logged_in request: %s, error: %s" %
                     (request, e))

2. Custom AuditTrailMiddleware.

In this section, you will learn how to set up a custom middleware, why it is needed and its importance.

As per the official django documentation, a middleware is a framework of hooks into Django's request/response processing. It’s a light, low-level “plugin” system for globally altering Django’s input or output.

With you now understanding what middleware is, you can go ahead and create a custom AuditTrailMiddleware.

The AuditTrailMiddleware will intercept the response to every API call and trigger the audit_trail signal that you had earlier created for every request whether successful or error thrown with the corresponding information.

Head over to your idea and at the apps/blogs level create a new file middlewares.py and paste the following lines of code.

from django.utils.deprecation import MiddlewareMixin
from .signals import audit_trail_signal

import traceback


class AuditTrailMiddleware(MiddlewareMixin):
    def process_response(self, request, response):

        if response.status_code >= 200 and response.status_code < 300:
            # Get the view class from the resolver match info
            # view_class = request.resolver_match.func.cls

            view_class = None
            if hasattr(request.resolver_match.func, 'cls'):

                view_class = request.resolver_match.func.cls

            # Get the model from the view's queryset attribute
            # model = view_class.queryset.model.__name__
            model = None
            if hasattr(view_class, 'queryset'):
                model = view_class.queryset.model.__name__

            audit_trail_signal.send(
                sender=request.user.__class__,
                request=request,
                response=response,
                user=request.user,
                model=model,
                event_category=model,
                method=request.method,
                summary="{} {}".format(request.method, request.path),
            )
            return response
        else:
            # An error occurred, send the audit trail signal with an error message
            # Get the view class and model as described above
            view_class = None
            if hasattr(request.resolver_match.func, 'cls'):
                view_class = request.resolver_match.func.cls
            elif hasattr(request.resolver_match.func, 'view_class'):
                view_class = request.resolver_match.func.view_class

            model = None
            if view_class is not None and hasattr(view_class, 'queryset'):
                model = view_class.queryset.model.__name__

            # Build the error message from the traceback
            error_message = traceback.format_exc()

            audit_trail_signal.send(
                sender=request.user.__class__,
                request=request,
                response=response,
                user=request.user,
                model=model,
                event_category="Blog",
                method=request.method,
                summary="{} {} - ERROR".format(request.method, request.path),
                detail=error_message,
            )

        return response

    def process_exception(self, request, exception):
        # An exception occurred, send the audit trail signal with an error message
        # Get the view class and model as described above
        view_class = None
        if hasattr(request.resolver_match.func, 'cls'):
            view_class = request.resolver_match.func.cls
        elif hasattr(request.resolver_match.func, 'view_class'):
            view_class = request.resolver_match.func.view_class

        model = None
        if view_class is not None and hasattr(view_class, 'queryset'):
            model = view_class.queryset.model.__name__

        # Build the error message from the traceback
        error_message = traceback.format_exc()

        audit_trail_signal.send(
            sender=request.user.__class__,
            request=request,
            response=None,
            user=request.user,
            model=model,
            event_category="Blog",
            method=request.method,
            summary="{} {} - ERROR".format(request.method, request.path),
            detail=error_message,
        )

With the AuditTrailMiddleware implementation in place, it needs to be registered for it to work thus head over to your settings file, the middleware definitions and add in the AuditTraiMiddleware at the end of the list as shown below

MIDDLEWARE = [
    ..., 
    'apps.blogs.middlewares.AuditTrailMiddleware' # path to your middleware class 
]

With this in place, the audit trail signal will be automatically triggered for every request-response cycle.

Head over to your apps/blogs/views/py and comment out the line that calls the audit trail signal on the PostAPI class.

class PostAPI(generics.ListCreateAPIView):
    serializer_class = PostSerializer
    queryset = Post.objects.all().order_by("-created_at")
    permission_classes = (permissions.IsAuthenticated,)

    def perform_create(self, serializer):
        data = self.request.data
        new_post = serializer.save(
            user=self.request.user, title=data["title"], content=data["content"]
        )
        all_posts = Post.objects.all().order_by("-created_at")
        serializer = PostSerializer(all_posts, many=True)
        # audit_trail_signal.send(sender=self.request.user.__class__, request=self.request,
        #                         user=self.request.user, model="Blog", event_category="Blog", method="CREATE", summary="Create a new post")
        return Response(serializer.data, status=status.HTTP_201_CREATED)

To test this exhaustively, re-start your development server and head over to the swagger endpoint and hit any posts / tags API endpoints.

http://localhost:8000/swagger/

With this, you have learnt on how to set up a custom Django middleware and use it to track user actions via an audit trail.

Happy hacking.

You can follow me on Twitter and GitHub