Django Lifecycle Hooks
This project provides a
@hook decorator as well as a base model or mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is Signals. However, in the projects I've worked on, my team often finds that Signals introduce unnesseary indirection and are at odds with Django's "fat models" approach of including related logic in the model class itself*.
In short, you can write model code that looks like this:
from django_lifecycle import LifecycleModel, hook class UserAccount(LifecycleModel): username = models.CharField(max_length=100) password = models.CharField(max_length=200) password_updated_at = models.DateTimeField(null=True) @hook('before_update', when='password', has_changed=True) def timestamp_password_change(self): self.password_updated_at = timezone.now()
Instead of overriding
__init___ in a clunky way that hurts readability:
# same class and field declarations as above ... def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__original_password = self.password def save(self, *args, **kwargs): if self.pk is not None and self.password != self.__original_password: self.password_updated_at = timezone.now() super().save(*args, **kwargs)
*This is not to say Signals are never useful; my team prefers to use them for incidental concerns not related to the business domain, like cache invalidation.
Table of Contents:
pip install django-lifecycle
- Python (3.3, 3.4, 3.5, 3.6)
- Django (1.8, 1.9, 1.10, 1.11, 2.0)
Either extend the provided abstract base model class:
from django_lifecycle import LifecycleModel, hook class YourModel(LifecycleModel): name = models.CharField(max_length=50)
Or add the mixin to your Django model definition:
from django.db import models from django_lifecycle import LifecycleModelMixin, hook class YourModel(LifecycleModelMixin, models.Model): name = models.CharField(max_length=50)
Great, now we can start adding lifecycle hooks! Let's do a few examples that illustrate the ability to not only hook into certain events, but to add basic conditions that can replace the need for boilerplate conditional code.
Say you want to process a thumbnail image in the background and send the user an email when they first sign up:
@hook('after_create') def do_after_create_jobs(self): enqueue_job(process_thumbnail, self.picture_url) mail.send_mail( 'Welcome!', 'Thank you for joining.', 'firstname.lastname@example.org', ['email@example.com'], )
Or say you want to email a user when their account is deleted. You could add the decorated method below:
@hook('after_delete') def email_deleted_user(self): mail.send_mail( 'We have deleted your account', 'Thank you for your time.', 'firstname.lastname@example.org', ['email@example.com'], )
Maybe you only want the hooked method to run only under certain circumstances related to the state of your model. Say if updating a model instance changes a "status" field's value from "active" to "banned", you want to send them an email:
@hook('after_update', when='status', was='active', is_now='banned') def email_banned_user(self): mail.send_mail( 'You have been banned', 'You may or may not deserve it.', 'firstname.lastname@example.org', ['email@example.com'], )
is_now keyword arguments allow you to compare the model's state from when it was first instantiated to the current moment. You can also pass an
* to indicate any value - these are the defaults, meaning that by default the hooked method will fire. The
when keyword specifies which field to check against.
You can also enforce certain dissallowed transitions. For example, maybe you don't want your staff to be able to delete an active trial because they should always expire:
@hook('before_delete', when='has_trial', is_now=True) def ensure_trial_not_active(self): raise CannotDeleteActiveTrial('Cannot delete trial user!')
We've ommitted the
was keyword meaning that the initial state of the
has_trial field can be any value ("*").
As we saw in the very first example, you can also pass the keyword argument
has_changed=True to run the hooked method if a field has changed, regardless of previous or current value.
@hook('before_update', when='address', has_changed=True) def timestamp_address_change(self): self.address_updated_at = timezone.now()
You can also have a hooked method fire when a field's value IS NOT equal to a certain value. See a common example below involving lowercasing a user's email.
@hook('before_save', when='email', is_not=None) def lowercase_email(self): self.email = self.email.lower()
If you need to hook into events with more complex conditions, you can take advantage of
@hook('after_update') def on_update(self): if self.has_changed('username') and not self.has_changed('password'): # do the thing here if self.initial_value('login_attempts') == 2: do_thing() else: do_other_thing()
You can decorate the same method multiple times if you want.
@hook('after_create') @hook('after_delete') def db_rows_changed(self): do_something()
The hook name is passed as the first positional argument to the @hook decorator, e.g.
@hook(hook_name: str, **kwargs)
|Hook name||When it fires|
@hook(hook_name: str, when: str = None, was='*', is_now='*', has_changed: bool = None, is_not = None):
|when||str||The name of the field that you want to check against; required for the conditions below to be checked|
|was||any||Only fire the hooked method if the value of the
|is_now||any||Only fire the hooked method if the value of the
|has_changed||bool||Only fire the hooked method if the value of the
|is_not||any||Only fire the hooked method if the value of the
These are available on your model when you use the mixin or extend the base model.
||Return a boolean indicating whether the field's value has changed since the model was initialized|
||Return the value of the field when the model was first initialized|
To prevent the hooked methods from being called, pass
skip_hooks=True when calling save:
Foreign key fields on a lifecycle model can only be checked with the
has_changed argument. That is, this library only checks to see if the value of the foreign key has changed. If you need more advanced conditions, consider omiting the run conditions and accessing the related model's fields in the hooked method.
0.4.0 (May 2019)
initial_value(field_name)behavior - should return value even if no change. Thanks @adamJLev!
0.3.2 (February 2019)
- Fixes bug preventing hooks from firing for custom PKs. Thanks @atugushev!
0.3.1 (August 2018)
- Fixes m2m field bug, in which accessing auto-generated reverse field in
before_createcauses exception b/c PK does not exist yet. Thanks @garyd203!
0.3.0 (April 2018)
- Resets model's comparison state for hook conditions after
0.2.4 (April 2018)
- Fixed support for adding multiple
@hookdecorators to same method.
0.2.3 (April 2018)
- Removes residual mixin methods from earlier implementation.
0.2.2 (April 2018)
- Save method now accepts
skip_hooks, an optional boolean keyword argument that controls whether hooked methods are called.
0.2.1 (April 2018)
- Fixed bug in
_potentially_hooked_methodsthat caused unwanted side effects by accessing model instance methods decorated with
0.2.0 (April 2018)
- Added Django 1.8 support. Thanks @jtiai!
- Tox testing added for Python 3.4, 3.5, 3.6 and Django 1.8, 1.11 and 2.0. Thanks @jtiai!
Tests are found in a simplified Django project in the
/tests folder. Install the project requirements and do
./manage.py test to run them.