Lifecycle Hooks (Events)
Developer guide for the Enezaframework. Part of the Developer documentation.
Table of Contents
- Overview
- Automatic Support on Eneza Models
- Standalone Event Models
- Import Reference
- Hook Moments
- Conditions
- Foreign-Key Changes
- Utility Methods
- Priority and On-Commit Hooks
- Suppressing Hooks
- Custom Conditions
- Migrating from django-lifecycle
- Behavioral Notes
- Testing Responsibility
- Related Guides
Overview
Enezaframework ships a declarative lifecycle-hook system. Add the @hook decorator to a model method
to run it before or after a model is created, updated, saved, or deleted, optionally only when a field
condition is true.
The implementation is built into EnezaModel, so existing application models do not need to
change their inheritance. Every model that already inherits an Enezaframework base model
(EnezaMaster, EnezaSetup, EnezaDocument, EnezaTransactionalTable, EnezaGeneral, and their
subclasses) can use hooks immediately.
from django.db import models
from enezaframework import AFTER_UPDATE
from enezaframework import WhenFieldValueIs
from enezaframework import WhenFieldValueWas
from enezaframework import hook
from enezaframework.models import EnezaGeneral
class Article(EnezaGeneral):
title = models.CharField(max_length=200, verbose_name="Title")
status = models.CharField(
max_length=20,
choices=(("draft", "Draft"), ("published", "Published")),
default="draft",
verbose_name="Status",
)
@hook(
AFTER_UPDATE,
condition=(
WhenFieldValueWas("status", value="draft")
& WhenFieldValueIs("status", value="published")
),
)
def on_publish(self):
# send an email, enqueue a job, update a related record, ...
...
The alternative is to override save() and __init__ to track previous values. Hooks keep that
logic declarative and on the model that owns it.
Automatic Support on Eneza Models
Use the normal Enezaframework base model for the record type:
| Record type | Base model | Hooks available |
|---|---|---|
| Master data | EnezaMaster |
Yes |
| Master one-to-one detail | EnezaMasterDetails |
Yes |
| General record | EnezaGeneral |
Yes |
| Document header with numbering | EnezaDocument |
Yes |
| Document header without numbering | EnezaTransactionalTable |
Yes |
| Document line | EnezaDocumentLines |
Yes |
| Setup/configuration | EnezaSetup |
Yes |
| Single-instance parameter | EnezaParameter |
Yes |
EnezaModel inherits EnezaEvents, which inherits EnezaEventsMixin. No extra import or base class
is required in application models.
Standalone Event Models
If a model does not inherit an Enezaframework base, use one of these:
from django.db import models
from enezaframework import EnezaEvents
from enezaframework import AFTER_CREATE
from enezaframework import hook
class AuditRecord(EnezaEvents):
message = models.TextField(verbose_name="Message")
@hook(AFTER_CREATE)
def write_initial_entry(self):
...
EnezaEvents is an abstract base that adds only hook support. It does not add the EnezaModel UUID
primary key, timestamps, audit users, or history.
For a model that already inherits another Django base or mixin, add the mixin explicitly:
from django.db import models
from enezaframework import EnezaEventsMixin
from enezaframework import AFTER_SAVE
from enezaframework import hook
class ExternalRecord(EnezaEventsMixin, models.Model):
external_id = models.CharField(max_length=64, verbose_name="External ID")
@hook(AFTER_SAVE)
def sync_external_record(self):
...
Import Reference
Import public names from the package root:
from enezaframework import EnezaEvents
from enezaframework import EnezaEventsMixin
from enezaframework import EnezaEventsException
from enezaframework import hook
from enezaframework import BEFORE_SAVE, AFTER_SAVE
from enezaframework import BEFORE_CREATE, AFTER_CREATE
from enezaframework import BEFORE_UPDATE, AFTER_UPDATE
from enezaframework import BEFORE_DELETE, AFTER_DELETE
from enezaframework import WhenFieldValueWas, WhenFieldValueIs
from enezaframework import WhenFieldHasChanged
from enezaframework import WhenFieldValueIsNot, WhenFieldValueWasNot
from enezaframework import WhenFieldValueChangesTo
from enezaframework import Always
from enezaframework import ChainableCondition
from enezaframework import bypass_hooks_for
Internal modules are available under enezaframework.events when a lower-level import is needed,
but application code should prefer the package-root imports.
Hook Moments
Hooks fire in this order during the corresponding operation:
| Constant | Hook name | When it fires |
|---|---|---|
BEFORE_SAVE |
before_save |
Immediately before save() is called |
AFTER_SAVE |
after_save |
Immediately after save() is called |
BEFORE_CREATE |
before_create |
Before save() when the instance is being added |
AFTER_CREATE |
after_create |
After save() when the instance was being added |
BEFORE_UPDATE |
before_update |
Before save() when the instance already exists |
AFTER_UPDATE |
after_update |
After save() when the instance already exists |
BEFORE_DELETE |
before_delete |
Immediately before delete() is called |
AFTER_DELETE |
after_delete |
Immediately after delete() is called |
Create vs. update is determined from Django's instance state (Model._state.adding), not from whether
pk is None. This matters for Enezaframework models because EnezaModel uses a UUID primary key
that is populated before the first save.
Example:
@hook(AFTER_CREATE)
def send_welcome_message(self):
...
@hook(BEFORE_DELETE)
def prevent_deletion(self):
if self.has_trial:
raise CannotDeleteActiveTrial("Cannot delete a trial account")
Stacking decorators is supported when one method should run for multiple moments:
@hook(AFTER_UPDATE, condition=WhenFieldHasChanged("published", has_changed=True))
@hook(AFTER_CREATE)
def handle_publish(self):
...
Conditions
Conditions inspect the instance's initial state or current state.
| Condition | Fires when |
|---|---|
WhenFieldHasChanged(field_name, has_changed=None) |
The field changed since initialization; pass has_changed=False to invert |
WhenFieldValueIs(field_name, value) |
The current value equals value; "*" matches anything |
WhenFieldValueIsNot(field_name, value) |
The current value does not equal value |
WhenFieldValueWas(field_name, value) |
The initial value equals value; "*" matches anything |
WhenFieldValueWasNot(field_name, value) |
The initial value does not equal value |
WhenFieldValueChangesTo(field_name, value) |
The initial value differs from value and the current value equals value |
Always() |
Always fires; this is the default when no condition is supplied |
Chain conditions with & and |:
@hook(
AFTER_UPDATE,
condition=(
WhenFieldValueWas("status", value="active")
& WhenFieldValueIs("status", value="banned")
),
)
def email_banned_user(self):
...
Example: only fire when a field changed:
@hook(BEFORE_UPDATE, condition=WhenFieldHasChanged("email", has_changed=True))
def timestamp_email_change(self):
...
Example: fire when a field is not a specific value:
@hook(BEFORE_SAVE, condition=WhenFieldValueIsNot("email", value=None))
def lowercase_email(self):
self.email = self.email.lower()
Legacy decorator keywords
The older keyword form remains available:
@hook(
AFTER_UPDATE,
when="status",
was="active",
is_now="banned",
has_changed=True,
)
def email_banned_user(self):
...
Supported keywords are when, when_any, has_changed, was, was_not, is_now, is_not, and
changes_to. Do not combine condition= with these keywords. Use when or when_any, not both.
Foreign-Key Changes
Put the foreign-key field name in a condition to watch the reference itself:
@hook(AFTER_UPDATE, condition=WhenFieldHasChanged("organization", has_changed=True))
def notify_user_of_organization_change(self):
...
Use dot-notation to watch a field on the related model:
@hook(AFTER_UPDATE, condition=WhenFieldValueChangesTo("organization.name", value="Acme"))
def notify_user_of_acme(self):
...
Dot-notation loads the related object while capturing initial state. Load these records with
select_related() to avoid an N+1 query problem:
UserAccount.objects.select_related("organization").get(pk=pk)
Utility Methods
Hooked methods and other model code can inspect the initial state:
| Method | Result |
|---|---|
has_changed(field_name) |
True if the field's value changed since initialization or the last refresh_from_db() |
initial_value(field_name) |
The value the field had at initialization or the last refresh_from_db() |
@hook(AFTER_UPDATE)
def check_profile_changes(self):
if self.has_changed("last_name") and not self.has_changed("marital_status"):
...
refresh_from_db() resets the initial state. A successful save() resets it on transaction commit.
Priority and On-Commit Hooks
Use priority to order multiple hooks for the same moment. Lower numbers run first:
from enezaframework import HIGHEST_PRIORITY, LOWER_PRIORITY
@hook(AFTER_UPDATE, priority=HIGHEST_PRIORITY)
def run_first(self):
...
@hook(AFTER_UPDATE, priority=LOWER_PRIORITY)
def run_later(self):
...
Use on_commit=True with an AFTER_* hook to run after the current database transaction commits:
@hook(AFTER_CREATE, on_commit=True)
def enqueue_welcome_job(self):
...
on_commit is only valid on AFTER_* hooks.
Suppressing Hooks
Skip hooks for one save:
article.save(skip_hooks=True)
Skip hooks for every save inside a block:
from enezaframework import bypass_hooks_for
with bypass_hooks_for((Article,)):
article.save() # hooks are not invoked
Use this for migrations, imports, bulk correction scripts, or framework bookkeeping where lifecycle events must not run.
Custom Conditions
A custom condition is any callable with this signature:
def changes_to_ned_flanders(instance, update_fields=None) -> bool:
return (
instance.has_changed("first_name")
and instance.has_changed("last_name")
and instance.first_name == "Ned"
and instance.last_name == "Flanders"
)
For chainable custom conditions, inherit ChainableCondition:
from enezaframework import BEFORE_SAVE
from enezaframework import ChainableCondition
from enezaframework import WhenFieldHasChanged
from enezaframework import hook
class IsNedFlanders(ChainableCondition):
def __call__(self, instance, update_fields=None):
return instance.first_name == "Ned" and instance.last_name == "Flanders"
@hook(
BEFORE_SAVE,
condition=(
WhenFieldHasChanged("first_name")
& WhenFieldHasChanged("last_name")
& IsNedFlanders()
),
)
def handle_ned(self):
...
Migrating from django-lifecycle
The old package is not required. Replace imports as follows:
| Old import | New import |
|---|---|
from django_lifecycle import LifecycleModel |
from enezaframework import EnezaEvents |
from django_lifecycle import LifecycleModelMixin |
from enezaframework import EnezaEventsMixin |
from django_lifecycle import hook |
from enezaframework import hook |
from django_lifecycle import BEFORE_UPDATE, AFTER_UPDATE |
from enezaframework import BEFORE_UPDATE, AFTER_UPDATE |
from django_lifecycle.conditions import WhenFieldHasChanged |
from enezaframework import WhenFieldHasChanged |
from django_lifecycle.conditions.base import ChainableCondition |
from enezaframework import ChainableCondition |
from django_lifecycle import bypass_hooks_for |
from enezaframework import bypass_hooks_for |
from django_lifecycle.decorators import DjangoLifeCycleException |
from enezaframework import EnezaEventsException |
django_lifecycle_checks in INSTALLED_APPS |
Not needed; the check is registered by enezaframework |
For an existing Enezaframework model, remove the old LifecycleModel base entirely. Keep the
Enezaframework base model and the @hook methods.
Behavioral Notes
save()anddelete()are wrapped intransaction.atomic()by the event mixin.- Hooks run around instance
save()anddelete().QuerySet.update(),bulk_create(), and queryset-leveldelete()do not run model hooks. on_commit=Truehooks run after the surrounding transaction commits. If the transaction rolls back, they do not run.- Foreign-key dot-notation can cause extra queries. Use
select_related(). - Hooks are discovered from model class methods. Do not decorate a field, property, or related descriptor.
- A model with
@hookmethods must inherit an Enezaframework base model,EnezaEvents, orEnezaEventsMixin. Theenezaevents.E001system check reports violations.
Testing Responsibility
The user is solely responsible for all testing and validation of models and hooks. Suggested checks:
python manage.py check
python manage.py test tests --settings=tests.settings
Also verify the intended create, update, delete, condition, on_commit, and skip_hooks behavior in
the target application before relying on it in production.