django-lifecycle

repository·master·Indexed 23 days ago

https://github.com/rsinger86/django-lifecycle

A decorator-based approach to adding lifecycle hooks to Django models. It provides a readable alternative to Django Signals or manual save() overrides by allowing developers to define logic that executes at specific points in a model's lifecycle (such as BEFORE_UPDATE or AFTER_CREATE) based on conditions like field changes or value transitions.

Tokens
6.3K
Snippets
18
Records
23
Agent score
80%

What's inside django-lifecycle

  1. Apply conditions to lifecycle hooks

    master

    To prevent a hook from firing on every lifecycle event, use the condition parameter in the @hook decorator. You can use built-in condition classes or provide your own. Conditions can be combined using boolean operators & (AND) and | (OR).

    Built-in Conditions:

    • WhenFieldHasChanged(field_name, has_changed)
    • WhenFieldValueIs(field_name, value)
    • WhenFieldValueIsNot(field_name, value)
    • WhenFieldValueWas(field_name, value)
    • WhenFieldValueWasNot(field_name, value)
    • WhenFieldValueChangesTo(field_name, value)
    from django_lifecycle.conditions import WhenFieldValueChangesTo
    from django_lifecycle import hook, BEFORE_UPDATE
    
    @hook(
        BEFORE_UPDATE, 
        condition=(
            WhenFieldValueChangesTo("first_name", value="Ned")
            & WhenFieldValueChangesTo("last_name", value="Flanders")
        )
    )
    def do_something(self):
        ...
  2. Trigger hooks based on state transitions

    master

    You can restrict a hook to run only when specific field value changes occur by using the condition argument in @hook.

    Use WhenFieldValueWas(field, value) to check the value the field had when the instance was first loaded, and WhenFieldValueIs(field, value) to check the current value. These can be combined using the & operator to define a specific transition (e.g., from 'active' to 'banned'). Passing "*" acts as a wildcard for any value.

    @hook(
        AFTER_UPDATE, 
        condition=(
            WhenFieldValueWas("status", value="active") 
            & WhenFieldValueIs('status', value='banned')
        )
    )
    def email_banned_user(self):
        mail.send_mail(
            'You have been banned', 'You may or may not deserve it.',
            'communitystandards@corporate.com', ['mr.troll@hotmail.com'],
        )
  3. How lifecycle hooks and conditions work

    master

    Lifecycle hooks are triggered by decorating a method with @hook(EVENT, condition=...).

    Events

    Common event constants include:

    • BEFORE_UPDATE
    • AFTER_UPDATE

    Conditions

    Conditions allow you to restrict when a hook runs. You can combine conditions using bitwise operators like & (AND).

    Key condition classes include:

    • WhenFieldHasChanged(field_name, has_changed=True): Triggers if the specified field has changed.
    • WhenFieldValueIs(field_name, value): Triggers if the field's current value matches the provided value.
    • WhenFieldValueWas(field_name, value): Triggers if the field's previous value (before the update) matches the provided value.
  4. Use the @hook decorator to add lifecycle logic

    master

    Instead of using Django Signals or overriding save() and __init__, use the @hook decorator to attach logic to specific lifecycle events. You can use conditions to ensure the hook only runs when specific criteria are met (e.g., when a field has changed or a field matches a specific value).

    Commonly used event constants include BEFORE_UPDATE and AFTER_UPDATE.

    from django_lifecycle import LifecycleModel, hook, BEFORE_UPDATE, AFTER_UPDATE
    
    
    class Article(LifecycleModel):
        contents = models.TextField()
        updated_at = models.DateTimeField(null=True)
        status = models.ChoiceField(choices=['draft', 'published'])
        editor = models.ForeignKey(AuthUser)
    
        @hook(
            BEFORE_UPDATE, 
            condition=WhenFieldHasChanged('contents', has_changed=True),
        )
        def on_content_change(self):
            self.updated_at = timezone.now()
    
        @hook(
            AFTER_UPDATE,
            condition=(
                WhenFieldValueWas("status", value="draft")
                & WhenFieldValueIs("status", value="published")
            ),
        )
        def on_publish(self):
            send_email(self.editor.email, "An article has published!")
  5. Create custom conditions for hooks

    master

    You can define custom logic to control when a @hook is executed.

    Function-based conditions

    A simple function that accepts (instance, update_fields=None) and returns a bool can be passed to the condition argument of a hook.

    Chainable conditions

    To allow complex logic using bitwise operators (like & or |), create a class that inherits from ChainableCondition. This allows you to combine your custom condition with built-in conditions like WhenFieldHasChanged.

    from django_lifecycle import BEFORE_SAVE
    from django_lifecycle.conditions import WhenFieldHasChanged
    from django_lifecycle.conditions.base import ChainableCondition
    
    
    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 foo():
        ...
  6. Watch for changes to fields on a related model

    master

    You can trigger a hook based on the value of a field residing on a related model by using dot-notation in the WhenFieldValueChangesTo condition. For example, employer.name allows you to watch the name field on the Organization model related to UserAccount.

    Performance Warning: N+1 Problem

    Using dot-notation requires the related model to be loaded during the initial model state capture. To avoid a major N+1 performance hit, you must always load these models using .select_related() in your Django QuerySets.

    Example: If watching employer.name, use UserAccount.objects.select_related("employer").

    class Organization(models.Model):
        name = models.CharField(max_length=100)
    
    
    class UserAccount(LifecycleModel):
        username = models.CharField(max_length=100)
        email = models.CharField(max_length=600)
        employer = models.ForeignKey(Organization, on_delete=models.SET_NULL)
    
        @hook(AFTER_UPDATE, condition=WhenFieldValueChangesTo("employer.name", value="Google"))
        def notify_user_of_google_buy_out(self):
            mail.send_mail("Update", "Google bought your employer!", ["to@example.com"],)
  7. Integrate django-lifecycle into your Django models

    master

    You can use django-lifecycle in two ways:

    1. Extend LifecycleModel

    Use the provided abstract base model class.

    from django_lifecycle import LifecycleModel, hook
    
    
    class YourModel(LifecycleModel):
        name = models.CharField(max_length=50)

    2. Use LifecycleModelMixin

    Add the mixin to your existing 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)
  8. Use lifecycle hooks in Django models

    master

    Instead of using Django Signals or overriding save() and __init__ to track state changes, you can use the @hook decorator on methods within a model that inherits from LifecycleModel. This allows you to define logic that executes at specific points in the model's lifecycle (like BEFORE_UPDATE or AFTER_UPDATE) based on specific conditions, such as field changes or specific value transitions.

    To use this, your model must inherit from LifecycleModel and you must import the necessary hook constants and condition classes from django_lifecycle.

    from django_lifecycle import LifecycleModel, hook, BEFORE_UPDATE, AFTER_UPDATE
    from django_lifecycle.conditions import WhenFieldValueIs, WhenFieldValueWas, WhenFieldHasChanged
    
    class Article(LifecycleModel):
        contents = models.TextField()
        updated_at = models.DateTimeField(null=True)
        status = models.ChoiceField(choices=['draft', 'published'])
        editor = models.ForeignKey(AuthUser)
    
        @hook(BEFORE_UPDATE, WhenFieldHasChanged("contents", has_changed=True))
        def on_content_change(self):
            self.updated_at = timezone.now()
    
        @hook(
            AFTER_UPDATE, 
            condition=(
                WhenFieldValueWas("status", value="draft")
                & WhenFieldValueIs("status", value="published")
            )
        )
        def on_publish(self):
            send_email(self.editor.email, "An article has published!")
  9. Prevent state transitions using hooks

    master

    To enforce business rules and prevent certain actions (like deleting an active record), use a BEFORE_* hook combined with a condition. If the condition is met, you can raise an exception within the hooked method to abort the operation.

    @hook(BEFORE_DELETE, condition=WhenFieldValueIs("has_trial", value=True))
    def ensure_trial_not_active(self):
        raise CannotDeleteActiveTrial('Cannot delete trial user!')
  10. Hook into specific lifecycle moments

    master

    Use the @hook decorator to execute logic at specific points in a Django model's lifecycle. Common lifecycle moments include AFTER_CREATE, AFTER_DELETE, and BEFORE_SAVE. You can also use the on_commit=True argument to ensure the hooked method only runs after the database transaction has been successfully committed, which is useful for enqueuing background jobs.

    @hook(AFTER_CREATE)
    def do_after_create_jobs(self):
        enqueue_job(process_thumbnail, self.picture_url)
    
        mail.send_mail(
            'Welcome!', 'Thank you for joining.',
            'from@example.com', ['to@example.com'],
        )
    
    @hook(AFTER_DELETE)
    def email_deleted_user(self):
        mail.send_mail(
            'We have deleted your account', 'We will miss you!.',
            'customerservice@corporate.com', ['human@gmail.com'],
        )
    
    @hook(AFTER_CREATE, on_commit=True)
    def do_after_create_jobs(self):
        enqueue_job(send_item_shipped_notication, self.item_id)
  11. Watch for changes to a ForeignKey reference

    master

    To trigger a hook when a ForeignKey field itself is changed (i.e., the ID of the related object changes), use WhenFieldHasChanged in the condition parameter of the @hook decorator. Pass the name of the ForeignKey field as a string to the when parameter of the condition.

    Note: This monitors the database column storing the foreign key (e.g., employer_id), not the attributes of the related object.

    class Organization(models.Model):
        name = models.CharField(max_length=100)
    
    
    class UserAccount(LifecycleModel):
        username = models.CharField(max_length=100)
        email = models.CharField(max_length=600)
        employer = models.ForeignKey(Organization, on_delete=models.SET_NULL)
    
        @hook(AFTER_UPDATE, condition=WhenFieldHasChanged("employer", has_changed=True))
        def notify_user_of_employer_change(self):
            mail.send_mail("Update", "You now work for someone else!", [self.email])