Django Signals

cem akpolat
9 min readSep 5, 2023

--

Django signals offers a way that you can perform a number of operations before and after creating and saving the model data in the database. The importance of these operations could differ in each project w.r.t use cases. Even these operations are already pretty well-defined in the official web page, to see how the real usage of the signals can affect the database models. The name of the all these operations are depicted in the following image. The color of the names indicate the signals occurs subsequently except m2m_changed event, since it can be combined with most of the signals.

The first four signals pre_init, post_init, pre_save and post_save are called once a data model is created and saved in the database, whose process is shown below. At each signal, a specific action can be executed, here are the details of these singals:

pre_init : This signal is emitted before initializing a model instance. As the instance has not yet been created, the instance properties can be modified or the instance initiation might be performed via a custom logic.

post_init : Once the model has been fully initiated, this signal is triggered. Anything related to the initial state of the created instance can be performed at this state.

pre_save : This signal is emitted before saving a model instance. The common usage types are to validate or modify the model instance fields before its persistent save to the database.

post_save : The next step is to save the model in the database, and this signal is triggered after saving the instance model. Sending notifications or updating related models are common actions after this signal.

As aforementioned, the first two operations are executed once a model instance is created if it doesn’t exist in the database. The other twos can also be executed while updating the model in the database. The usage of these signals can occur e.g. while using REST operations such as PUT, POST, and PATCH.

Apart from the instance creation, the deletion of the database model leads also to the signal generation like pre_delete and post_delete. It has totally similar concept as above.

pre_detele: Apart from the instance creation, during the deletion of a model instance emits also a signal. You may perform some operations for custom checks or cleaning up related data.

post_delete: This signal is emitted once a model has been deleted, and it might be useful for performing actions after deletion, like logging or related clean-up.

The final one in this tutorial is m2m_changed signal, which occurs when a related model of a model is modified. For instance, if a category is modified depicted in the figure below, the related model device will be affected and m2m_changed signal is triggered.

m2m_changed: It is emitted when the set of related objects in a ManyToManyField changes. It can be useful for handling changes in relationships between instances. Whenever a model is changed, other related models can also be handled. In the figure above the device model has a relationship with Category and Service, once a device data is modified, m2m_changed signal will be triggered and allows us to perform required operations.

All the aforementioned operations take place w.r.t the phase while creating and saving a Django model instance. After the formal definition of these steps, the following steps focus on experimenting signals and its usages.

  1. Create a Django Project
  2. Create/Update/Delete a Django Model Object & Observe Changes
  3. Applicable Use Cases

1. Create a Django Project

This step is composed of the simple steps to initiate a Django project. Basically, first a virtual environment is created, and it is activated. Afterward, the django library is installed, and the Django project is initiated with the dsignals name. To see whether it is callable on the web browser or not, the Django project is executed, and it should be reachable under localhost:8000 URL address.

python3 -m venv .venv/
source .venv/bin/activate
pip install django
django-admin startproject dsignals
python manage.py startapp main
python manage.py runserver
Prepare migrations for the models
`ptyhon manage.py makemigrations`
Apply the migrations
`python manage.py migrate`

The model that will be created User, Category , Device and Task models. The models are kept as simple as possible. To be able to use it more efficiently, the serializers and views functions are added subsequently. The django_rest_framework is the easiest plugin to shorten the development duration. Afterward, signals.py, which logs for each signal, is appended.

Models.py includes all necessary models.

from django.db import models

class User(models.Model):
name = models.CharField(max_length=100)
email = models.EmailField()

class Meta:
app_label = 'main'
def __str__(self):
return self.name

class Category(models.Model):
name = models.CharField(max_length=100)
# Add other fields as needed
class Meta:
app_label = 'main'
def __str__(self):
return self.name

class Device(models.Model):
name = models.CharField(max_length=100)
category = models.ForeignKey(Category, on_delete=models.CASCADE)
# Add other fields as needed
class Meta:
app_label = 'main'
def __str__(self):
return self.name

class Task(models.Model):
name = models.CharField(max_length=100)
description = models.TextField()
devices = models.ManyToManyField(Device, related_name='tasks', blank=True)

def __str__(self):
return self.name

Serializers.py serializes all device and category features.

from rest_framework import serializers
from main.models import Device, Category

class DeviceSerializer(serializers.ModelSerializer):

class Meta:
model = Device
fields = '__all__'

class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = '__all__'

class TaskSerializer(serializers.ModelSerializer):
class Meta:
model = Task
fields = '__all__'

Views.py returns the serialized the device and category model instances to the end-user as a response.

from rest_framework import viewsets

from main.models import Device, Category
from main.serializer import DeviceSerializer, CategorySerializer

class DeviceViewSet(viewsets.ModelViewSet):
queryset = Device.objects.all()
serializer_class = DeviceSerializer


class CategoryViewSet(viewsets.ModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer


class TaskViewSet(viewsets.ModelViewSet):
queryset = Task.objects.all()
serializer_class = TaskSerializer

signals.py includes only device related signals, not the category. Once we test the whole Django app, we will see all logs.

from django.dispatch import receiver
from main.models import User, Device, Task
from django.db.models.signals import (
pre_init, post_init, pre_save, post_save,
pre_delete, post_delete, m2m_changed,
class_prepared
)

@receiver(pre_init, sender=Device)
def device_pre_init(sender, **kwargs):
# Code to execute before initializing the device
print(f"Pre_init: Preparing to initialize device: {sender}")

@receiver(pre_save, sender=Device)
def device_pre_save(sender, instance, **kwargs):
print(f"Pre_save: Preparing to save device: {instance.name}")

@receiver(post_save, sender=Device)
def device_post_save(sender, instance, created, **kwargs):
if created:
print(f"Post_save: New device '{instance.name}' has been saved!")
else:
print(f"Post_update: Device '{instance.name}' has been updated!")

@receiver(pre_delete, sender=Device)
def device_pre_delete(sender, instance, **kwargs):
print(f"Pre_delete:Preparing to delete device: {instance.name}")

@receiver(post_delete, sender=Device)
def device_post_delete(sender, instance, **kwargs):
print(f"Post_delete:Device '{instance.name}' has been deleted!")

@receiver(post_init, sender=Device)
def device_post_init(sender, instance, **kwargs):
# Code to execute after initializing the device
print(f"Post_init: Device '{instance}' has been initialized!")

@receiver(m2m_changed, sender=Device.tasks.through)
def device_m2m_changed(sender, instance, action, reverse, model, pk_set, **kwargs):
print(f"Signal received: action={action}, device={instance.name}, tasks={pk_set}")
if action == "pre_add":
for task_id in pk_set:
try:
task = Task.objects.get(pk=task_id)
print(f"About to add task '{task.name}' to device '{instance.name}'")
except Task.DoesNotExist:
print(f"Task with ID {task_id} does not exist.")
# Handle the case where the task doesn't exist gracefully

elif action == "post_add":
# Tasks have been added to the device
for task_id in pk_set:
task = Task.objects.get(pk=task_id)
print(f"Task '{task.name}' added to device '{instance.name}'")

elif action == "pre_remove":
# Tasks are about to be removed from the device
for task_id in pk_set:
task = Task.objects.get(pk=task_id)
print(f"About to remove task '{task.name}' from device '{instance.name}'")

elif action == "post_remove":
# Tasks have been removed from the device
for task_id in pk_set:
task = Task.objects.get(pk=task_id)
print(f"Task '{task.name}' removed from device '{instance.name}'")

elif action == "pre_clear":
# All tasks are about to be cleared from the device
print(f"About to clear all tasks from device '{instance.name}'")

elif action == "post_clear":
# All tasks have been cleared from the device
print(f"All tasks cleared from device '{instance.name}'")

@receiver(class_prepared)
def class_prepared_handler(sender, **kwargs):
print(f"Model class '{sender.__name__}' has been prepared!")

In the django settings.py file, rest_framework should be added after our newly added module, and it should be seen as below:

...
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'main',
'rest_framework'
]
...

The last code that will be added is the url.py file that will direct our REST requests to the related views.

from django.contrib import admin
from django.urls import path, include
from rest_framework import routers
from main.views import DeviceViewSet, CategoryViewSet

router = routers.DefaultRouter()
router.register(r'devices', DeviceViewSet)
router.register(r'categories', CategoryViewSet)
router.register(r'tasks', TaskViewSet)

urlpatterns = [
path('admin/', admin.site.urls),
path('', include(router.urls)),
path('devices/<int>/', include(router.urls)),
]

The URL basically explain that we can access admin/,`tasks/` /devices/ URLs. To start all these implemented code, two steps are required: migrate the database models and start the Django web server.

Prepare migrations for the models
`ptyhon manage.py makemigrations`
Apply the migrations
`python manage.py migrate`
Run the django web server
`python manage.py runserver`

The actual development includes some simple models, signals, views and URLs. All operations for device handling such as GET, POST, PUT, and DELETE are supported. To retrieve all devices, you can simply request the following URLs for retrieving all devices or a single device if it exists.

http://127.0.0.1:8000/categories/
http://127.0.0.1:8000/devices/1/
http://127.0.0.1:8000/tasks/1/

At this point, all basic components are implemented to observe the console logs when the signals are triggered. One additional file requrements.txt is generated using pip3 freeze > requirements.txt , since I forgot to add it in the beginning. The required libraries are listed below:

asgiref==3.7.2
Django==4.2.4
django-admin==2.0.2
django-excel-response2==3.0.6
django-six==1.0.5
djangorestframework==3.14.0
excel-base==1.0.4
isoweek==1.3.3
python-dateutil==2.8.2
pytz==2023.3
screen==1.0.1
six==1.16.0
sqlparse==0.4.4
TimeConvert==3.0.13
tzlocal==5.0.1
xlwt==1.3.0

2. Create/Update/Delete a Django Model Object & Observe Changes

For this page, many devices, categories and tasks are created. Once you call the 127.0.0.1:8000 URL address, the following website will appear.

The console logs show us a GET request as expected, there is any signal indices.

Once the http://127.0.0.1:8000/devices/ URL is called, the created device list is delivered as a response.

In order to return such a result, the models should be created from the data model, therefore we see below pre_init and post_init are triggered. The trigger functions for each model separately, i.e. you may even change a specific device data.

In case a single device is requested via http://127.0.0.1:8000/devices/2/, the single device data is obtained, and only two same signals are triggered.

In this example, the device2 is updated again using http://127.0.0.1:8000/devices/2/ and the group name is changed.

Notice that pre_init, post_niet, pre_save, post_update/post_save signals are fired.

Once the device2 is removed, the triggered signals are pre_init, post_init, pre_delete, and post_delete.

The last example that is presented here is to observe the Many-to-many relationship. A task can be assigned more than a device. In the following example, task’s devices are reduced and the device with the ID number 6 is removed.

The logs demonstrate us m2m_changed signal is triggered, since these logs are written in this signal. Afterward, the pre_init, post_init signals appear, because the GET operation is called to retrieve the changed task object.

3. Applicable Use Cases

In the previous section, all signals are demonstrated with the simple examples. Indeed, there are many use-cases where the signals can be employed. For example:

1. Auditing and Logging: Use signals to log when a model is created, updated or deleted.

2. Custom Field Calculation: Use signals to make calculations and set values on specific fields before saving an instance.

3. Handling Related Objects: Use signals to perform actions when related objects change.

4. Sending Notifications: Use signals to send notifications when certain events occur.

5. Validation Before Save: Use signals to perform custom validation on a model instance before it’s saved.

6. Automatically Set Timestamps: Use signals to automatically set timestamps on a model instance.

Conclusion

In this tutorial, the Django signals are introduced. A Django application is implemented to demonstrate how the signals are triggered, and finally some potential use-cases are given for the use of signals.

--

--