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 save
and __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.
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)
❗ If you are using Django 1.8 or below and want to extend the base model, you also have to add django_lifecycle
to INSTALLED_APPS
.
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.',
'[email protected]', ['[email protected]'],
)
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.',
'[email protected]', ['[email protected]'],
)
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.',
'[email protected]', ['[email protected]'],
)
The was
and 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 has_changed
and initial_value
methods:
@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('before_create)
.
@hook(hook_name: str, **kwargs)
Hook name | When it fires |
---|---|
before_save | Immediately before save is called |
after_save | Immediately after save is called |
before_create | Immediately before save is called, if pk is None |
after_create | Immediately after save is called, if pk was initially None |
before_update | Immediately before save is called, if pk is NOT None |
after_update | Immediately after save is called, if pk was NOT None |
before_delete | Immediately before delete is called |
after_delete | Immediately after delete is called |
@hook(hook_name: str, when: str = None, was='*', is_now='*', has_changed: bool = None, is_not = None):
Keywarg arg | Type | Details |
---|---|---|
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 when field was equal to this value when first initialized; defaults to * . |
is_now | any | Only fire the hooked method if the value of the when field is currently equal to this value; defaults to * . |
has_changed | bool | Only fire the hooked method if the value of the when field has changed since the model was initialized |
is_not | any | Only fire the hooked method if the value of the when field is NOT equal to this value |
These are available on your model when you use the mixin or extend the base model.
Method | Details |
---|---|
has_changed(field_name: str) -> bool |
Return a boolean indicating whether the field's value has changed since the model was initialized |
initial_value(field_name: str) -> bool |
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:
account.save(skip_hooks=True)
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.
- Fixes m2m field bug, in which accessing auto-generated reverse field in
before_create
causes exception b/c PK does not exist yet. Thanks @garyd203!
- Resets model's comparison state for hook conditions after
save
called.
- Fixed support for adding multiple
@hook
decorators to same method.
- Removes residual mixin methods from earlier implementation.
- Save method now accepts
skip_hooks
, an optional boolean keyword argument that controls whether hooked methods are called.
- Fixed bug in
_potentially_hooked_methods
that caused unwanted side effects by accessing model instance methods decorated with@cache_property
or@property
.
- 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.
See License.