Giter VIP home page Giter VIP logo

django-lifecycle's Introduction

Django Lifecycle Hooks

Package version Python versions Python versions PyPI - Django Version

This project provides a @hook decorator as well as a base model and mixin to add lifecycle hooks to your Django models. Django's built-in approach to offering lifecycle hooks is Signals. However, my team often finds that Signals introduce unnecessary indirection and are at odds with Django's "fat models" approach.

Django Lifecycle Hooks supports:

  • Python 3.7, 3.8, 3.9, 3.10, 3.11, and 3.12
  • Django 2.2, 3.2, 4.0, 4.1, 4.2, and 5.0

In short, you can write model code like this:

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, 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!")

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._orig_contents = self.contents
        self._orig_status = self.status


    def save(self, *args, **kwargs):
        if self.pk is not None and self.contents != self._orig_contents:
            self.updated_at = timezone.now()

        super().save(*args, **kwargs)

        if self.status != self._orig_status:
            send_email(self.editor.email, "An article has published!")

Documentation: https://rsinger86.github.io/django-lifecycle

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


Changelog

See Changelog

Testing

Tests are found in a simplified Django project in the /tests folder. Install the project requirements and do ./manage.py test to run them.

License

See License.

django-lifecycle's People

Contributors

abdullahkady avatar adamchainz avatar alaanour94 avatar alb3rto269 avatar amcclosky avatar amertz08 avatar amiralaghmandan avatar atugushev avatar avallbona avatar bahmdev avatar bmbouter avatar bmispelon avatar coredumperror avatar dependabot[bot] avatar dmytrolitvinov avatar dralley avatar enriquesoria avatar faisal-manzer avatar garyd203 avatar jacoduplessis avatar jpmelos avatar nextmat avatar partizaans avatar rsinger86 avatar samitnuk avatar simkimsia avatar sodrooome avatar thejoeejoee avatar tomdyson avatar udit-001 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

django-lifecycle's Issues

select_related doesn't work with ForeignKey or OneToOneField

When you use the dot notation in @hook decorator for the related fields (ForeignKey or OneToOneField) it hits the database for every object separately. It doesn't matter if you use select_related or not.
Here are the models to test:

from django.contrib.auth.models import User
from django.db import models

from django_lifecycle import LifecycleModel, hook, AFTER_SAVE


class Organization(models.Model):
    name = models.CharField(max_length=250)


class Profile(LifecycleModel):
    user = models.OneToOneField(User, on_delete=models.CASCADE, null=True)
    employer = models.ForeignKey(Organization, on_delete=models.SET_NULL, null=True)
    bio = models.TextField(null=True, blank=True)
    age = models.PositiveIntegerField(null=True, blank=True)

    @hook(AFTER_SAVE, when='user.first_name', has_changed=True)
    @hook(AFTER_SAVE, when='user.last_name', has_changed=True)
    def user_changed(self):
        print('User was changed')

    @hook(AFTER_SAVE, when='employer.name', has_changed=True)
    def employer_changed(self):
        print('Employer was changed')

What I got when tried to fetch profiles (with db queries logging):

>>> from main.models import Profile
>>> queryset = Profile.objects.all()[:10]
>>> queryset
(0.000) SELECT "main_profile"."id", "main_profile"."user_id", "main_profile"."employer_id", "main_profile"."bio", "main_profile"."age" FROM "main_profile" LIMIT 10; args=(); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 2 LIMIT 21; args=(2,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 3 LIMIT 21; args=(3,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 4 LIMIT 21; args=(4,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 5 LIMIT 21; args=(5,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 6 LIMIT 21; args=(6,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 7 LIMIT 21; args=(7,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 8 LIMIT 21; args=(8,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 9 LIMIT 21; args=(9,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 10 LIMIT 21; args=(10,); alias=default
<QuerySet [<Profile: Profile object (1)>, <Profile: Profile object (2)>, <Profile: Profile object (3)>, <Profile: Profile object (4)>, <Profile: Profile object (5)>, <Profile: Profile object (6)>, <Profile: Profile object (7)>, <Profile: Profile object (8)>, <Profile: Profile object (9)>, <Profile: Profile object (10)>]>

>>> queryset = Profile.objects.select_related('user')[:10]
>>> queryset
(0.001) SELECT "main_profile"."id", "main_profile"."user_id", "main_profile"."employer_id", "main_profile"."bio", "main_profile"."age", "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "main_profile" LEFT OUTER JOIN "auth_user" ON ("main_profile"."user_id" = "auth_user"."id") LIMIT 10; args=(); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 2 LIMIT 21; args=(2,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 3 LIMIT 21; args=(3,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 4 LIMIT 21; args=(4,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 5 LIMIT 21; args=(5,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 6 LIMIT 21; args=(6,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 7 LIMIT 21; args=(7,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 8 LIMIT 21; args=(8,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 9 LIMIT 21; args=(9,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 10 LIMIT 21; args=(10,); alias=default
<QuerySet [<Profile: Profile object (1)>, <Profile: Profile object (2)>, <Profile: Profile object (3)>, <Profile: Profile object (4)>, <Profile: Profile object (5)>, <Profile: Profile object (6)>, <Profile: Profile object (7)>, <Profile: Profile object (8)>, <Profile: Profile object (9)>, <Profile: Profile object (10)>]>

>>> queryset = Profile.objects.select_related('user', 'employer')[:10]
>>> queryset
(0.001) SELECT "main_profile"."id", "main_profile"."user_id", "main_profile"."employer_id", "main_profile"."bio", "main_profile"."age", "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined", "main_organization"."id", "main_organization"."name" FROM "main_profile" LEFT OUTER JOIN "auth_user" ON ("main_profile"."user_id" = "auth_user"."id") LEFT OUTER JOIN "main_organization" ON ("main_profile"."employer_id" = "main_organization"."id") LIMIT 10; args=(); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 2 LIMIT 21; args=(2,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 3 LIMIT 21; args=(3,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 4 LIMIT 21; args=(4,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 5 LIMIT 21; args=(5,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 6 LIMIT 21; args=(6,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 7 LIMIT 21; args=(7,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 8 LIMIT 21; args=(8,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 9 LIMIT 21; args=(9,); alias=default
(0.000) SELECT "main_organization"."id", "main_organization"."name" FROM "main_organization" WHERE "main_organization"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = 10 LIMIT 21; args=(10,); alias=default
<QuerySet [<Profile: Profile object (1)>, <Profile: Profile object (2)>, <Profile: Profile object (3)>, <Profile: Profile object (4)>, <Profile: Profile object (5)>, <Profile: Profile object (6)>, <Profile: Profile object (7)>, <Profile: Profile object (8)>, <Profile: Profile object (9)>, <Profile: Profile object (10)>]>

"LifecycleModelMixin" should put to last position.

I got some wired error massages when I followed the documention, but when I changed "LifecycleModelMixin" to last position. Everything ran fine.

May I suggest to switch the position of "models.Model" and "LifecycleModelMixin" with each other.

AttributeError: 'MoneyField' object has no attribute 'get_internal_type'

When I use MoneyField I get error:

freelance/conftest.py:62: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
/usr/local/lib/python3.8/site-packages/factory/base.py:46: in __call__
    return cls.create(**kwargs)
/usr/local/lib/python3.8/site-packages/factory/base.py:564: in create
    return cls._generate(enums.CREATE_STRATEGY, kwargs)
/usr/local/lib/python3.8/site-packages/factory/django.py:141: in _generate
    return super(DjangoModelFactory, cls)._generate(strategy, params)
/usr/local/lib/python3.8/site-packages/factory/base.py:501: in _generate
    return step.build()
/usr/local/lib/python3.8/site-packages/factory/builder.py:272: in build
    step.resolve(pre)
/usr/local/lib/python3.8/site-packages/factory/builder.py:221: in resolve
    self.attributes[field_name] = getattr(self.stub, field_name)
/usr/local/lib/python3.8/site-packages/factory/builder.py:372: in __getattr__
    value = value.evaluate(
/usr/local/lib/python3.8/site-packages/factory/declarations.py:321: in evaluate
    return self.generate(step, defaults)
/usr/local/lib/python3.8/site-packages/factory/declarations.py:411: in generate
    return step.recurse(subfactory, params, force_sequence=force_sequence)
/usr/local/lib/python3.8/site-packages/factory/builder.py:233: in recurse
    return builder.build(parent_step=self, force_sequence=force_sequence)
/usr/local/lib/python3.8/site-packages/factory/builder.py:276: in build
    instance = self.factory_meta.instantiate(
/usr/local/lib/python3.8/site-packages/factory/base.py:315: in instantiate
    return self.factory._create(model, *args, **kwargs)
/usr/local/lib/python3.8/site-packages/factory/django.py:185: in _create
    return manager.create(*args, **kwargs)
/usr/local/lib/python3.8/site-packages/django/db/models/manager.py:82: in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
/usr/local/lib/python3.8/site-packages/django/db/models/query.py:431: in create
    obj = self.model(**kwargs)
/usr/local/lib/python3.8/site-packages/django_lifecycle/mixins.py:16: in __init__
    self._initial_state = self._snapshot_state()
/usr/local/lib/python3.8/site-packages/django_lifecycle/mixins.py:21: in _snapshot_state
    for watched_related_field in self._watched_fk_model_fields:
/usr/local/lib/python3.8/site-packages/django/utils/functional.py:48: in __get__
    res = instance.__dict__[self.name] = self.func(instance)
/usr/local/lib/python3.8/site-packages/django_lifecycle/mixins.py:164: in _watched_fk_model_fields
    for method in self._potentially_hooked_methods:
/usr/local/lib/python3.8/site-packages/django/utils/functional.py:48: in __get__
    res = instance.__dict__[self.name] = self.func(instance)
/usr/local/lib/python3.8/site-packages/django_lifecycle/mixins.py:140: in _potentially_hooked_methods
    skip = set(get_unhookable_attribute_names(self))
/usr/local/lib/python3.8/site-packages/django_lifecycle/utils.py:65: in get_unhookable_attribute_names
    _get_field_names(instance)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

instance = <Order: Order #006>

    def _get_field_names(instance) -> List[str]:
        names = []
    
        for f in instance._meta.get_fields():
            names.append(f.name)
    
>           if instance._meta.get_field(f.name).get_internal_type() == "ForeignKey":
E           AttributeError: 'MoneyField' object has no attribute 'get_internal_type'

/usr/local/lib/python3.8/site-packages/django_lifecycle/utils.py:57: AttributeError

`when_any` runs hook once per field changed

Seems like when multiple tracked fields in when_any change, the hook is fired that many times, rather than once as one would expect. #8 seems to address this but isn't merged.

class MyModel(LifecycleModelMixin, BaseModel):
   ...
    @hook(AFTER_UPDATE, when_any=['foo', 'bar'], has_changed=True)
    def run_hook(self):
       print("hey")
    

model = MyModel()
model.foo = 1
model.bar = 2
model.save()
# prints "hey" twice

This does not appear to be the behavior when when_any is omitted.

Issues when using lifecycle with urlman (or in general with metaprogramming?)

First of all.. Great job! This library simplifies a lot of stuff in my project :)

I am also depending on urlman that ultimately relies on some metaprogramming magic. Unfortunately when using it together with django-lifecycle I got a ValueError. Consider this untested toy example:

from django_lifecycle import LifecycleModel, hook

import urlman

class Book(LifecycleModel):
    author = models.ForeignKey('user.User')

    class urls(urlman.Urls):
        view = "/books/{self.pk}/"

    @hook('after_update', when='author_id', has_changed=True)
    def on_author_change(self):
        do_thing()

Then when saving a Book instance I got the error.

Traceback (most recent call last):
    ... omitted ...
    self.instance.save()
  File "/Users/andrea/venvs/foo/lib/python3.7/site-packages/django_lifecycle/__init__.py", line 157, in save
    self._run_hooked_methods("before_update")
  File "/Users/andrea/venvs/foo/lib/python3.7/site-packages/django_lifecycle/__init__.py", line 254, in _run_hooked_methods
    for method in self._potentially_hooked_methods:
  File "/Users/andrea/venvs/foo/lib/python3.7/site-packages/django/utils/functional.py", line 80, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
  File "/Users/andrea/venvs/foo/lib/python3.7/site-packages/django_lifecycle/__init__.py", line 241, in _potentially_hooked_methods
    if hasattr(attr, "_hooked"):
  File "/Users/andrea/venvs/foo/lib/python3.7/site-packages/urlman.py", line 63, in __getattr__
    return self.get_url(attr)
  File "/Users/andrea/venvs/foo/lib/python3.7/site-packages/urlman.py", line 71, in get_url
    (attr, self.instance.__class__.__name__))
ValueError: No URL called '_hooked' on 'XXXXX'

The issue is solved by specifying _hooked in the urls class but meh..

Please let me know if you think that this is a urlman related problem.

LifecycleModelMixin returns None for delete

By default, Django's ORM has a return value for delete(), as per: https://docs.djangoproject.com/en/3.1/ref/models/querysets/#django.db.models.query.QuerySet.delete

It looks like LifecycleModelMixin's code is:

    def delete(self, *args, **kwargs):
        self._run_hooked_methods(BEFORE_DELETE)
        super().delete(*args, **kwargs)
        self._run_hooked_methods(AFTER_DELETE)

Hence, it returns None in the absence of any AFTER_DELETE hooks.

If this is not intended, happy to open a PR to fix. Seems like we should not change the return value of delete.

Lifecycle hook not triggered

Hi,

I've just tried implementing django-lifecycle into my project, but I'm having a hard time getting started.

My model looks like this:

class MyModel(LifecycleModel):
    ...
    model = models.CharField(max_length=200)
    ...

    @hook('before_update', when='model', has_changed=True)
    def on_content_change(self):
        self.model = 'test'

for some reason, the hook doesn't seem to be triggered. I've also tried stacking decorators to include other moments with the same result.

Conversely, this works:

class MyModel(LifecycleModel):
    ...
    model = models.CharField(max_length=200)
    ...

    def save(self, *args, **kwargs):
        self.model = 'test'
        super(MyModel, self).save(*args, **kwargs)

Am I missing something in my implementation? I'm on Django 2.2.8, python 3.7, and django-lifecycle 0.7.1.

Callables for is_now?

I wonder if you'd considered allowing callable for is_now and was conditions? For example, if this function:

    def _check_is_now_condition(self, field_name: str, specs: dict) -> bool:
        return specs["is_now"] in (self._current_value(field_name), "*")

Was changed to something like:

    def _check_is_now_condition(self, field_name: str, specs: dict) -> bool:
        if callable(specs["is_now"]):
           return specs["is_now"](self)
        else:
           return specs["is_now"] in (self._current_value(field_name), "*")

Then users could pass a callable and implement any logic they like in relation to the tested values.

id/pk field is None AFTER_DELETE

It's not a big deal to the workflow I am trying to build, but I was surprised to see that a model's id/pk field is set to None by the time the handler for AFTER_DELETE is called. All other fields retain their values. Is this expected behavior?

Possible for was_not and is_now to take in multiple values?

Hi I have a case where the same implementation, but I hope not to repeat myself.

Instead of having two sets of

@hook(
        AFTER_UPDATE,
        when="status",
        was_not="rejected_by_vendor",
        is_now="rejected_by_vendor"
    )
    def change_lineitems_to_quotation_rejected(self):
        # blah

@hook(
        AFTER_UPDATE,
        when="status",
        was_not="rejected_by_customer",
        is_now="rejected_by_customer"
    )
    def same_change_lineitems_to_quotation_rejected(self):
        # same blah

I hope to have

@hook(
        AFTER_UPDATE,
        when="status",
        was_not_any=["rejected_by_vendor", "rejected_by_customer"],
        is_now_any=["rejected_by_vendor", "rejected_by_customer"]
    )
    def same_change_lineitems_to_quotation_rejected(self):
        # same blah

Can help?

Currently I chose

def change_lineitems_to_quotation_rejected_because_customer(self):
   # the blah is here

@hook(
        AFTER_UPDATE,
        when="status",
        was_not="rejected_by_vendor",
        is_now="rejected_by_vendor"
    )
    def change_lineitems_to_quotation_rejected_because_vendor(self):
          self.change_lineitems_to_quotation_rejected()

@hook(
        AFTER_UPDATE,
        when="status",
        was_not="rejected_by_customer",
        is_now="rejected_by_customer"
    )
    def change_lineitems_to_quotation_rejected_because_customer(self):
        self.change_lineitems_to_quotation_rejected()

changes on related models don't trigger the hooked methods

I think you SHOULD update the documentation because it caused me to waste lot of time trying to figure out why foreign keys triggers are not working before reading that you don't support that and are not planning to do that either.

Thank You!!!

Coming from Rails - where callbacks like before_update, after_create etc. are part of the core - I could not understand why things were so much harder in Django.

Which is why I am so glad I found this package. It's made coding so much more pleasant.

No issue to report here. Just wanted to say "Thank you"

Cheers,
Abhinav.

Using @hook on a model with a GenericForeignKey doesn't work

Not sure I am doing something wrong, but I used @hook on a model with a GenericForeignKey and this was the result...

in _get_field_names
    if instance._meta.get_field(f.name).get_internal_type() == "ForeignKey":
AttributeError: 'GenericForeignKey' object has no attribute 'get_internal_type'

The underlying model:

class AbstractCheckbox(models.Model):
    class Meta:
        abstract = True
    is_checked = models.BooleanField(default=False, db_index=True)

class Checkbox(AbstractCheckbox, LifecycleModelMixin):
    content_type = models.ForeignKey(ContentType,null=True, blank=True, on_delete=models.PROTECT)
    object_id = models.PositiveIntegerField(null=True, blank=True, db_index=True)
    parent = GenericForeignKey(ct_field='content_type', fk_field='object_id')

    @hook('after_update', when='is_checked', has_changed=True)
    def checkbox_changed(self):
          pass

Adding hooks to 3rd-party objects

Our code provides some django-models and we use django-lifecycle. Our models are also used in a library-like way where other django-projects want to provide some post-creation workflows after our code creates those model objects.

How can we add a hook to a model without monkey patching it? The other code can't subclass our model because then our code won't be using their subclassed type.

It would be great if there was a way to register a hook onto a model in a way other than class definition somehow. If you have any pointers on how to add this, I wouldn't mind implementing it.

Allow list for "when" parameter

In my projects, it's been a little cumbersome to stack decorators when you want to react to multiple fields changing:

    @hook("after_update", when="published", has_changed=True)
    @hook("after_update", when="path", has_changed=True)
    @hook("after_update", when="type", has_changed=True)
    def handle_update(self):
        # do something

I see a couple of ways to handle this:

Option 1:

    @hook("after_update", when=["type", "path", "published"], has_changed=True)
    def handle_update(self):
        # do something

Option 2:

    @hook("after_update", when_any=["type", "path", "published"], has_changed=True)
    def handle_update(self):
        # do something

Option 2 would enable adding a when_all parameter later on if we want to get a little fancy with boolean logic. On the other hand, maybe it's best to keep this simple - nothing wrong with stacking decorators. Any feedback would be appreciated.

@hook does not work if model does not inherit from LifecycleModelMixin first

Hi there! I've just created a simple model:

class SomeModel(models.Model, LifecycleModelMixin):
    is_active = models.BooleanField(default=False)

    def __str__(self):
        return str(self.is_active)

    @hook(AFTER_UPDATE, when='is_active', was=True, is_now=False)
    def is_active_signal(self):
        print('works')

It didn't work until I inverted models.Model and LifecycleModelMixin. I donโ€™t know if this is a bug or not, but if not, then it should be described in the documentation, I guess.

Best wishes.
Raman.

Hooks are falsely triggered when using the `update_fields` kwarg in `save` method

This is not a duplicate of #43, or at least it was not described well enough there.

The issue happens when using update_fields.
Fields that are not included in the update_fields are seen as normal changes, thus firing any hooks associated with them. In reality, this shouldn't happen; since it's pretty clear that these changes won't be committed/persisted in this save call.

Example

If we run the following:

class SomeModel(LifecycleModel):
    f1 = models.CharField(max_length=100, default="f1-value")
    f2 = models.CharField(max_length=100, default="f2-value")
    
    @hook("before_update", when="f1", has_changed=True)
    def hook_fired_when_f1_changes(self):
        print("f1 has been changed!")

#####################

instance = SomeModel.objects.create()
instance.f1 = "x"
instance.f2 = "y"
instance.save(update_fields=["f2"])

This will print out f1 has been changed!, meaning that hook_fired_when_f1_changes was fired. In reality, only f2 was saved. We can check this by running the following:

instance = SomeModel.objects.get()  # or refresh_from_db()
instance.f1  # "f1-value"
instance.f2  # "y"

`initial_value()` not working as expected, returning None

Using this quite simple code:

In [13]: m = SomeModel.objects.last()

In [14]: m.initial_value('status')
>> None 

In [15]: m.status = 'foo'

In [18]: m.initial_value('status')
>> 'active'

I noticed that the initial_value method returns None on any field unless the value has been changed, which seems like a bug.

How can I pass the request object into hook code

Hello.

In some parts of my django application I use the "self.request" object, e.g. in class based views, when I want to send an email to a user. With django-lifecycle I can't to do it.

So, can someone to give me an advice in this problem? Thanks.

"atomic"-ness of hooks should be configureable or removed

First of all, thanks for this library. The API is really well done.

However, today I discovered that in #85 the change was made to force hooks to run inside of a transaction, which for many cases is desirable behavior, however one of my uses for lifecycle hooks is to queue background jobs in AFTER_SAVE assuming any calls to the model's save() will either observe the default django orm autocommit behavior or abide by whatever the behavior set by its current context will be.

Forcing a transaction / savepoint that wraps all model hooks using the atomic decorator means you can't safely make a call to an external service(or queue a celery / rq job) and assume the model changes will be visible. For example, it is not unusual for a background job to begin execution before the transaction that queued the job commits.

I'd be happy to open a PR that either reverts #85 or makes the current behavior configureable in some way depending no your preference if you are open to it.

How to deal with save(update_fields=['foo', bar']) ?

Hi, thanx for a very interesting alternative to django signals!

Does you package tackle with save(update_fields=[..]) the right way? Fo example I changed two fields on the instance, then I called save(update_fields=['field1', 'field2']) and earlier I declared a hook to update some field3 on field1, field2 updates. Will field3 be updated despite of update_fields kwarg in save()?

Missing dependency on "packaging"

Our application (Pulp 3, aka pulpcore) is failing with this error:

Traceback (most recent call last):
  File "/usr/local/lib/pulp/bin/pulpcore-manager", line 8, in <module>
    sys.exit(manage())
  File "/usr/local/lib/pulp/lib/python3.9/site-packages/pulpcore/app/manage.py", line 11, in manage
    execute_from_command_line(sys.argv)
  File "/usr/local/lib/pulp/lib/python3.9/site-packages/django/core/management/__init__.py", line 419, in execute_from_command_line
    utility.execute()
  File "/usr/local/lib/pulp/lib/python3.9/site-packages/django/core/management/__init__.py", line 395, in execute
    django.setup()
  File "/usr/local/lib/pulp/lib/python3.9/site-packages/django/__init__.py", line 24, in setup
    apps.populate(settings.INSTALLED_APPS)
  File "/usr/local/lib/pulp/lib/python3.9/site-packages/django/apps/registry.py", line 114, in populate
    app_config.import_models()
  File "/usr/local/lib/pulp/lib/python3.9/site-packages/django/apps/config.py", line 301, in import_models
    self.models_module = import_module(models_module_name)
  File "/usr/lib/python3.9/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
  File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 790, in exec_module
  File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
  File "/usr/local/lib/pulp/lib/python3.9/site-packages/pulpcore/app/models/__init__.py", line 4, in <module>
    from .base import (  # noqa
  File "/usr/local/lib/pulp/lib/python3.9/site-packages/pulpcore/app/models/base.py", line 9, in <module>
    from django_lifecycle import LifecycleModel
  File "/usr/local/lib/pulp/lib/python3.9/site-packages/django_lifecycle/__init__.py", line 1, in <module>
    from .django_info import IS_GTE_1_POINT_9
  File "/usr/local/lib/pulp/lib/python3.9/site-packages/django_lifecycle/django_info.py", line 1, in <module>
    from packaging.version import Version
ModuleNotFoundError: No module named 'packaging'

Manually installing packaging fixes it.

Perhaps it should be listed in install_requires?:
https://github.com/rsinger86/django-lifecycle/blob/master/setup.py#L56

Order in which hooks are executed

Is it possible to control somehow the order in which hooks are executed?

My use case is something like this:

class Festival(LifecycleModelMixin, models.Model):
    name = models.CharField(max_length=200)
    slug = models.SlugField(unique=True, null=True, blank=True)

    @hook(BEFORE_CREATE)
    def set_slug(self):
        self.slug = generate_slug(self.name)

    @hook(BEFORE_CREATE)
    def do_something_with_slug(self):
        print(f"Here we want to use our slug, but it could be None: {self.slug}")

Support see changes for columns added during __init__

Hello, today I tried to move some our signals to the hooks and realised that if column was added into model init then it is shown as not changed.
Ex:

class Reservation(DirtyFieldsMixin, LifecycleModelMixin, models.Model):
  ...

In [15]: r = Reservation(edit_comment='test')

In [16]: r.has_changed('edit_comment')
Out[16]: False

In [17]: r.get_dirty_fields(check_relationship=True, verbose=True).get('edit_comment')
Out[17]: {'saved': None, 'current': 'test'}

In [18]: r = Reservation()

In [19]: r.edit_comment = 'test'

In [20]: r.has_changed('edit_comment')
Out[20]: True

So, if column is changed AFTER init - it is shown as changed, if into init - not.
Is it possible to cover this case too?

Watching for ForeignKey value changes seems to not trigger the hook

Using the below example in version 0.6.0, I am expecting to print a message any time someone changes a user's first name that is saved in SomeModel. From what I can tell from the documentation, I am setting everything up correctly. Am I misunderstanding how this works?

Assuming a model set up like this:

from django.conf import settings
from django.db import models
from django_lifecycle import LifecycleModel, hook

class SomeModel(LifecycleModel):
    user = models.ForeignKey(
        on_delete=models.CASCADE,
        to=settings.AUTH_USER_MODEL
    )
    # More fields here...

    @hook('after_update', when='user.first_name', has_changed=True)
    def user_first_name_changed(self):
        print(
            f"User's first_name has changed from "
            f"{self.initial_value('user.first_name')} to {user.first_name}!"
        )

When we then perform the following code, nothing prints:

from django.contrib.auth import get_user_model

# Create a test user (Jane Doe)
get_user_model().objects.create_user(
    username='test', 
    password=None, 
    first_name='Jane', 
    last_name='Doe'
)

# Create an instance
SomeModel.objects.create(user=user)

# Retrieve a new instance of the user
user = get_user_model().objects.get(username='test')

# Change the name (John Doe)
user.first_name = 'John'
user.save()

# Nothing prints from the hook

In the tests, I see that it is calling user_account._clear_watched_fk_model_cache() explicitly after changing the Organization.name. However, from looking at the code, I do not see this call anywhere except for the overridden UserAccount.save() method. Thus, saving the Organization has no way to notify the UserAccount that a change has been made, and therefore, the hook cannot possibly be fired. The only reason that I can see that the test is passing is because of the explicit call to user_account._clear_watched_fk_model_cache().

    def test_has_changed_is_true_if_fk_related_model_field_has_changed(self):
        org = Organization.objects.create(name="Dunder Mifflin")
        UserAccount.objects.create(**self.stub_data, organization=org)
        user_account = UserAccount.objects.get()

        org.name = "Dwight's Paper Empire"
        org.save()
        user_account._clear_watched_fk_model_cache()
        self.assertTrue(user_account.has_changed("organization.name"))

Should has_changed/initial_value work with on_commit hooks?

First of all, thanks for this project. I used to rely a lot on the built-in django signals. However, I have a project that is growing fast and django-lifecycle is helping us to bring some order to all these before_* and after_* actions.

One of my use-cases requires 2 features that django-lifecycle offers:

  • The ability to compare against the initial state, i.e. obj.has_changed('field_name').
  • Running hooks on commit to trigger background tasks.

Both features work well by separate. However calling has_changed or initial_value from a on_commit hook compares against the already saved state.

Looking into the code I noticed that the reason is that the save method resets the _inital_state just before returning:

[django_lifecycle/mixins.py#L177]

@transaction.atomic
def save(self, *args, **kwargs):
    # run before_* hooks
    save(...)
    # run after_* hooks

    self._initial_state = self._snapshot_state()

To reproduce the issue you can use this case:

from django_lifecycle import LifecycleModel, AFTER_UPDATE, hook


class MyModel(LifecycleModel):
    foo = models.CharField(max_length=3)

    @hook(AFTER_UPDATE, on_commit=True)
    def my_hook(self):
        assert self.has_changed('foo')   # <-- fails
obj = MyModel.objects.create(foo='bar')
obj.foo = 'baz'
obj.save()

I think It is arguable if this behavior is expected or if it is a bug. If it is expected, probably we should add a note in the docs mentioning that has_changed and initial_state does not make sense with on_commit=True. If it is a bug, any idea how to address it? I can contribute with a PR if necessary and if we agree on a solution.

Add "changes_to" condition

Instead of this:

    @hook("after_update", when="status", was_not="active", is_now="active")
    def do_something(self):
        # do something

Would allow this:

    @hook("after_update", when="status", changes_to="active")
    def do_something(self):
        # do something

Using `only` queryset method leads to a RecursionError

I tried to query a model using LifecycleModel class and when doing an only('id') I got a RecursionError

    res = instance.__dict__[self.name] = self.func(instance)
  File "/app/.heroku/python/lib/python3.7/site-packages/django_lifecycle/mixins.py", line 169, in _watched_fk_model_fields
    for method in self._potentially_hooked_methods:
  File "/app/.heroku/python/lib/python3.7/site-packages/django/utils/functional.py", line 80, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
  File "/app/.heroku/python/lib/python3.7/site-packages/django_lifecycle/mixins.py", line 152, in _potentially_hooked_methods
    attr = getattr(self, name)
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/query_utils.py", line 135, in __get__
    instance.refresh_from_db(fields=[self.field_name])
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/base.py", line 628, in refresh_from_db
    db_instance = db_instance_qs.get()
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/query.py", line 402, in get
    num = len(clone)
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/query.py", line 256, in __len__
    self._fetch_all()
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/query.py", line 1242, in _fetch_all
    self._result_cache = list(self._iterable_class(self))
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/query.py", line 55, in __iter__
    results = compiler.execute_sql(chunked_fetch=self.chunked_fetch, chunk_size=self.chunk_size)
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/sql/compiler.py", line 1127, in execute_sql
    sql, params = self.as_sql()
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/sql/compiler.py", line 474, in as_sql
    extra_select, order_by, group_by = self.pre_sql_setup()
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/sql/compiler.py", line 54, in pre_sql_setup
    self.setup_query()
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/sql/compiler.py", line 45, in setup_query
    self.select, self.klass_info, self.annotation_col_map = self.get_select()
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/sql/compiler.py", line 219, in get_select
    cols = self.get_default_columns()
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/sql/compiler.py", line 641, in get_default_columns
    only_load = self.deferred_to_columns()
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/sql/compiler.py", line 1051, in deferred_to_columns
    self.query.deferred_to_data(columns, self.query.get_loaded_field_names_cb)
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/sql/query.py", line 680, in deferred_to_data
    add_to_dict(seen, model, field)
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/sql/query.py", line 2167, in add_to_dict
    data[key] = {value}
  File "/app/.heroku/python/lib/python3.7/site-packages/django/db/models/fields/__init__.py", line 508, in __hash__
    return hash(self.creation_counter)
RecursionError: maximum recursion depth exceeded while calling a Python object

README.md not included in dist?

I ran into this earlier and it looks like maybe your README.md is not being included in 0.4.1:

Collecting django-lifecycle
  Using cached https://files.pythonhosted.org/packages/d4/ab/9daddd333fdf41bf24da744818a00ce8caa8e39d93da466b752b291ce412/django-lifecycle-0.4.1.tar.gz
    ERROR: Complete output from command python setup.py egg_info:
    ERROR: Traceback (most recent call last):
      File "<string>", line 1, in <module>
      File "/private/var/folders/pb/j_dpdd4n0858j1ym98g3884r0000gn/T/pip-install-x3_d5nyd/django-lifecycle/setup.py", line 30, in <module>
        long_description=readme(),
      File "/private/var/folders/pb/j_dpdd4n0858j1ym98g3884r0000gn/T/pip-install-x3_d5nyd/django-lifecycle/setup.py", line 7, in readme
        with open("README.md", "r") as infile:
      File "/Users/jefftriplett/.pyenv/versions/3.6.5/lib/python3.6/codecs.py", line 897, in open
        file = builtins.open(filename, mode, buffering)
    FileNotFoundError: [Errno 2] No such file or directory: 'README.md'
    ----------------------------------------
ERROR: Command "python setup.py egg_info" failed with error code 1 in /private/var/folders/pb/j_dpdd4n0858j1ym98g3884r0000gn/T/pip-install-x3_d5nyd/django-lifecycle/

DeprecationWarning for Python 3.12

Python 3.10 shows the following warning regarding a future deprecation in Python 3.12:

  /usr/local/lib/python3.10/site-packages/django_lifecycle/django_info.py:1: DeprecationWarning: The distutils package is deprecated and slated for removal in Python 3.12. Use setuptools or check PEP 632 for potential alternatives
    from distutils.version import StrictVersion

It would be nice to fix it at some point.

Use multiple hooks as AND condition

How to execute method only if all hooks conditions is True?
For example

@hook(AFTER_SAVE, when='foo', was='bar')
@hook(AFTER_SAVE, when='something', is_now='value')
def my_method(self):
pass

I tested this, but my_method runs when only one of the hook condition is True as OR condition

Run hooks within a single transaction

Django save signals are run within the same transaction. So if something goes wrong in post_save for example the instance is never saved als the transaction is rolled back. Would be nice to have the same behavior for lifecycle hooks

Cascade delete not firing BEFORE/AFTER_DELETE hooks?

Hi,
I have 2 models:

class Model_A(LifecycleModelMixin, models.Model):
    code = models.CharField(max_length=50)

class Model_B(LifecycleModelMixin, models.Model):
    code = models.CharField(max_length=50)
    model_a = models.ForeignKey(to="app.Model_A", on_delete=models.CASCADE, blank=True, null=True)

    @hook(AFTER_DELETE)
    def do_after_delete(self):
        print("AFTER_DELETE")

    @hook(BEFORE_DELETE)
    def do_before_delete(self):
        print("BEFORE_DELETE")

It seems like lifecycle hooks are not triggered when I delete one instance of Model_A which has a couple of Model_B children.
Hooks are normally triggered when I delete Model_B directly.

I am not doing SQL delete, rather I am triggering django instance delete by doing:

instance_a = Model_A.objects.get(code="ABC)
instance_a.delete()

My understanding is that this should fire delete signals, so I was expecting they would fire on cascade deleted instances as well.
Is this expected behaviour or am I doing something wrong?

Thanks in advance!

Django 3.2 Support

Is there a plan to officially support Django 3.2, one of the LTS versions? The package seems to work as-is, but it's not listed as supported which is causing some apprehension to using the package on my team. I'm happy to open a PR to add support if there isn't some reason that has been avoided.

GitHub Actions CI

Currently, test runs aren't automated through github actions.

I can fairly easily add this in if repo maintainers are interested.

Django 4.x support

Currently, Django 4.x is not tested within tox.ini.

Best to update this and check if there are any bugs surrounding Django 4.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.