Lifecycle Hooks (Events)

Developer guide for the Enezaframework. Part of the Developer documentation.

Table of Contents

  1. Overview
  2. Automatic Support on Eneza Models
  3. Standalone Event Models
  4. Import Reference
  5. Hook Moments
  6. Conditions
  7. Foreign-Key Changes
  8. Utility Methods
  9. Priority and On-Commit Hooks
  10. Suppressing Hooks
  11. Custom Conditions
  12. Migrating from django-lifecycle
  13. Behavioral Notes
  14. Testing Responsibility
  15. 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.

python
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:

python
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:

python
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:

python
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:

python
@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:

python
@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 |:

python
@hook(
    AFTER_UPDATE,
    condition=(
        WhenFieldValueWas("status", value="active")
        & WhenFieldValueIs("status", value="banned")
    ),
)
def email_banned_user(self):
    ...

Example: only fire when a field changed:

python
@hook(BEFORE_UPDATE, condition=WhenFieldHasChanged("email", has_changed=True))
def timestamp_email_change(self):
    ...

Example: fire when a field is not a specific value:

python
@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:

python
@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:

python
@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:

python
@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:

python
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()
python
@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:

python
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:

python
@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:

python
article.save(skip_hooks=True)

Skip hooks for every save inside a block:

python
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:

python
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:

python
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() and delete() are wrapped in transaction.atomic() by the event mixin.
  • Hooks run around instance save() and delete(). QuerySet.update(), bulk_create(), and queryset-level delete() do not run model hooks.
  • on_commit=True hooks 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 @hook methods must inherit an Enezaframework base model, EnezaEvents, or EnezaEventsMixin. The enezaevents.E001 system check reports violations.

Testing Responsibility

The user is solely responsible for all testing and validation of models and hooks. Suggested checks:

powershell
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.