├── tests ├── __init__.py ├── testapp │ ├── views.py │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_modelcustompk.py │ │ ├── test_model_with_generic_fk.py │ │ ├── test_decorator.py │ │ ├── test_conditions.py │ │ ├── test_user_account.py │ │ └── test_mixin.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0003_useraccount_name_changes.py │ │ ├── 0006_useraccount_configurations.py │ │ ├── 0007_modelthatfailsiftriggered.py │ │ ├── 0002_auto_20190928_2324.py │ │ ├── 0004_alter_locale_id_alter_locale_users_and_more.py │ │ ├── 0005_modelwithgenericforeignkey.py │ │ └── 0001_initial.py │ └── models.py ├── urls.py └── settings.py ├── django_lifecycle ├── py.typed ├── constants.py ├── priority.py ├── types.py ├── models.py ├── hooks.py ├── abstract.py ├── __init__.py ├── utils.py ├── dataclass_validation.py ├── conditions │ ├── base.py │ ├── legacy.py │ └── __init__.py ├── model_state.py ├── decorators.py └── mixins.py ├── django_lifecycle_checks ├── py.typed ├── __init__.py └── apps.py ├── MANIFEST.in ├── pypi_submit.py ├── script └── deploy ├── .gitignore ├── manage.py ├── requirements.txt ├── .github └── workflows │ ├── docs-publish.yml │ └── python-publish.yml ├── LICENSE.md ├── .travis.yml ├── tox.ini ├── mkdocs.yml ├── docs ├── fk_changes.md ├── advanced.md ├── index.md ├── examples.md └── hooks_and_conditions.md ├── setup.py ├── README.md └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/views.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_lifecycle/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_lifecycle_checks/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_lifecycle_checks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.md 3 | -------------------------------------------------------------------------------- /django_lifecycle/constants.py: -------------------------------------------------------------------------------- 1 | class NotSet(object): 2 | pass 3 | -------------------------------------------------------------------------------- /pypi_submit.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.system("python setup.py sdist --verbose") 4 | os.system("twine upload dist/*") 5 | -------------------------------------------------------------------------------- /script/deploy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | mkdocs gh-deploy 5 | python setup.py sdist --verbose 6 | twine upload dist/* -------------------------------------------------------------------------------- /django_lifecycle/priority.py: -------------------------------------------------------------------------------- 1 | LOWER_PRIORITY = 100 2 | LOW_PRIORITY = 75 3 | DEFAULT_PRIORITY = 50 4 | HIGH_PRIORITY = 25 5 | HIGHEST_PRIORITY = 0 6 | -------------------------------------------------------------------------------- /django_lifecycle/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import Callable 3 | from typing import Iterable 4 | from typing import Union 5 | 6 | Condition = Callable[[Any, Union[Iterable[str], None]], bool] 7 | -------------------------------------------------------------------------------- /django_lifecycle/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from .mixins import LifecycleModelMixin 4 | 5 | 6 | class LifecycleModel(LifecycleModelMixin, models.Model): 7 | class Meta: 8 | abstract = True 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build/ 3 | local-development.txt 4 | local-dev.txt 5 | dist/ 6 | MANIFEST 7 | db.sqlite3 8 | __pycache__/ 9 | .mypy_cache 10 | .vscode 11 | README.txt 12 | .tox 13 | django_lifecycle.egg-info 14 | site/ 15 | .idea 16 | venv 17 | .venv 18 | .python-version -------------------------------------------------------------------------------- /tests/testapp/migrations/0003_useraccount_name_changes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.8 on 2020-02-03 10:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('testapp', '0002_auto_20190928_2324'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='useraccount', 15 | name='name_changes', 16 | field=models.IntegerField(default=0), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /django_lifecycle/hooks.py: -------------------------------------------------------------------------------- 1 | BEFORE_SAVE = "before_save" 2 | AFTER_SAVE = "after_save" 3 | 4 | BEFORE_CREATE = "before_create" 5 | AFTER_CREATE = "after_create" 6 | 7 | BEFORE_UPDATE = "before_update" 8 | AFTER_UPDATE = "after_update" 9 | 10 | BEFORE_DELETE = "before_delete" 11 | AFTER_DELETE = "after_delete" 12 | 13 | 14 | VALID_HOOKS = ( 15 | BEFORE_SAVE, 16 | AFTER_SAVE, 17 | BEFORE_CREATE, 18 | AFTER_CREATE, 19 | BEFORE_UPDATE, 20 | AFTER_UPDATE, 21 | BEFORE_DELETE, 22 | AFTER_DELETE 23 | ) 24 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0006_useraccount_configurations.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0b1 on 2024-02-20 11:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("testapp", "0005_modelwithgenericforeignkey"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="useraccount", 15 | name="configurations", 16 | field=models.JSONField(default=dict), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_modelcustompk.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from django.test import TestCase 3 | from tests.testapp.models import ModelCustomPK 4 | 5 | 6 | class ModelCustomPKTestCase(TestCase): 7 | def test_update_created_at_before_create(self): 8 | instance = ModelCustomPK.objects.create() 9 | instance.refresh_from_db() 10 | self.assertTrue(isinstance(instance.created_at, datetime.datetime)) 11 | 12 | def test_update_answer_after_create(self): 13 | instance = ModelCustomPK.objects.create() 14 | self.assertEqual(instance.answer, 42) 15 | -------------------------------------------------------------------------------- /django_lifecycle/abstract.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from dataclasses import dataclass 3 | from typing import Any 4 | 5 | 6 | @dataclass(order=False) 7 | class AbstractHookedMethod(ABC): 8 | method: Any 9 | priority: int 10 | 11 | @property 12 | @abstractmethod 13 | def name(self) -> str: 14 | ... 15 | 16 | @abstractmethod 17 | def run(self, instance: Any) -> None: 18 | ... 19 | 20 | def __lt__(self, other): 21 | if not isinstance(other, AbstractHookedMethod): 22 | return NotImplemented 23 | 24 | return self.priority < other.priority 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asgiref==3.4.1 2 | Click==7.0 3 | Django==5.0b1 4 | django-capture-on-commit-callbacks==1.10.0 5 | djangorestframework==3.11.2 6 | ghp-import==2.0.2 7 | importlib-metadata==4.8.1 8 | Jinja2==2.11.3 9 | livereload==2.6.1 10 | Markdown==3.2.1 11 | MarkupSafe==1.1.1 12 | mergedeep==1.3.4 13 | mkdocs==1.2.3 14 | mkdocs-material==4.6.3 15 | packaging==21.0 16 | Pygments==2.7.4 17 | pymdown-extensions==6.3 18 | pyparsing==3.0.0 19 | python-dateutil==2.8.2 20 | pytz==2023.3 21 | PyYAML==6.0.1 22 | pyyaml-env-tag==0.1 23 | six==1.14.0 24 | sqlparse==0.3.0 25 | tornado==6.0.3 26 | urlman==1.2.0 27 | watchdog==2.1.6 28 | zipp==3.6.0 29 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | """coremodel URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | urlpatterns = [] 17 | -------------------------------------------------------------------------------- /.github/workflows/docs-publish.yml: -------------------------------------------------------------------------------- 1 | name: Deploy docs 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | docs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: '3.12' 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install mkdocs mkdocs-gen-files mkdocs-material 23 | - run: git config user.name 'github-actions[bot]' && git config user.email 'github-actions[bot]@users.noreply.github.com' 24 | - name: Publish docs 25 | run: mkdocs gh-deploy -------------------------------------------------------------------------------- /tests/testapp/migrations/0007_modelthatfailsiftriggered.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1 on 2025-03-13 17:34 2 | 3 | import django_lifecycle.mixins 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('testapp', '0006_useraccount_configurations'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='ModelThatFailsIfTriggered', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ], 19 | options={ 20 | 'abstract': False, 21 | }, 22 | bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /django_lifecycle/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.2.4" 2 | __author__ = "Robert Singer" 3 | __author_email__ = "robertgsinger@gmail.com" 4 | 5 | from .constants import NotSet 6 | from .decorators import hook 7 | from .hooks import AFTER_CREATE 8 | from .hooks import AFTER_DELETE 9 | from .hooks import AFTER_SAVE 10 | from .hooks import AFTER_UPDATE 11 | from .hooks import BEFORE_CREATE 12 | from .hooks import BEFORE_DELETE 13 | from .hooks import BEFORE_SAVE 14 | from .hooks import BEFORE_UPDATE 15 | from .mixins import LifecycleModelMixin 16 | from .mixins import bypass_hooks_for 17 | from .models import LifecycleModel 18 | 19 | __all__ = [ 20 | "hook", 21 | "LifecycleModelMixin", 22 | "LifecycleModel", 23 | "BEFORE_SAVE", 24 | "AFTER_SAVE", 25 | "BEFORE_CREATE", 26 | "AFTER_CREATE", 27 | "BEFORE_UPDATE", 28 | "AFTER_UPDATE", 29 | "BEFORE_DELETE", 30 | "AFTER_DELETE", 31 | "NotSet", 32 | "bypass_hooks_for", 33 | ] 34 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_model_with_generic_fk.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.test import TestCase 3 | from tests.testapp.models import ModelWithGenericForeignKey 4 | from tests.testapp.models import Organization 5 | 6 | if django.VERSION < (3, 2): 7 | from django_capture_on_commit_callbacks import TestCaseMixin 8 | else: 9 | 10 | class TestCaseMixin: 11 | """Dummy implementation for Django >= 4.0""" 12 | 13 | 14 | class ModelWithGenericForeignKeyTestCase(TestCaseMixin, TestCase): 15 | 16 | def test_saving_model_with_generic_fk_doesnt_break(self): 17 | evil_corp = Organization.objects.create(name="Evil corp.") 18 | good_corp = Organization.objects.create(name="Good corp.") 19 | model = ModelWithGenericForeignKey.objects.create(tag="evil-corp", content_object=evil_corp) 20 | 21 | # One hook should be executed 22 | with self.captureOnCommitCallbacks(execute=True) as callbacks: 23 | model.tag = "good-corp" 24 | model.content_object = good_corp 25 | model.save() 26 | 27 | self.assertEqual(len(callbacks), 1) 28 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0002_auto_20190928_2324.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-09-28 23:24 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django_lifecycle 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('testapp', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Organization', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=100)), 20 | ], 21 | options={ 22 | 'abstract': False, 23 | }, 24 | bases=(django_lifecycle.LifecycleModelMixin, models.Model), 25 | ), 26 | migrations.AddField( 27 | model_name='useraccount', 28 | name='organization', 29 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='testapp.Organization'), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 rsinger86 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: 3 | - pip install tox 4 | dist: xenial 5 | sudo: required 6 | script: 7 | - tox 8 | matrix: 9 | include: 10 | - python: 3.5 11 | env: TOXENV=py35-django20 12 | - python: 3.6 13 | env: TOXENV=py36-django20 14 | - python: 3.7 15 | env: TOXENV=py37-django20 16 | - python: 3.8 17 | env: TOXENV=py38-django20 18 | - python: 3.5 19 | env: TOXENV=py35-django21 20 | - python: 3.6 21 | env: TOXENV=py36-django21 22 | - python: 3.7 23 | env: TOXENV=py37-django21 24 | - python: 3.8 25 | env: TOXENV=py38-django21 26 | - python: 3.5 27 | env: TOXENV=py35-django22 28 | - python: 3.6 29 | env: TOXENV=py36-django22 30 | - python: 3.7 31 | env: TOXENV=py37-django22 32 | - python: 3.8 33 | env: TOXENV=py38-django22 34 | - python: 3.6 35 | env: TOXENV=py36-django30 36 | - python: 3.7 37 | env: TOXENV=py37-django30 38 | - python: 3.8 39 | env: TOXENV=py38-django30 40 | - python: 3.6 41 | env: TOXENV=py36-django31 42 | - python: 3.7 43 | env: TOXENV=py37-django31 44 | - python: 3.8 45 | env: TOXENV=py38-django31 46 | - python: 3.7 47 | env: TOXENV=flake8 48 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: '3.12' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /django_lifecycle/utils.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from typing import Any 3 | 4 | from django.core.exceptions import FieldDoesNotExist 5 | from django.core.exceptions import ObjectDoesNotExist 6 | from django.db import models 7 | 8 | 9 | def sanitize_field_name(instance: models.Model, field_name: str) -> str: 10 | try: 11 | field = instance._meta.get_field(field_name) 12 | 13 | try: 14 | internal_type = field.get_internal_type() 15 | except AttributeError: 16 | return field 17 | if internal_type == "ForeignKey" or internal_type == "OneToOneField": 18 | if not field_name.endswith("_id"): 19 | return field_name + "_id" 20 | except FieldDoesNotExist: 21 | pass 22 | 23 | return field_name 24 | 25 | 26 | def get_value(instance, sanitized_field_name: str) -> Any: 27 | if "." in sanitized_field_name: 28 | 29 | def getitem(obj, field_name: str): 30 | try: 31 | return getattr(obj, field_name) 32 | except (AttributeError, ObjectDoesNotExist): 33 | return None 34 | 35 | return reduce(getitem, sanitized_field_name.split("."), instance) 36 | else: 37 | return getattr(instance, sanitize_field_name(instance, sanitized_field_name)) 38 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | toxworkdir={env:TOXWORKDIR:{toxinidir}/.tox} 3 | envlist = 4 | {py37,py38,py39}-django22 5 | {py37,py38,py39}-django30 6 | {py37,py38,py39}-django31 7 | {py37,py38,py39,py310}-django32 8 | {py38,py39,py310}-django40 9 | {py38,py39,py310,py311}-django41 10 | {py38,py39,py310,py311}-django42 11 | {py310,py311,py312}-django42 12 | {py310,py311,py312}-django50 13 | flake8 14 | skip_missing_interpreters = False 15 | 16 | [flake8] 17 | max-line-length = 120 18 | 19 | [testenv] 20 | commands = python -W error::DeprecationWarning -W error::PendingDeprecationWarning ./manage.py test 21 | envdir = {toxworkdir}/venvs/{envname} 22 | setenv = 23 | PYTHONDONTWRITEBYTECODE=1 24 | PYTHONWARNINGS=once 25 | DJANGO_SETTINGS_MODULE=tests.settings 26 | deps = 27 | django22: django>=2.2,<3.0 28 | django30: django>=3.0,<3.1 29 | django31: django>=3.1,<3.2 30 | django32: django>=3.2,<3.3 31 | django40: django>=4.0,<4.1 32 | django41: django>=4.1,<4.2 33 | django42: django>=4.2,<4.3 34 | django50: django>=5.0a1,<5.1 35 | django-capture-on-commit-callbacks 36 | urlman>=1.2.0 37 | 38 | [testenv:flake8] 39 | basepython = python3.12 40 | deps = 41 | flake8==6.1.0 42 | commands = 43 | flake8 . --exclude=.venv/,venv/,.tox/,django_lifecycle/__init__.py 44 | -------------------------------------------------------------------------------- /django_lifecycle/dataclass_validation.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger(__name__) 4 | 5 | 6 | class Validations: 7 | # Snippet grabbed from: 8 | # https://gist.github.com/rochacbruno/978405e4839142e409f8402eece505e8 9 | 10 | def __post_init__(self): 11 | """ 12 | Run validation methods if declared. 13 | The validation method can be a simple check 14 | that raises ValueError or a transformation to 15 | the field value. 16 | The validation is performed by calling a function named: 17 | `validate_(self, value, field) -> field.type` 18 | 19 | Finally, calls (if defined) `validate(self)` for validations that depend on other fields 20 | """ 21 | for name, field in self.__dataclass_fields__.items(): 22 | validator_name = f"validate_{name}" 23 | method = getattr(self, validator_name, None) 24 | if callable(method): 25 | logger.debug(f"Calling validator: {validator_name}") 26 | new_value = method(getattr(self, name), field=field) 27 | setattr(self, name, new_value) 28 | 29 | validate = getattr(self, "validate", None) 30 | if callable(validate): 31 | logger.debug(f"Calling validator: {validate}") 32 | validate() 33 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0004_alter_locale_id_alter_locale_users_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-11-08 08:31 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("testapp", "0003_useraccount_name_changes"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="locale", 15 | name="id", 16 | field=models.BigAutoField( 17 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 18 | ), 19 | ), 20 | migrations.AlterField( 21 | model_name="locale", 22 | name="users", 23 | field=models.ManyToManyField(to="testapp.useraccount"), 24 | ), 25 | migrations.AlterField( 26 | model_name="organization", 27 | name="id", 28 | field=models.BigAutoField( 29 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 30 | ), 31 | ), 32 | migrations.AlterField( 33 | model_name="useraccount", 34 | name="id", 35 | field=models.BigAutoField( 36 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 37 | ), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /django_lifecycle/conditions/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import operator 4 | from dataclasses import dataclass 5 | from typing import Any 6 | from typing import Callable 7 | from typing import Iterable 8 | from typing import Union 9 | 10 | from .. import types 11 | 12 | 13 | @dataclass 14 | class ChainedCondition: 15 | def __init__( 16 | self, 17 | left: types.Condition, 18 | right: types.Condition, 19 | operator: Callable[[Any, Any], bool], 20 | ): 21 | self.left = left 22 | self.right = right 23 | self.operator = operator 24 | 25 | def __and__(self, other): 26 | return ChainedCondition(self, other, operator=operator.and_) 27 | 28 | def __or__(self, other): 29 | return ChainedCondition(self, other, operator=operator.or_) 30 | 31 | def __call__(self, instance: Any, update_fields: Union[Iterable[str], None] = None) -> bool: 32 | left_result = self.left(instance, update_fields) 33 | right_result = self.right(instance, update_fields) 34 | return self.operator(left_result, right_result) 35 | 36 | 37 | class ChainableCondition: 38 | """Base class for defining chainable conditions using `&` and `|`""" 39 | 40 | def __and__(self, other) -> ChainedCondition: 41 | return ChainedCondition(self, other, operator=operator.and_) 42 | 43 | def __or__(self, other) -> ChainedCondition: 44 | return ChainedCondition(self, other, operator=operator.or_) 45 | 46 | def __call__(self, instance: Any, update_fields: Union[Iterable[str], None] = None) -> bool: 47 | ... 48 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0005_modelwithgenericforeignkey.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-11-08 08:39 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django_lifecycle.mixins 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("contenttypes", "0002_remove_content_type_name"), 12 | ("testapp", "0004_alter_locale_id_alter_locale_users_and_more"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="ModelWithGenericForeignKey", 18 | fields=[ 19 | ( 20 | "id", 21 | models.BigAutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("tag", models.SlugField()), 29 | ("object_id", models.PositiveIntegerField(blank=True, null=True)), 30 | ( 31 | "content_type", 32 | models.ForeignKey( 33 | blank=True, 34 | null=True, 35 | on_delete=django.db.models.deletion.CASCADE, 36 | to="contenttypes.contenttype", 37 | ), 38 | ), 39 | ], 40 | options={ 41 | "abstract": False, 42 | }, 43 | bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django Lifecycle Hooks 2 | 3 | theme: 4 | name: "material" 5 | language: en 6 | feature: 7 | tabs: false 8 | palette: 9 | # Palette toggle for automatic mode 10 | - media: "(prefers-color-scheme)" 11 | primary: green 12 | accent: green 13 | toggle: 14 | icon: material/brightness-auto 15 | name: Switch to light mode 16 | 17 | # Palette toggle for light mode 18 | - media: "(prefers-color-scheme: light)" 19 | scheme: default 20 | primary: green 21 | accent: green 22 | toggle: 23 | icon: material/brightness-7 24 | name: Switch to dark mode 25 | 26 | # Palette toggle for dark mode 27 | - media: "(prefers-color-scheme: dark)" 28 | scheme: slate 29 | primary: green 30 | accent: green 31 | toggle: 32 | icon: material/brightness-4 33 | name: Switch to system preference 34 | font: 35 | text: Roboto 36 | code: Roboto Mono 37 | logo: 38 | icon: "cached" 39 | repo_name: rsinger86/django-lifecycle 40 | repo_url: https://github.com/rsinger86/django-lifecycle 41 | 42 | # Extensions 43 | markdown_extensions: 44 | - admonition 45 | - pymdownx.highlight: 46 | anchor_linenums: true 47 | line_spans: __span 48 | pygments_lang_class: true 49 | - pymdownx.inlinehilite 50 | - pymdownx.snippets 51 | - pymdownx.superfences 52 | - toc: 53 | permalink: true 54 | 55 | nav: 56 | - Introduction: index.md 57 | - Examples: examples.md 58 | - Lifecycle Moments & Conditions: hooks_and_conditions.md 59 | - Watching ForeignKey Changes: fk_changes.md 60 | - Advanced: advanced.md 61 | -------------------------------------------------------------------------------- /django_lifecycle_checks/apps.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Iterable 3 | from typing import Type 4 | from typing import Union 5 | 6 | from django.apps import AppConfig 7 | from django.apps import apps 8 | from django.core.checks import Error 9 | from django.core.checks import Tags 10 | from django.core.checks import register 11 | from django.db import models 12 | 13 | 14 | def get_models_to_check( 15 | app_configs: Union[Iterable[AppConfig], None] 16 | ) -> Iterable[Type[models.Model]]: 17 | app_configs = app_configs or apps.get_app_configs() 18 | for app_config in app_configs: 19 | yield from app_config.get_models() 20 | 21 | 22 | def model_has_hooked_methods(model: Type[models.Model]) -> bool: 23 | for attribute_name, attribute in inspect.getmembers( 24 | model, predicate=inspect.isfunction 25 | ): 26 | if hasattr(attribute, "_hooked"): 27 | return True 28 | return False 29 | 30 | 31 | def model_has_lifecycle_mixin(model: Type[models.Model]) -> bool: 32 | from django_lifecycle.mixins import LifecycleModelMixin 33 | 34 | return issubclass(model, LifecycleModelMixin) 35 | 36 | 37 | def check_models_with_hooked_methods_inherit_from_lifecycle( 38 | app_configs: Union[Iterable[AppConfig], None] = None, **kwargs 39 | ): 40 | for model in get_models_to_check(app_configs): 41 | if model_has_hooked_methods(model) and not model_has_lifecycle_mixin(model): 42 | yield Error( 43 | "Model has hooked methods but it doesn't inherit from LifecycleModelMixin", 44 | id="django_lifecycle.E001", 45 | obj=model, 46 | ) 47 | 48 | 49 | class ChecksConfig(AppConfig): 50 | default_auto_field = "django.db.models.BigAutoField" 51 | name = "django_lifecycle_checks" 52 | 53 | def ready(self) -> None: 54 | super().ready() 55 | 56 | register(check_models_with_hooked_methods_inherit_from_lifecycle, Tags.models) 57 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_decorator.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | 4 | from django_lifecycle import hook, LifecycleModelMixin, AFTER_CREATE 5 | from django_lifecycle.conditions import Always 6 | from django_lifecycle.decorators import DjangoLifeCycleException 7 | from django_lifecycle.priority import HIGHEST_PRIORITY, LOWER_PRIORITY 8 | from tests.testapp.models import ModelThatFailsIfTriggered 9 | 10 | 11 | class DecoratorTests(TestCase): 12 | def test_decorate_with_multiple_hooks(self): 13 | class FakeModel(object): 14 | @hook("after_create") 15 | @hook("after_delete") 16 | def multiple_hooks(self): 17 | pass 18 | 19 | @hook("after_create") 20 | def one_hook(self): 21 | pass 22 | 23 | instance = FakeModel() 24 | self.assertEqual(len(instance.multiple_hooks._hooked), 2) 25 | self.assertEqual(len(instance.one_hook._hooked), 1) 26 | 27 | def test_priority_hooks(self): 28 | class FakeModel(LifecycleModelMixin, models.Model): 29 | @hook(AFTER_CREATE) 30 | def mid_priority(self): 31 | pass 32 | 33 | @hook(AFTER_CREATE, priority=HIGHEST_PRIORITY) 34 | def top_priority(self): 35 | pass 36 | 37 | @hook(AFTER_CREATE, priority=LOWER_PRIORITY) 38 | def lowest_priority(self): 39 | pass 40 | 41 | hooked_methods = FakeModel()._get_hooked_methods(AFTER_CREATE) 42 | hooked_method_names = [method.name for method in hooked_methods] 43 | 44 | expected_method_names = ["top_priority", "mid_priority", "lowest_priority"] 45 | self.assertListEqual(expected_method_names, hooked_method_names) 46 | 47 | def test_condition_cannot_be_mixed_with_legacy_parameters(self): 48 | with self.assertRaises(DjangoLifeCycleException): 49 | hook(AFTER_CREATE, condition=Always(), has_changed=True) 50 | 51 | def test_no_condition_or_legacy_parameters_is_valid(self): 52 | hook(AFTER_CREATE) # no exception is raised 53 | -------------------------------------------------------------------------------- /django_lifecycle/model_state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any 3 | from typing import Dict 4 | from typing import TYPE_CHECKING 5 | 6 | from django_lifecycle.utils import get_value 7 | from django_lifecycle.utils import sanitize_field_name 8 | 9 | if TYPE_CHECKING: 10 | from django_lifecycle import LifecycleModelMixin 11 | 12 | 13 | class ModelState: 14 | def __init__(self, initial_state: Dict[str, Any]): 15 | self.initial_state = initial_state 16 | 17 | @classmethod 18 | def from_instance(cls, instance: "LifecycleModelMixin") -> ModelState: 19 | state = instance.__dict__.copy() 20 | 21 | for watched_related_field in instance._watched_fk_model_fields(): 22 | state[watched_related_field] = get_value(instance, watched_related_field) 23 | 24 | fields_to_remove = ( 25 | "_state", 26 | "_potentially_hooked_methods", 27 | "_initial_state", 28 | "_watched_fk_model_fields", 29 | ) 30 | for field in fields_to_remove: 31 | state.pop(field, None) 32 | 33 | return ModelState(state) 34 | 35 | def get_diff(self, instance: "LifecycleModelMixin") -> dict: 36 | current = ModelState.from_instance(instance).initial_state 37 | diffs = {} 38 | 39 | for key, initial_value in self.initial_state.items(): 40 | try: 41 | current_value = current[key] 42 | except KeyError: 43 | continue 44 | 45 | if initial_value != current_value: 46 | diffs[key] = (key, current_value) 47 | 48 | return diffs 49 | 50 | def get_value(self, instance: "LifecycleModelMixin", field_name: str) -> Any: 51 | """ 52 | Get initial value of field when model was instantiated. 53 | """ 54 | field_name = sanitize_field_name(instance, field_name) 55 | return self.initial_state.get(field_name) 56 | 57 | def has_changed(self, instance: "LifecycleModelMixin", field_name: str) -> bool: 58 | """ 59 | Check if a field has changed since the model was instantiated. 60 | """ 61 | field_name = sanitize_field_name(instance, field_name) 62 | return field_name in self.get_diff(instance) 63 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.3 on 2018-03-23 05:44 2 | 3 | import uuid 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='UserAccount', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('username', models.CharField(max_length=100)), 21 | ('first_name', models.CharField(max_length=100)), 22 | ('last_name', models.CharField(max_length=100)), 23 | ('password', models.CharField(max_length=200)), 24 | ('email', models.EmailField(max_length=254, null=True)), 25 | ('password_updated_at', models.DateTimeField(null=True)), 26 | ('joined_at', models.DateTimeField(null=True)), 27 | ('has_trial', models.BooleanField(default=False)), 28 | ('status', models.CharField( 29 | choices=[('active', 'Active'), ('banned', 'Banned'), ('inactive', 'Inactive')], 30 | default='active', max_length=30 31 | )), 32 | ], 33 | options={ 34 | 'abstract': False, 35 | }, 36 | ), 37 | 38 | migrations.CreateModel( 39 | name='Locale', 40 | fields=[ 41 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 42 | ('code', models.CharField(max_length=20)), 43 | ('users', models.ManyToManyField(to='UserAccount')), 44 | ], 45 | ), 46 | 47 | migrations.CreateModel( 48 | name='ModelCustomPK', 49 | fields=[ 50 | ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), 51 | ('created_at', models.DateTimeField(null=True)), 52 | ('answer', models.IntegerField(default=None, null=True)), 53 | ], 54 | options={ 55 | 'abstract': False, 56 | }, 57 | ), 58 | 59 | ] 60 | -------------------------------------------------------------------------------- /docs/fk_changes.md: -------------------------------------------------------------------------------- 1 | # Watching Changes to ForeignKey Fields 2 | 3 | ## ForeignKey Reference Changes 4 | 5 | You can watch whether a foreign key reference changes by putting the name of the FK field in the `when` parameter: 6 | 7 | ```python 8 | class Organization(models.Model): 9 | name = models.CharField(max_length=100) 10 | 11 | 12 | class UserAccount(LifecycleModel): 13 | username = models.CharField(max_length=100) 14 | email = models.CharField(max_length=600) 15 | employer = models.ForeignKey(Organization, on_delete=models.SET_NULL) 16 | 17 | @hook(AFTER_UPDATE, condition=WhenFieldHasChanged("employer", has_changed=True)) 18 | def notify_user_of_employer_change(self): 19 | mail.send_mail("Update", "You now work for someone else!", [self.email]) 20 | ``` 21 | 22 | To be clear: This hook will fire when the value in the database column that stores the foreign key (in this case, `organization_id`) changes. Read on to see how to watch for changes to *fields on the related model*. 23 | 24 | ## ForeignKey Field Value Changes 25 | You can have a hooked method fire based on the *value of a field* on a foreign key-related model using dot-notation: 26 | 27 | ```python 28 | class Organization(models.Model): 29 | name = models.CharField(max_length=100) 30 | 31 | 32 | class UserAccount(LifecycleModel): 33 | username = models.CharField(max_length=100) 34 | email = models.CharField(max_length=600) 35 | employer = models.ForeignKey(Organization, on_delete=models.SET_NULL) 36 | 37 | @hook(AFTER_UPDATE, condition=WhenFieldValueChangesTo("employer.name", value="Google")) 38 | def notify_user_of_google_buy_out(self): 39 | mail.send_mail("Update", "Google bought your employer!", ["to@example.com"],) 40 | ``` 41 | 42 | **If you use dot-notation**, *Please be aware of the potential performance hit*: When your model is first initialized, the related model will also be loaded in order to store the "initial" state of the related field. Models set up with these hooks should always be loaded using `.select_related()`, i.e. `UserAccount.objects.select_related("organization")` for the example above. If you don't do this, you will almost certainly experience a major [N+1](https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping) performance problem. 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import codecs 3 | import os 4 | import re 5 | from codecs import open 6 | 7 | from setuptools import setup 8 | 9 | 10 | def get_metadata(package, field): 11 | """ 12 | Return package data as listed in `__{field}__` in `init.py`. 13 | """ 14 | with codecs.open(os.path.join(package, "__init__.py"), encoding="utf-8") as fp: 15 | init_py = fp.read() 16 | return re.search("^__{}__ = ['\"]([^'\"]+)['\"]".format(field), init_py, re.MULTILINE).group(1) 17 | 18 | 19 | def readme(): 20 | with open("README.md", "r") as infile: 21 | return infile.read() 22 | 23 | 24 | classifiers = [ 25 | # Pick your license as you wish (should match "license" above) 26 | "Development Status :: 4 - Beta", 27 | "License :: OSI Approved :: MIT License", 28 | "Programming Language :: Python", 29 | "Programming Language :: Python :: 3.7", 30 | "Programming Language :: Python :: 3.8", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "Framework :: Django", 36 | "Framework :: Django :: 2.2", 37 | "Framework :: Django :: 3.2", 38 | "Framework :: Django :: 4.0", 39 | "Framework :: Django :: 4.1", 40 | "Framework :: Django :: 4.2", 41 | "Framework :: Django :: 5.0", 42 | ] 43 | setup( 44 | name="django-lifecycle", 45 | version=get_metadata("django_lifecycle", "version"), 46 | description="Declarative model lifecycle hooks.", 47 | author=get_metadata("django_lifecycle", "author"), 48 | author_email=get_metadata("django_lifecycle", "author_email"), 49 | packages=["django_lifecycle", "django_lifecycle_checks", "django_lifecycle.conditions"], 50 | include_package_data=True, 51 | url="https://github.com/rsinger86/django-lifecycle", 52 | project_urls={ 53 | "Documentation": "https://rsinger86.github.io/django-lifecycle/", 54 | "Source": "https://github.com/rsinger86/django-lifecycle", 55 | }, 56 | license="MIT", 57 | keywords="django model lifecycle hooks callbacks", 58 | long_description=readme(), 59 | classifiers=classifiers, 60 | long_description_content_type="text/markdown", 61 | install_requires=["Django>=3.2"], 62 | tests_require=[ 63 | "urlman>=1.2.0", 64 | "django-capture-on-commit-callbacks", 65 | ], 66 | ) 67 | -------------------------------------------------------------------------------- /django_lifecycle/conditions/legacy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any 5 | from typing import List 6 | from typing import Optional 7 | 8 | from ..constants import NotSet 9 | from ..conditions.base import ChainableCondition 10 | from ..conditions import WhenFieldValueChangesTo 11 | from ..conditions import WhenFieldHasChanged 12 | from ..conditions import WhenFieldValueIsNot 13 | from ..conditions import WhenFieldValueIs 14 | from ..conditions import WhenFieldValueWas 15 | from ..conditions import WhenFieldValueWasNot 16 | 17 | 18 | @dataclass 19 | class When(ChainableCondition): 20 | when: Optional[str] = None 21 | was: Any = "*" 22 | is_now: Any = "*" 23 | has_changed: Optional[bool] = None 24 | is_not: Any = NotSet 25 | was_not: Any = NotSet 26 | changes_to: Any = NotSet 27 | 28 | def __call__(self, instance: Any, update_fields=None) -> bool: 29 | has_changed_condition = WhenFieldHasChanged(self.when, has_changed=self.has_changed) 30 | if not has_changed_condition(instance, update_fields=update_fields): 31 | return False 32 | 33 | changes_to_condition = WhenFieldValueChangesTo(self.when, value=self.changes_to) 34 | if not changes_to_condition(instance, self.when): 35 | return False 36 | 37 | is_now_condition = WhenFieldValueIs(self.when, value=self.is_now) 38 | if not is_now_condition(instance, self.when): 39 | return False 40 | 41 | was_condition = WhenFieldValueWas(self.when, value=self.was) 42 | if not was_condition(instance, self.when): 43 | return False 44 | 45 | was_not_condition = WhenFieldValueWasNot(self.when, value=self.was_not) 46 | if not was_not_condition(instance, self.when): 47 | return False 48 | 49 | is_not_condition = WhenFieldValueIsNot(self.when, value=self.is_not) 50 | if not is_not_condition(instance, self.when): 51 | return False 52 | 53 | return True 54 | 55 | 56 | @dataclass 57 | class WhenAny: 58 | when_any: Optional[List[str]] = None 59 | was: Any = "*" 60 | is_now: Any = "*" 61 | has_changed: Optional[bool] = None 62 | is_not: Any = NotSet 63 | was_not: Any = NotSet 64 | changes_to: Any = NotSet 65 | 66 | def __call__(self, instance: Any, update_fields=None) -> bool: 67 | conditions = ( 68 | When( 69 | when=field, 70 | was=self.was, 71 | is_now=self.is_now, 72 | has_changed=self.has_changed, 73 | is_not=self.is_not, 74 | was_not=self.was_not, 75 | changes_to=self.changes_to, 76 | ) 77 | for field in self.when_any 78 | ) 79 | return any(condition(instance, update_fields=update_fields) for condition in conditions) 80 | -------------------------------------------------------------------------------- /django_lifecycle/conditions/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any 5 | from typing import Iterable 6 | from typing import Union 7 | 8 | from ..constants import NotSet 9 | from ..conditions.base import ChainableCondition 10 | 11 | 12 | __all__ = [ 13 | "WhenFieldValueWas", 14 | "WhenFieldValueIs", 15 | "WhenFieldHasChanged", 16 | "WhenFieldValueIsNot", 17 | "WhenFieldValueWasNot", 18 | "WhenFieldValueChangesTo", 19 | "Always", 20 | ] 21 | 22 | 23 | @dataclass 24 | class WhenFieldValueWas(ChainableCondition): 25 | field_name: str 26 | value: Any = "*" 27 | 28 | def __call__(self, instance: Any, update_fields: Union[Iterable[str], None] = None) -> bool: 29 | return self.value in (instance.initial_value(self.field_name), "*") 30 | 31 | 32 | @dataclass 33 | class WhenFieldValueIs(ChainableCondition): 34 | field_name: str 35 | value: Any = "*" 36 | 37 | def __call__(self, instance: Any, update_fields: Union[Iterable[str], None] = None) -> bool: 38 | return self.value in (instance._current_value(self.field_name), "*") 39 | 40 | 41 | @dataclass 42 | class WhenFieldHasChanged(ChainableCondition): 43 | field_name: str 44 | has_changed: bool | None = None 45 | 46 | def __call__(self, instance: Any, update_fields: Union[Iterable[str], None] = None) -> bool: 47 | is_partial_fields_update = update_fields is not None 48 | is_synced = is_partial_fields_update is False or self.field_name in update_fields 49 | if not is_synced: 50 | return False 51 | 52 | return self.has_changed is None or self.has_changed == instance.has_changed(self.field_name) 53 | 54 | 55 | @dataclass 56 | class WhenFieldValueIsNot(ChainableCondition): 57 | field_name: str 58 | value: Any = NotSet 59 | 60 | def __call__(self, instance: Any, update_fields: Union[Iterable[str], None] = None) -> bool: 61 | return self.value is NotSet or instance._current_value(self.field_name) != self.value 62 | 63 | 64 | @dataclass 65 | class WhenFieldValueWasNot(ChainableCondition): 66 | field_name: str 67 | value: Any = NotSet 68 | 69 | def __call__(self, instance: Any, update_fields: Union[Iterable[str], None] = None) -> bool: 70 | return self.value is NotSet or instance.initial_value(self.field_name) != self.value 71 | 72 | 73 | @dataclass 74 | class WhenFieldValueChangesTo(ChainableCondition): 75 | field_name: str 76 | value: Any = NotSet 77 | 78 | def __call__(self, instance: Any, update_fields: Union[Iterable[str], None] = None) -> bool: 79 | is_partial_fields_update = update_fields is not None 80 | is_synced = is_partial_fields_update is False or self.field_name in update_fields 81 | if not is_synced: 82 | return False 83 | 84 | value_has_changed = bool(instance.initial_value(self.field_name) != self.value) 85 | new_value_is_the_expected = bool(instance._current_value(self.field_name) == self.value) 86 | return self.value is NotSet or (value_has_changed and new_value_is_the_expected) 87 | 88 | 89 | class Always: 90 | def __call__(self, instance: Any, update_fields=None): 91 | return True 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Lifecycle Hooks 2 | 3 | [![Package version](https://badge.fury.io/py/django-lifecycle.svg)](https://pypi.python.org/pypi/django-lifecycle) 4 | [![Python versions](https://img.shields.io/pypi/status/django-lifecycle.svg)](https://img.shields.io/pypi/status/django-lifecycle.svg/) 5 | [![Python versions](https://img.shields.io/pypi/pyversions/django-lifecycle.svg)](https://pypi.org/project/django-lifecycle/) 6 | ![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-lifecycle) 7 | 8 | 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](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnecessary indirection and are at odds with Django's "fat models" approach. 9 | 10 | **Django Lifecycle Hooks** supports: 11 | 12 | * Python 3.7, 3.8, 3.9, 3.10, 3.11, and 3.12 13 | * Django 2.2, 3.2, 4.0, 4.1, 4.2, and 5.0 14 | 15 | In short, you can write model code like this: 16 | 17 | ```python 18 | from django_lifecycle import LifecycleModel, hook, BEFORE_UPDATE, AFTER_UPDATE 19 | from django_lifecycle.conditions import WhenFieldValueIs, WhenFieldValueWas, WhenFieldHasChanged 20 | 21 | 22 | class Article(LifecycleModel): 23 | contents = models.TextField() 24 | updated_at = models.DateTimeField(null=True) 25 | status = models.ChoiceField(choices=['draft', 'published']) 26 | editor = models.ForeignKey(AuthUser) 27 | 28 | @hook(BEFORE_UPDATE, WhenFieldHasChanged("contents", has_changed=True)) 29 | def on_content_change(self): 30 | self.updated_at = timezone.now() 31 | 32 | @hook( 33 | AFTER_UPDATE, 34 | condition=( 35 | WhenFieldValueWas("status", value="draft") 36 | & WhenFieldValueIs("status", value="published") 37 | ) 38 | ) 39 | def on_publish(self): 40 | send_email(self.editor.email, "An article has published!") 41 | ``` 42 | 43 | Instead of overriding `save` and `__init__` in a clunky way that hurts readability: 44 | 45 | ```python 46 | # same class and field declarations as above ... 47 | 48 | def __init__(self, *args, **kwargs): 49 | super().__init__(*args, **kwargs) 50 | self._orig_contents = self.contents 51 | self._orig_status = self.status 52 | 53 | 54 | def save(self, *args, **kwargs): 55 | if self.pk is not None and self.contents != self._orig_contents: 56 | self.updated_at = timezone.now() 57 | 58 | super().save(*args, **kwargs) 59 | 60 | if self.status != self._orig_status: 61 | send_email(self.editor.email, "An article has published!") 62 | ``` 63 | 64 | --- 65 | 66 | **Documentation**: https://rsinger86.github.io/django-lifecycle 67 | 68 | **Source Code**: https://github.com/rsinger86/django-lifecycle 69 | 70 | --- 71 | 72 | # Changelog 73 | 74 | See [Changelog](CHANGELOG.md) 75 | 76 | # Testing 77 | 78 | Tests are found in a simplified Django project in the `/tests` folder. Install the project requirements and do `./manage.py test` to run them. 79 | 80 | # License 81 | 82 | See [License](LICENSE.md). 83 | -------------------------------------------------------------------------------- /docs/advanced.md: -------------------------------------------------------------------------------- 1 | # Advanced Usage 2 | 3 | ## Utility Methods 4 | 5 | These are available on your model instance when the mixin or extend the base model is used. 6 | 7 | | Method | Details | 8 | |:----------------------------------------:|:-----------------------------------------------------------------------------------------------------------------------:| 9 | | `has_changed(field_name: str) -> bool` | Return a boolean indicating whether the field's value has changed since the model was initialized, or refreshed from db | 10 | | `initial_value(field_name: str) -> Any` | Return the value of the field when the model was first initialized, or refreshed from db | 11 | 12 | ### Example 13 | You can use these methods for more advanced checks, for example: 14 | 15 | ```python 16 | from django_lifecycle import LifecycleModel, AFTER_UPDATE, hook 17 | 18 | 19 | class UserAccount(LifecycleModel): 20 | first_name = models.CharField(max_length=100) 21 | last_name = models.CharField(max_length=100) 22 | email = models.CharField(max_length=100) 23 | marital_status = models.CharField(max_length=100) 24 | 25 | @hook(AFTER_UPDATE) 26 | def on_name_change_heck_on_marital_status(self): 27 | if ( 28 | self.has_changed('last_name') 29 | and not self.has_changed('marital_status') 30 | ): 31 | send_mail( 32 | to=self.email, 33 | "Has your marital status changed recently?" 34 | ) 35 | 36 | ``` 37 | 38 | ## Custom conditions 39 | Custom conditions can be created as long as they respect condition signature 40 | ```python 41 | def changes_to_ned_flanders(instance, update_fields=None) -> bool: 42 | return ( 43 | instance.has_changed("first_name") 44 | and instance.has_changed("last_name") 45 | and instance.first_name == "Ned" 46 | and instance.last_name == "Flanders" 47 | ) 48 | ``` 49 | 50 | To allow your custom conditions to be chainable, create a class based condition inheriting `ChainableCondition`. 51 | ```python 52 | from django_lifecycle import BEFORE_SAVE 53 | from django_lifecycle.conditions import WhenFieldHasChanged 54 | from django_lifecycle.conditions.base import ChainableCondition 55 | 56 | 57 | class IsNedFlanders(ChainableCondition): 58 | def __call__(self, instance, update_fields=None): 59 | return ( 60 | instance.first_name == "Ned" 61 | and instance.last_name == "Flanders" 62 | ) 63 | 64 | 65 | @hook( 66 | BEFORE_SAVE, 67 | condition=( 68 | WhenFieldHasChanged("first_name") 69 | & WhenFieldHasChanged("last_name") 70 | & IsNedFlanders() 71 | ) 72 | ) 73 | def foo(): 74 | ... 75 | ``` 76 | 77 | ## Suppressing Hooked Methods 78 | 79 | To prevent the hooked methods from being called, pass `skip_hooks=True` when calling save: 80 | 81 | ```python 82 | account.save(skip_hooks=True) 83 | ``` 84 | 85 | Or, you can rely on the `bypass_hooks_for` context manager: 86 | 87 | ```python 88 | from django_lifecycle import bypass_hooks_for 89 | 90 | 91 | class MyModel(LifecycleModel): 92 | @hook(AFTER_CREATE) 93 | def trigger(self): 94 | pass 95 | 96 | with bypass_hooks_for((MyModel,)): 97 | model = MyModel() 98 | model.save() # will not invoke model.trigger() method 99 | 100 | ``` 101 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Django Lifecycle Hooks 2 | 3 | [![Package version](https://badge.fury.io/py/django-lifecycle.svg)](https://pypi.python.org/pypi/django-lifecycle) 4 | [![Python versions](https://img.shields.io/pypi/status/django-lifecycle.svg)](https://img.shields.io/pypi/status/django-lifecycle.svg/) 5 | [![Python versions](https://img.shields.io/pypi/pyversions/django-lifecycle.svg)](https://pypi.org/project/django-lifecycle/) 6 | ![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-lifecycle) 7 | 8 | 9 | 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](https://docs.djangoproject.com/en/dev/topics/signals/). However, my team often finds that Signals introduce unnecessary indirection and are at odds with Django's "fat models" approach. 10 | 11 | In short, you can write model code like this: 12 | 13 | ```python 14 | from django_lifecycle import LifecycleModel, hook, BEFORE_UPDATE, AFTER_UPDATE 15 | 16 | 17 | class Article(LifecycleModel): 18 | contents = models.TextField() 19 | updated_at = models.DateTimeField(null=True) 20 | status = models.ChoiceField(choices=['draft', 'published']) 21 | editor = models.ForeignKey(AuthUser) 22 | 23 | @hook( 24 | BEFORE_UPDATE, 25 | condition=WhenFieldHasChanged('contents', has_changed=True), 26 | ) 27 | def on_content_change(self): 28 | self.updated_at = timezone.now() 29 | 30 | @hook( 31 | AFTER_UPDATE, 32 | condition=( 33 | WhenFieldValueWas("status", value="draft") 34 | & WhenFieldValueIs("status", value="published") 35 | ) 36 | ) 37 | def on_publish(self): 38 | send_email(self.editor.email, "An article has published!") 39 | ``` 40 | 41 | Instead of overriding `save` and `__init__` in a clunky way that hurts readability: 42 | 43 | ```python 44 | # same class and field declarations as above ... 45 | 46 | def __init__(self, *args, **kwargs): 47 | super().__init__(*args, **kwargs) 48 | self._orig_contents = self.contents 49 | self._orig_status = self.status 50 | 51 | 52 | def save(self, *args, **kwargs): 53 | if self.pk is not None and self.contents != self._orig_contents): 54 | self.updated_at = timezone.now() 55 | 56 | super().save(*args, **kwargs) 57 | 58 | if self.status != self._orig_status: 59 | send_email(self.editor.email, "An article has published!") 60 | ``` 61 | 62 | ## Requirements 63 | 64 | * Python (3.7+) 65 | * Django (2.2+) 66 | 67 | ## Installation 68 | 69 | ``` 70 | pip install django-lifecycle 71 | ``` 72 | 73 | ## Getting Started 74 | 75 | Either extend the provided abstract base model class: 76 | 77 | ```python 78 | from django_lifecycle import LifecycleModel, hook 79 | 80 | 81 | class YourModel(LifecycleModel): 82 | name = models.CharField(max_length=50) 83 | 84 | ``` 85 | 86 | Or add the mixin to your Django model definition: 87 | 88 | 89 | ```python 90 | from django.db import models 91 | from django_lifecycle import LifecycleModelMixin, hook 92 | 93 | 94 | class YourModel(LifecycleModelMixin, models.Model): 95 | name = models.CharField(max_length=50) 96 | 97 | ``` 98 | 99 | ## (Optional) Add `django_lifecycle_checks` to your `INSTALLED_APPS` 100 | 101 | ```python 102 | INSTALLED_APPS = [ 103 | # ... 104 | "django_lifecycle_checks", 105 | # ... 106 | ] 107 | ``` 108 | 109 | This will raise an exception if you forget to add the mixin or extend the base model class. 110 | 111 | 112 | --- 113 | 114 | [Read on](examples.md) to see more examples of how to use lifecycle hooks. 115 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for coremodel project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.0.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | import django 16 | 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = "o)04)%_us9ed1l7*cv&5@t(2*r#$^r7o(q^4p@y9@b20_ay_jv" 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS = [] 31 | 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | "tests.testapp", 37 | "django.contrib.messages", 38 | "django.contrib.admin", 39 | "django.contrib.auth", 40 | "django.contrib.contenttypes", 41 | "django.contrib.sessions", 42 | "django_lifecycle_checks", 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | "django.middleware.security.SecurityMiddleware", 47 | "django.contrib.sessions.middleware.SessionMiddleware", 48 | "django.middleware.common.CommonMiddleware", 49 | "django.middleware.csrf.CsrfViewMiddleware", 50 | "django.contrib.auth.middleware.AuthenticationMiddleware", 51 | "django.contrib.messages.middleware.MessageMiddleware", 52 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 53 | ] 54 | 55 | ROOT_URLCONF = "tests.urls" 56 | 57 | TEMPLATES = [ 58 | { 59 | "BACKEND": "django.template.backends.django.DjangoTemplates", 60 | "DIRS": [], 61 | "APP_DIRS": True, 62 | "OPTIONS": { 63 | "context_processors": [ 64 | "django.template.context_processors.debug", 65 | "django.template.context_processors.request", 66 | "django.contrib.auth.context_processors.auth", 67 | "django.contrib.messages.context_processors.messages", 68 | ] 69 | }, 70 | } 71 | ] 72 | 73 | WSGI_APPLICATION = "coremodel.wsgi.application" 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/2.0/ref/settings/#databases 78 | 79 | DATABASES = { 80 | "default": { 81 | "ENGINE": "django.db.backends.sqlite3", 82 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 83 | } 84 | } 85 | 86 | 87 | # Password validation 88 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 89 | 90 | AUTH_PASSWORD_VALIDATORS = [ 91 | { 92 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 93 | }, 94 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 95 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 96 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 97 | ] 98 | 99 | 100 | # Internationalization 101 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 102 | 103 | LANGUAGE_CODE = "en-us" 104 | 105 | TIME_ZONE = "UTC" 106 | 107 | USE_I18N = True 108 | 109 | if django.VERSION < (4, 0): 110 | USE_L10N = True 111 | 112 | USE_TZ = True 113 | 114 | 115 | # Static files (CSS, JavaScript, Images) 116 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 117 | 118 | STATIC_URL = "/static/" 119 | 120 | # Default primary key field type 121 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 122 | 123 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 124 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | # 1.2.4 (June 2024) 4 | - Revert deepcopying model state to copy 5 | - Update the initial state after calling `refresh_from_db` on the model instance. Thanks, @partizaans! 6 | - Refactor: Extract model state methods 7 | 8 | # 1.2.3 (February 2024) 9 | 10 | - Fix imports 11 | 12 | # 1.2.2 (February 2024) 13 | 14 | - Fix pypi release by explicitly include `conditions` folder 15 | 16 | # 1.2.1 (February 2024) 17 | 18 | - Fix import errors 19 | 20 | # 1.2.0 (February 2024) 21 | 22 | - Hook condition can be now built using some predefined conditions and/or with custom ones. 23 | - Fix `has_changed` and `changed_to` when working with mutable data (i.e.: `dict`s). Thanks @AlaaNour94 24 | 25 | # 1.1.2 (November 2023) 26 | 27 | - Fix: Hooks were failing if some watched field (those in `when=""` or `when_any=[...]`) was a `GenericForeignKey` 28 | 29 | ## 1.1.1 (November 2023) 30 | 31 | - Fix: Include missing `django_lifecycle_checks` into python package 32 | 33 | ## 1.1.0 (November 2023) 34 | 35 | - Drop support for Django < 2.2. 36 | - Confirm support for Django 5.0. Thanks @adamchainz! 37 | - Remove urlman from required packages. Thanks @DmytroLitvinov! 38 | - Add an optional Django check to avoid errors by not inheriting from `LifecycleModelMixin` (or `LifecycleModel`) 39 | 40 | ## 1.0.2 (September 2023) 41 | 42 | - Correct package info to note that Django 4.0, 4.1, and 4.2 are supported. 43 | 44 | ## 1.0.1 (August 2023) 45 | 46 | - Initial state gets reset using `transaction.on_commit()`, fixing the `has_changed()` and `initial_value()` methods for on_commit hooks. Thanks @alb3rto269! 47 | 48 | 49 | ## 1.0.0 (May 2022) 50 | 51 | - Drops Python 3.6 support 52 | - Adds `priority` hook kwarg to control the order in which hooked methods fire. Thanks @EnriqueSoria! 53 | - Internal cleanup/refactoring. Thanks @EnriqueSoria! 54 | 55 | ## 0.9.6 (February 2022) 56 | 57 | - Adds missing `packaging` to `install_requires`. Thanks @mikedep333! 58 | 59 | ## 0.9.5 (February 2022) 60 | 61 | - Makes the `has_changed`, `changes_to` conditions depend on whether the field in question was included in the SQL update/insert statement by checking 62 | the `update_fields` argument passed to save. 63 | 64 | ## 0.9.4 (February 2022) 65 | 66 | - Adds optional @hook `on_commit` argument for executing hooks when the database transaction is committed. Thanks @amcclosky! 67 | 68 | ## 0.9.3 (October 2021) 69 | 70 | - Correct packge info to note that Django 3.2 is supported. 71 | 72 | ## 0.9.2 (October 2021) 73 | 74 | - Run hooked methods inside transactions, just as signals do. Thanks @amirmotlagh! 75 | 76 | ## 0.9.1 (March 2021) 77 | 78 | - Makes hooks work with OneToOneFields. Thanks @bahmdev! 79 | 80 | ## 0.9.0 (February 2021) 81 | 82 | - Prevents calling a hooked method twice with the same state. Thanks @garyd203! 83 | 84 | ## 0.8.1 (January 2021) 85 | 86 | - Added missing return to `delete()` method override. Thanks @oaosman84! 87 | 88 | ## 0.8.0 (October 2020) 89 | 90 | - Significant performance improvements. Thanks @dralley! 91 | 92 | ## 0.7.7 (August 2020) 93 | 94 | - Fixes issue with `GenericForeignKey`. Thanks @bmbouter! 95 | 96 | ## 0.7.6 (May 2020) 97 | 98 | - Updates to use constants for hook names; updates docs to indicate Python 3.8/Django 3.x support. Thanks @thejoeejoee! 99 | 100 | ## 0.7.5 (April 2020) 101 | 102 | - Adds static typed variables for hook names; thanks @Faisal-Manzer! 103 | - Fixes some typos in docs; thanks @tomdyson and @bmispelon! 104 | 105 | ## 0.7.1 (January 2020) 106 | 107 | - Fixes bug in `utils._get_field_names` that could cause recursion bug in some cases. 108 | 109 | ## 0.7.0 (December 2019) 110 | 111 | - Adds `changes_to` condition - thanks @samitnuk! Also some typo fixes in docs. 112 | 113 | ## 0.6.1 (November 2019) 114 | 115 | - Remove variable type annotation for Python 3.5 compatability. 116 | 117 | ## 0.6.0 (October 2019) 118 | 119 | - Adds `when_any` hook parameter to watch multiple fields for state changes 120 | 121 | ## 0.5.0 (September 2019) 122 | 123 | - Adds `was_not` condition 124 | - Allow watching changes to FK model field values, not just FK references 125 | 126 | ## 0.4.2 (July 2019) 127 | 128 | - Fixes missing README.md issue that broke install. 129 | 130 | ## 0.4.1 (June 2019) 131 | 132 | - Fixes [urlman](https://github.com/andrewgodwin/urlman)-compatability. 133 | 134 | ## 0.4.0 (May 2019) 135 | 136 | - Fixes `initial_value(field_name)` behavior - should return value even if no change. Thanks @adamJLev! 137 | 138 | ## 0.3.2 (February 2019) 139 | 140 | - Fixes bug preventing hooks from firing for custom PKs. Thanks @atugushev! 141 | 142 | ## 0.3.1 (August 2018) 143 | 144 | - Fixes m2m field bug, in which accessing auto-generated reverse field in `before_create` causes exception b/c PK does not exist yet. Thanks @garyd203! 145 | 146 | ## 0.3.0 (April 2018) 147 | 148 | - Resets model's comparison state for hook conditions after `save` called. 149 | 150 | ## 0.2.4 (April 2018) 151 | 152 | - Fixed support for adding multiple `@hook` decorators to same method. 153 | 154 | ## 0.2.3 (April 2018) 155 | 156 | - Removes residual mixin methods from earlier implementation. 157 | 158 | ## 0.2.2 (April 2018) 159 | 160 | - Save method now accepts `skip_hooks`, an optional boolean keyword argument that controls whether hooked methods are called. 161 | 162 | ## 0.2.1 (April 2018) 163 | 164 | - Fixed bug in `_potentially_hooked_methods` that caused unwanted side effects by accessing model instance methods decorated with `@cache_property` or `@property`. 165 | 166 | ## 0.2.0 (April 2018) 167 | 168 | - Added Django 1.8 support. Thanks @jtiai! 169 | - Tox testing added for Python 3.4, 3.5, 3.6 and Django 1.8, 1.11 and 2.0. Thanks @jtiai! -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | Here are some examples to illustrate how you can hook into specific lifecycle moments, optionally based on state transitions. 3 | 4 | ## Specific lifecycle moments 5 | 6 | For simple cases, you might always want something to happen at a certain point, such as after saving or before deleting a model instance. 7 | When a user is first created, you could process a thumbnail image in the background and send the user an email: 8 | 9 | ```python 10 | @hook(AFTER_CREATE) 11 | def do_after_create_jobs(self): 12 | enqueue_job(process_thumbnail, self.picture_url) 13 | 14 | mail.send_mail( 15 | 'Welcome!', 'Thank you for joining.', 16 | 'from@example.com', ['to@example.com'], 17 | ) 18 | ``` 19 | 20 | Or you want to email a user when their account is deleted. You could add the decorated method below: 21 | 22 | ```python 23 | @hook(AFTER_DELETE) 24 | def email_deleted_user(self): 25 | mail.send_mail( 26 | 'We have deleted your account', 'We will miss you!.', 27 | 'customerservice@corporate.com', ['human@gmail.com'], 28 | ) 29 | ``` 30 | 31 | Or if you want to enqueue a background job that depends on state being committed to your database 32 | 33 | ```python 34 | @hook(AFTER_CREATE, on_commit=True) 35 | def do_after_create_jobs(self): 36 | enqueue_job(send_item_shipped_notication, self.item_id) 37 | ``` 38 | 39 | Read on to see how to only fire the hooked method if certain conditions about the model's current and previous state are met. 40 | 41 | ## Transitions between specific values 42 | 43 | Maybe you only want the hooked method to run under certain circumstances related to the state of your model. If a model's `status` field change from `"active"` to `"banned"`, you may want to send an email to the user: 44 | 45 | ```python 46 | @hook( 47 | AFTER_UPDATE, 48 | condition=( 49 | WhenFieldValueWas("status", value="active") 50 | & WhenFieldValueIs('status', value='banned') 51 | ) 52 | ) 53 | def email_banned_user(self): 54 | mail.send_mail( 55 | 'You have been banned', 'You may or may not deserve it.', 56 | 'communitystandards@corporate.com', ['mr.troll@hotmail.com'], 57 | ) 58 | ``` 59 | 60 | The `WhenFieldValueWas` and `WhenFieldValueIs` conditions allow you to compare the model's state from when it was first instantiated to the current moment. You can also pass `"*"` to indicate any value - these are the defaults, meaning that by default the hooked method will fire. 61 | 62 | ## Preventing state transitions 63 | 64 | You can also enforce certain disallowed transitions. For example, maybe you don't want your staff to be able to delete an active trial because they should expire instead: 65 | 66 | ```python 67 | @hook(BEFORE_DELETE, condition=WhenFieldValueIs("has_trial", value=True)) 68 | def ensure_trial_not_active(self): 69 | raise CannotDeleteActiveTrial('Cannot delete trial user!') 70 | ``` 71 | 72 | ## Any change to a field 73 | 74 | You can use the `WhenFieldValueChangesTo` condition to run the hooked method if a field has changed. 75 | 76 | ```python 77 | @hook(BEFORE_UPDATE, condition=WhenFieldHasChanged("address", has_changed=True)) 78 | def timestamp_address_change(self): 79 | self.address_updated_at = timezone.now() 80 | ``` 81 | 82 | ## When a field's value is NOT 83 | 84 | You can have a hooked method fire when a field's value IS NOT equal to a certain value. 85 | 86 | ```python 87 | @hook(BEFORE_SAVE, condition=WhenFieldValueIsNot("email", value=None)) 88 | def lowercase_email(self): 89 | self.email = self.email.lower() 90 | ``` 91 | 92 | ## When a field's value was NOT 93 | 94 | You can have a hooked method fire when a field's initial value was not equal to a specific value. 95 | 96 | ```python 97 | @hook( 98 | BEFORE_SAVE, 99 | condition=( 100 | WhenFieldValueWasNot("status", value="rejected") 101 | & WhenFieldValueIs("status", value="published") 102 | ) 103 | ) 104 | def send_publish_alerts(self): 105 | send_mass_email() 106 | ``` 107 | 108 | ## When a field's value changes to 109 | 110 | You can have a hooked method fire when a field's initial value was not equal to a specific value 111 | but now is. 112 | 113 | ```python 114 | @hook(BEFORE_SAVE, condition=WhenFieldValueChangesTo("status", value="published")) 115 | def send_publish_alerts(self): 116 | send_mass_email() 117 | ``` 118 | 119 | Generally, `WhenFieldValueChangesTo` is a shorthand for the situation when `WhenFieldValueWasNot` and `WhenFieldValueIs` 120 | conditions have the same value. The sample above is equal to: 121 | 122 | ```python 123 | @hook( 124 | BEFORE_SAVE, 125 | condition=( 126 | WhenFieldValueWasNot("status", value="published") 127 | & WhenFieldValueIs("status", value="published") 128 | ) 129 | ) 130 | def send_publish_alerts(self): 131 | send_mass_email() 132 | ``` 133 | 134 | ## Stacking decorators 135 | 136 | You can decorate the same method multiple times if you want to hook a method to multiple moments. 137 | 138 | ```python 139 | @hook(AFTER_UPDATE, condition=WhenFieldHasChanged("published", has_changed=True)) 140 | @hook(AFTER_CREATE, condition=WhenFieldHasChanged("type", has_changed=True)) 141 | def handle_update(self): 142 | # do something 143 | ``` 144 | 145 | ## Going deeper with utility methods 146 | 147 | If you need to hook into events with more complex conditions, you can [write your own conditions](advanced.md), or take advantage of `has_changed` and `initial_value` [utility methods](advanced.md): 148 | 149 | ```python 150 | @hook(AFTER_UPDATE) 151 | def on_update(self): 152 | if self.has_changed('username') and not self.has_changed('password'): 153 | # do the thing here 154 | if self.initial_value('login_attempts') == 2: 155 | do_thing() 156 | else: 157 | do_other_thing() 158 | ``` 159 | 160 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.contrib.contenttypes.fields import GenericForeignKey 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.core import mail 6 | from django.db import models 7 | from django.utils import timezone 8 | from django.utils.functional import cached_property 9 | from urlman import Urls 10 | 11 | from django_lifecycle import AFTER_SAVE 12 | from django_lifecycle import AFTER_UPDATE 13 | from django_lifecycle import hook 14 | from django_lifecycle.models import LifecycleModel 15 | 16 | 17 | class CannotDeleteActiveTrial(Exception): 18 | pass 19 | 20 | 21 | class CannotRename(Exception): 22 | pass 23 | 24 | 25 | class Organization(LifecycleModel): 26 | name = models.CharField(max_length=100) 27 | 28 | 29 | class UserAccount(LifecycleModel): 30 | username = models.CharField(max_length=100) 31 | first_name = models.CharField(max_length=100) 32 | last_name = models.CharField(max_length=100) 33 | password = models.CharField(max_length=200) 34 | email = models.EmailField(null=True) 35 | password_updated_at = models.DateTimeField(null=True) 36 | joined_at = models.DateTimeField(null=True) 37 | has_trial = models.BooleanField(default=False) 38 | organization = models.ForeignKey(Organization, null=True, on_delete=models.SET_NULL) 39 | name_changes = models.IntegerField(default=0) 40 | configurations = models.JSONField(default=dict) 41 | 42 | status = models.CharField( 43 | default="active", 44 | max_length=30, 45 | choices=(("active", "Active"), ("banned", "Banned"), ("inactive", "Inactive")), 46 | ) 47 | 48 | class urls(Urls): 49 | view = "/books/{self.pk}/" 50 | 51 | @hook("before_save", when="email", is_not=None) 52 | def lowercase_email(self): 53 | self.email = self.email.lower() 54 | 55 | @hook("before_create") 56 | def timestamp_joined_at(self): 57 | self.joined_at = timezone.now() 58 | 59 | @hook("after_create", on_commit=True) 60 | def do_after_create_jobs(self): 61 | # queue background job to process thumbnail image... 62 | mail.send_mail("Welcome!", "Thank you for joining.", "from@example.com", ["to@example.com"]) 63 | 64 | @staticmethod 65 | def build_email_changed_body(old_email, new_email): 66 | return f"We've changed your email from {old_email} to {new_email}" 67 | 68 | @hook(AFTER_UPDATE, when="email", has_changed=True, on_commit=True) 69 | def notify_email_changed(self): 70 | mail.send_mail( 71 | subject="Email changed succesfully", 72 | message=self.build_email_changed_body( 73 | old_email=self.initial_value("email"), new_email=self.email 74 | ), 75 | from_email="from@example.com", 76 | recipient_list=["to@example.com"], 77 | ) 78 | 79 | @hook("before_update", when="password", has_changed=True) 80 | def timestamp_password_change(self): 81 | self.password_updated_at = timezone.now() 82 | 83 | @hook("before_update", when="first_name", has_changed=True) 84 | @hook("before_update", when="last_name", has_changed=True) 85 | def count_name_changes(self): 86 | self.name_changes += 1 87 | 88 | @hook("before_delete", when="has_trial", was="*", is_now=True) 89 | def ensure_trial_not_active(self): 90 | raise CannotDeleteActiveTrial("Cannot delete trial user!") 91 | 92 | @hook("before_update", when="last_name", changes_to="Flanders") 93 | def ensure_last_name_is_not_changed_to_flanders(self): 94 | raise CannotRename("Oh, not Flanders. Anybody but Flanders.") 95 | 96 | @hook("after_update", when="organization.name", has_changed=True, on_commit=True) 97 | def notify_org_name_change(self): 98 | mail.send_mail( 99 | "The name of your organization has changed!", 100 | "You organization is now named %s" % self.organization.name, 101 | "from@example.com", 102 | ["to@example.com"], 103 | ) 104 | 105 | @hook( 106 | "after_update", 107 | when="organization.name", 108 | was="Hogwarts", 109 | is_now="Hogwarts Online", 110 | ) 111 | def notify_user_they_were_moved_to_online_school(self): 112 | mail.send_mail( 113 | "You were moved to our online school!", 114 | "You organization is now named %s" % self.organization.name, 115 | "from@example.com", 116 | ["to@example.com"], 117 | ) 118 | 119 | @hook("after_delete") 120 | def email_deleted_user(self): 121 | mail.send_mail( 122 | "We have deleted your account", 123 | "Thank you for your time.", 124 | "from@example.com", 125 | ["to@example.com"], 126 | ) 127 | 128 | @hook("after_update", when="status", was="active", is_now="banned") 129 | def email_banned_user(self): 130 | mail.send_mail( 131 | "You have been banned", 132 | "You may or may not deserve it.", 133 | "from@example.com", 134 | ["to@example.com"], 135 | ) 136 | 137 | @hook("after_update", when_any=["first_name", "last_name"], has_changed=True) 138 | def email_user_about_name_change(self): 139 | mail.send_mail( 140 | "Update", 141 | "You changed your first name or your last name", 142 | "from@example.com", 143 | ["to@example.com"], 144 | ) 145 | 146 | @cached_property 147 | def full_name(self): 148 | return self.first_name + " " + self.last_name 149 | 150 | 151 | class Locale(models.Model): 152 | code = models.CharField(max_length=20) 153 | 154 | users = models.ManyToManyField(UserAccount) 155 | 156 | 157 | class ModelCustomPK(LifecycleModel): 158 | id = models.UUIDField(primary_key=True, default=uuid.uuid4) 159 | created_at = models.DateTimeField(null=True) 160 | answer = models.IntegerField(null=True, default=None) 161 | 162 | @hook("before_create") 163 | def timestamp_created_at(self): 164 | self.created_at = timezone.now() 165 | 166 | @hook("after_create") 167 | def answer_to_the_ultimate_question_of_life(self): 168 | self.answer = 42 169 | 170 | 171 | class ModelWithGenericForeignKey(LifecycleModel): 172 | tag = models.SlugField() 173 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, blank=True, null=True) 174 | object_id = models.PositiveIntegerField(blank=True, null=True) 175 | content_object = GenericForeignKey("content_type", "object_id") 176 | 177 | @hook(AFTER_SAVE, when="content_object", has_changed=True, on_commit=True) 178 | def do_something(self): 179 | print("Hey there! I am using django-lifecycle") 180 | 181 | 182 | class ModelThatFailsIfTriggered(LifecycleModel): 183 | @hook("after_create") 184 | def one_hook(self): 185 | raise RuntimeError 186 | -------------------------------------------------------------------------------- /django_lifecycle/decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import operator 4 | from dataclasses import dataclass 5 | from functools import reduce 6 | from functools import wraps 7 | from typing import Any 8 | from typing import Callable 9 | from typing import List 10 | from typing import Optional 11 | 12 | from . import types 13 | from .conditions import Always 14 | from .conditions.legacy import When 15 | from .constants import NotSet 16 | from .dataclass_validation import Validations 17 | from .hooks import VALID_HOOKS 18 | from .priority import DEFAULT_PRIORITY 19 | 20 | 21 | class DjangoLifeCycleException(Exception): 22 | pass 23 | 24 | 25 | @dataclass(order=False) 26 | class HookConfig(Validations): 27 | hook: str 28 | on_commit: bool = False 29 | priority: int = DEFAULT_PRIORITY 30 | condition: Optional[types.Condition] = None 31 | 32 | # Legacy parameters 33 | when: Optional[str] = None 34 | when_any: Optional[List[str]] = None 35 | was: Any = "*" 36 | is_now: Any = "*" 37 | has_changed: Optional[bool] = None 38 | is_not: Any = NotSet 39 | was_not: Any = NotSet 40 | changes_to: Any = NotSet 41 | 42 | def __post_init__(self): 43 | super().__post_init__() 44 | 45 | if self.condition is None: 46 | self.condition = self._get_condition_from_legacy_parameters() 47 | 48 | def _legacy_parameters_have_been_passed(self) -> bool: 49 | return any( 50 | [ 51 | self.when is not None, 52 | self.when_any is not None, 53 | self.was != "*", 54 | self.is_now != "*", 55 | self.has_changed is not None, 56 | self.is_not is not NotSet, 57 | self.was_not is not NotSet, 58 | self.changes_to is not NotSet, 59 | ] 60 | ) 61 | 62 | def _get_condition_from_legacy_parameters(self) -> Callable: 63 | if self.when: 64 | return When( 65 | when=self.when, 66 | was=self.was, 67 | is_now=self.is_now, 68 | has_changed=self.has_changed, 69 | is_not=self.is_not, 70 | was_not=self.was_not, 71 | changes_to=self.changes_to, 72 | ) 73 | 74 | elif self.when_any: 75 | return reduce( 76 | operator.or_, 77 | [ 78 | When( 79 | when=field, 80 | was=self.was, 81 | is_now=self.is_now, 82 | has_changed=self.has_changed, 83 | is_not=self.is_not, 84 | was_not=self.was_not, 85 | changes_to=self.changes_to, 86 | ) 87 | for field in self.when_any 88 | ], 89 | ) 90 | else: 91 | return Always() 92 | 93 | def validate_hook(self, value, **kwargs): 94 | if value not in VALID_HOOKS: 95 | raise DjangoLifeCycleException( 96 | "%s is not a valid hook; must be one of %s" % (hook, VALID_HOOKS) 97 | ) 98 | 99 | return value 100 | 101 | def validate_when(self, value, **kwargs): 102 | if value is not None and not isinstance(value, str): 103 | raise DjangoLifeCycleException( 104 | "'when' hook param must be a string matching the name of a model field" 105 | ) 106 | 107 | return value 108 | 109 | def validate_when_any(self, value, **kwargs): 110 | if value is None: 111 | return 112 | 113 | when_any_error_msg = ( 114 | "'when_any' hook param must be a list of strings " "matching the names of model fields" 115 | ) 116 | 117 | if not isinstance(value, list): 118 | raise DjangoLifeCycleException(when_any_error_msg) 119 | 120 | if len(value) == 0: 121 | raise DjangoLifeCycleException( 122 | "'when_any' hook param must contain at least one field name" 123 | ) 124 | 125 | for field_name in value: 126 | if not isinstance(field_name, str): 127 | raise DjangoLifeCycleException(when_any_error_msg) 128 | 129 | return value 130 | 131 | def validate_has_changed(self, value, **kwargs): 132 | if value is not None and not isinstance(value, bool): 133 | raise DjangoLifeCycleException("'has_changed' hook param must be a boolean") 134 | 135 | return value 136 | 137 | def validate_on_commit(self, value, **kwargs): 138 | if value is None: 139 | return 140 | 141 | if not isinstance(value, bool): 142 | raise DjangoLifeCycleException("'on_commit' hook param must be a boolean") 143 | 144 | return value 145 | 146 | def validate_priority(self, value, **kwargs): 147 | if self.priority < 0: 148 | raise DjangoLifeCycleException("'priority' hook param must be a positive integer") 149 | 150 | return value 151 | 152 | def validate_on_commit_only_for_after_hooks(self): 153 | if self.on_commit and not self.hook.startswith("after_"): 154 | raise DjangoLifeCycleException( 155 | "'on_commit' hook param is only valid with AFTER_* hooks" 156 | ) 157 | 158 | def validate_when_and_when_any(self): 159 | if self.when is not None and self.when_any is not None: 160 | raise DjangoLifeCycleException("Can pass either 'when' or 'when_any' but not both") 161 | 162 | def validate_condition_and_legacy_parameters_are_not_combined(self): 163 | if self.condition is not None and self._legacy_parameters_have_been_passed(): 164 | raise DjangoLifeCycleException( 165 | "Legacy parameters (when, when_any, ...) can't be used together with condition" 166 | ) 167 | 168 | def validate(self): 169 | self.validate_when_and_when_any() 170 | self.validate_on_commit_only_for_after_hooks() 171 | self.validate_condition_and_legacy_parameters_are_not_combined() 172 | 173 | def __lt__(self, other): 174 | if not isinstance(other, HookConfig): 175 | return NotImplemented 176 | 177 | return self.priority < other.priority 178 | 179 | def __call__(self, hooked_method): 180 | if not hasattr(hooked_method, "_hooked"): 181 | 182 | @wraps(hooked_method) 183 | def func(*args, **kwargs): 184 | hooked_method(*args, **kwargs) 185 | 186 | func._hooked = [] 187 | else: 188 | func = hooked_method 189 | 190 | func._hooked.append(self) 191 | 192 | # Sort hooked methods by priority 193 | func._hooked = sorted(func._hooked) 194 | 195 | return func 196 | 197 | 198 | hook = HookConfig # keep backwards compatibility 199 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_conditions.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django_lifecycle.constants import NotSet 4 | from django_lifecycle.conditions import WhenFieldValueChangesTo 5 | from django_lifecycle.conditions import WhenFieldHasChanged 6 | from django_lifecycle.conditions import WhenFieldValueIsNot 7 | from django_lifecycle.conditions import WhenFieldValueIs 8 | from django_lifecycle.conditions import WhenFieldValueWas 9 | from django_lifecycle.conditions import WhenFieldValueWasNot 10 | from tests.testapp.models import UserAccount 11 | 12 | 13 | class ChainableConditionsTests(TestCase): 14 | def test_and_condition(self): 15 | is_homer = WhenFieldValueIs("first_name", value="Homer") 16 | is_simpson = WhenFieldValueIs("last_name", value="Simpson") 17 | is_homer_simpson = is_homer & is_simpson 18 | 19 | homer_simpson = UserAccount(first_name="Homer", last_name="Simpson") 20 | self.assertTrue(is_homer_simpson(homer_simpson)) 21 | 22 | homer_flanders = UserAccount(first_name="Homer", last_name="Flanders") 23 | self.assertFalse(is_homer_simpson(homer_flanders)) 24 | 25 | ned_simpson = UserAccount(first_name="Ned", last_name="Simpson") 26 | self.assertFalse(is_homer_simpson(ned_simpson)) 27 | 28 | def test_or_condition(self): 29 | is_admin = WhenFieldValueIs("username", value="admin") 30 | is_superuser = WhenFieldValueIs("username", value="superuser") 31 | user_has_superpowers = is_admin | is_superuser 32 | 33 | self.assertTrue(user_has_superpowers(UserAccount(username="admin"))) 34 | self.assertTrue(user_has_superpowers(UserAccount(username="superuser"))) 35 | self.assertFalse(user_has_superpowers(UserAccount(username="citizen"))) 36 | 37 | 38 | class ConditionsTests(TestCase): 39 | @property 40 | def stub_data(self): 41 | return { 42 | "username": "homer.simpson", 43 | "first_name": "Homer", 44 | "last_name": "Simpson", 45 | "password": "donuts", 46 | } 47 | 48 | def test_has_changed_specs(self): 49 | condition = WhenFieldHasChanged("first_name", has_changed=True) 50 | 51 | data = self.stub_data 52 | data["first_name"] = "Homer" 53 | UserAccount.objects.create(**data) 54 | user_account = UserAccount.objects.get() 55 | 56 | self.assertFalse(condition(user_account)) 57 | user_account.first_name = "Ned" 58 | self.assertTrue(condition(user_account)) 59 | 60 | def test_check_is_now_condition_wildcard_should_pass(self): 61 | condition = WhenFieldValueIs("first_name", value="*") 62 | data = self.stub_data 63 | data["first_name"] = "Homer" 64 | UserAccount.objects.create(**data) 65 | user_account = UserAccount.objects.get() 66 | user_account.first_name = "Ned" 67 | self.assertTrue(condition(user_account)) 68 | 69 | def test_check_is_now_condition_matching_value_should_pass(self): 70 | condition = WhenFieldValueIs("first_name", value="Ned") 71 | data = self.stub_data 72 | data["first_name"] = "Homer" 73 | UserAccount.objects.create(**data) 74 | user_account = UserAccount.objects.get() 75 | user_account.first_name = "Ned" 76 | self.assertTrue(condition(user_account)) 77 | 78 | def test_check_is_now_condition_not_matched_value_should_not_pass(self): 79 | condition = WhenFieldValueIs("first_name", value="Bart") 80 | data = self.stub_data 81 | data["first_name"] = "Homer" 82 | UserAccount.objects.create(**data) 83 | user_account = UserAccount.objects.get() 84 | self.assertFalse(condition(user_account)) 85 | 86 | def test_check_was_not_condition_should_pass_when_not_set(self): 87 | condition = WhenFieldValueWasNot("first_name", value=NotSet) 88 | data = self.stub_data 89 | UserAccount.objects.create(**data) 90 | user_account = UserAccount.objects.get() 91 | self.assertTrue(condition(user_account)) 92 | 93 | def test_check_was_not_condition_not_matching_value_should_pass(self): 94 | condition = WhenFieldValueWasNot("first_name", value="Bart") 95 | 96 | data = self.stub_data 97 | data["first_name"] = "Homer" 98 | UserAccount.objects.create(**data) 99 | user_account = UserAccount.objects.get() 100 | self.assertTrue(condition(user_account)) 101 | 102 | def test_check_was_not_condition_matched_value_should_not_pass(self): 103 | condition = WhenFieldValueWasNot("first_name", value="Homer") 104 | 105 | data = self.stub_data 106 | data["first_name"] = "Homer" 107 | UserAccount.objects.create(**data) 108 | user_account = UserAccount.objects.get() 109 | self.assertFalse(condition(user_account)) 110 | 111 | def test_check_was_condition_wildcard_should_pass(self): 112 | condition = WhenFieldValueWas("first_name", value="*") 113 | 114 | data = self.stub_data 115 | data["first_name"] = "Homer" 116 | UserAccount.objects.create(**data) 117 | user_account = UserAccount.objects.get() 118 | self.assertTrue(condition(user_account)) 119 | 120 | def test_check_was_condition_matching_value_should_pass(self): 121 | condition = WhenFieldValueWas("first_name", value="Homer") 122 | 123 | data = self.stub_data 124 | data["first_name"] = "Homer" 125 | UserAccount.objects.create(**data) 126 | user_account = UserAccount.objects.get() 127 | self.assertTrue(condition(user_account)) 128 | 129 | def test_check_was_condition_not_matched_value_should_not_pass(self): 130 | condition = WhenFieldValueWas("first_name", value="Bart") 131 | 132 | data = self.stub_data 133 | data["first_name"] = "Homer" 134 | UserAccount.objects.create(**data) 135 | user_account = UserAccount.objects.get() 136 | self.assertFalse(condition(user_account)) 137 | 138 | def test_is_not_condition_should_pass(self): 139 | condition = WhenFieldValueIsNot("first_name", value="Ned") 140 | 141 | data = self.stub_data 142 | data["first_name"] = "Homer" 143 | UserAccount.objects.create(**data) 144 | user_account = UserAccount.objects.get() 145 | self.assertTrue(condition(user_account)) 146 | 147 | def test_is_not_condition_should_not_pass(self): 148 | condition = WhenFieldValueIsNot("first_name", value="Ned") 149 | 150 | data = self.stub_data 151 | data["first_name"] = "Ned" 152 | UserAccount.objects.create(**data) 153 | user_account = UserAccount.objects.get() 154 | self.assertFalse(condition(user_account)) 155 | 156 | def test_changes_to_condition_should_pass(self): 157 | condition = WhenFieldValueChangesTo("last_name", value="Flanders") 158 | data = self.stub_data 159 | UserAccount.objects.create(**data) 160 | user_account = UserAccount.objects.get() 161 | user_account.last_name = "Flanders" 162 | self.assertTrue(condition(user_account)) 163 | 164 | def test_changes_to_condition_included_in_update_fields_should_fire_hook(self): 165 | condition = WhenFieldValueChangesTo("last_name", value="Flanders") 166 | user_account = UserAccount.objects.create(**self.stub_data) 167 | user_account.last_name = "Flanders" 168 | self.assertTrue(condition(user_account, update_fields=["last_name"])) 169 | 170 | def test_changes_to_condition_not_included_in_update_fields_should_not_fire_hook( 171 | self, 172 | ): 173 | condition = WhenFieldValueChangesTo("last_name", value="Flanders") 174 | user_account = UserAccount.objects.create(**self.stub_data) 175 | user_account.last_name = "Flanders" 176 | self.assertFalse(condition(user_account, update_fields=["first_name"])) 177 | 178 | def test_changes_to_condition_should_not_pass(self): 179 | condition = WhenFieldValueChangesTo("last_name", value="Flanders") 180 | data = self.stub_data 181 | data["first_name"] = "Marge" 182 | data["last_name"] = "Simpson" 183 | UserAccount.objects.create(**data) 184 | user_account = UserAccount.objects.get() 185 | user_account.last_name = "Bouvier" 186 | self.assertFalse(condition(user_account)) 187 | -------------------------------------------------------------------------------- /docs/hooks_and_conditions.md: -------------------------------------------------------------------------------- 1 | # Available Hooks & Conditions 2 | 3 | You can hook into one or more lifecycle moments by adding the `@hook` decorator to a model's method. The moment name 4 | is passed as the first positional argument, `@hook(BEFORE_CREATE)`, and optional keyword arguments can be passed to 5 | set up conditions for when the method should fire. 6 | 7 | ## Decorator Signature 8 | 9 | ```python 10 | @hook( 11 | moment: str, 12 | condition: Optional[types.Condition] = None, 13 | priority: int = DEFAULT_PRIORITY, 14 | on_commit: Optional[bool] = None, 15 | 16 | # Legacy parameters 17 | when: str = None, 18 | when_any: List[str] = None, 19 | has_changed: bool = None, 20 | is_now: Any = '*', 21 | is_not: Any = None, 22 | was: Any = '*', 23 | was_not: Any = None, 24 | changes_to: Any = None, 25 | ) 26 | ``` 27 | 28 | ## Lifecycle Moments 29 | 30 | Below is a full list of hooks, in the same order in which they will get called during the respective operations: 31 | 32 | | Hook constant | Hook name | When it fires | 33 | | :-------------: | :-----------: | :--------------------------------------------------------------- | 34 | | `BEFORE_SAVE` | before_save | Immediately before `save` is called | 35 | | `AFTER_SAVE` | after_save | Immediately after `save` is called | 36 | | `BEFORE_CREATE` | before_create | Immediately before `save` is called, if `pk` is `None` | 37 | | `AFTER_CREATE` | after_create | Immediately after `save` is called, if `pk` was initially `None` | 38 | | `BEFORE_UPDATE` | before_update | Immediately before `save` is called, if `pk` is NOT `None` | 39 | | `AFTER_UPDATE` | after_update | Immediately after `save` is called, if `pk` was NOT `None` | 40 | | `BEFORE_DELETE` | before_delete | Immediately before `delete` is called | 41 | | `AFTER_DELETE` | after_delete | Immediately after `delete` is called | 42 | 43 | All of hook constants are strings containing the specific hook name, for example `AFTER_UPDATE` is string 44 | `"after_update"` - preferably way is to use hook constant. 45 | 46 | ## Conditions 47 | 48 | You can add a condition to specify in which case the hook will be fired or not, depending on the initial or current 49 | state of a model instance's fields. 50 | 51 | There are some conditions already implemented, but you can provide your own condition, or even chain them using `&` and `|`. 52 | 53 | - `WhenFieldHasChanged(field_name, has_changed)` 54 | - `WhenFieldValueIs(field_name, value)` 55 | - `WhenFieldValueIsNot(field_name, value)` 56 | - `WhenFieldValueWas(field_name, value)` 57 | - `WhenFieldValueWasNot(field_name, value)` 58 | - `WhenFieldValueChangesTo(field_name, value)` 59 | 60 | ### Chaining conditions 61 | Conditions can be chained using `&` and `|` boolean operators 62 | 63 | ```python 64 | from django_lifecycle.conditions import WhenFieldValueChangesTo 65 | from django_lifecycle import hook, BEFORE_UPDATE 66 | 67 | @hook( 68 | BEFORE_UPDATE, 69 | condition=( 70 | WhenFieldValueChangesTo("first_name", value="Ned") 71 | & WhenFieldValueChangesTo("last_name", value="Flanders") 72 | ) 73 | ) 74 | def do_something(self): 75 | ... 76 | ``` 77 | 78 | ## Legacy Condition Keyword Arguments 79 | 80 | If you do not use any conditional parameters, the hook will fire every time the lifecycle moment occurs. You can use the keyword arguments below to conditionally fire the method depending on the initial or current state of a model instance's fields. 81 | 82 | | Keyword arg | Type | Details | 83 | | :---------: | :-------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | 84 | | when | str | The name of the field that you want to check against; required for the conditions below to be checked. Use the name of a FK field to watch changes to the related model _reference_ or use dot-notation to watch changes to the _values_ of fields on related models, e.g. `"organization.name"`. But [please be aware](fk_changes.md#fk-hook-warning) of potential performance drawbacks. | 85 | | when_any | List[str] | Similar to the `when` parameter, but takes a list of field names. The hooked method will fire if any of the corresponding fields meet the keyword conditions. Useful if you don't like stacking decorators. | 86 | | has_changed | bool | Only fire the hooked method if the value of the `when` field has changed since the model was initialized | 87 | | is_now | Any | Only fire the hooked method if the value of the `when` field is currently equal to this value; defaults to `*`. | 88 | | is_not | Any | Only fire the hooked method if the value of the `when` field is NOT equal to this value | 89 | | was | Any | Only fire the hooked method if the value of the `when` field was equal to this value when first initialized; defaults to `*`. | 90 | | was_not | Any | Only fire the hooked method if the value of the `when` field was NOT equal to this value when first initialized. | 91 | | changes_to | Any | Only fire the hooked method if the value of the `when` field was NOT equal to this value when first initialized but is currently equal to this value. | 92 | | priority | int | Specify the priority, useful when some hooked methods depend on other ones. | 93 | | on_commit | bool | When `True` only fire the hooked method after the current database transaction has been commited or not at all. (Only applies to `AFTER_*` hooks) | 94 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_user_account.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import django 4 | from django.core import mail 5 | from django.test import TestCase 6 | 7 | from tests.testapp.models import CannotDeleteActiveTrial 8 | from tests.testapp.models import Organization 9 | from tests.testapp.models import UserAccount 10 | 11 | if django.VERSION < (3, 2): 12 | from django_capture_on_commit_callbacks import TestCaseMixin 13 | else: 14 | 15 | class TestCaseMixin: 16 | """Dummy implementation for Django >= 4.0""" 17 | 18 | 19 | class UserAccountTestCase(TestCaseMixin, TestCase): 20 | @property 21 | def stub_data(self): 22 | return { 23 | "username": "homer.simpson", 24 | "first_name": "Homer", 25 | "last_name": "Simpson", 26 | "password": "donuts", 27 | } 28 | 29 | def test_update_joined_at_before_create(self): 30 | account = UserAccount.objects.create(**self.stub_data) 31 | account.refresh_from_db() 32 | self.assertTrue(isinstance(account.joined_at, datetime.datetime)) 33 | 34 | def test_send_welcome_email_after_create(self): 35 | with self.captureOnCommitCallbacks(execute=True) as callbacks: 36 | UserAccount.objects.create(**self.stub_data) 37 | 38 | self.assertEqual(len(callbacks), 2, msg=f"{callbacks}") 39 | self.assertEqual(len(mail.outbox), 1) 40 | self.assertEqual(mail.outbox[0].subject, "Welcome!") 41 | 42 | def test_initial_values_on_commit_hook(self): 43 | initial_email = "homer.simpson@springfieldnuclear.com" 44 | account = UserAccount.objects.create(**self.stub_data, email=initial_email) 45 | 46 | new_email = "homer.thompson@springfieldnuclear.com" 47 | account.email = new_email 48 | with self.captureOnCommitCallbacks(execute=True): 49 | account.save() 50 | 51 | self.assertEqual(len(mail.outbox), 1) 52 | self.assertEqual( 53 | mail.outbox[0].body, 54 | account.build_email_changed_body( 55 | old_email=initial_email, new_email=new_email 56 | ), 57 | ) 58 | 59 | def test_email_banned_user_after_update(self): 60 | account = UserAccount.objects.create(status="active", **self.stub_data) 61 | account.refresh_from_db() 62 | mail.outbox = [] 63 | account.status = "banned" 64 | account.save() 65 | self.assertEqual(mail.outbox[0].subject, "You have been banned") 66 | 67 | def test_update_password_updated_at_during_update(self): 68 | account = UserAccount.objects.create(**self.stub_data) 69 | account.refresh_from_db() 70 | account.password = "maggie" 71 | account.save() 72 | account.refresh_from_db() 73 | 74 | self.assertTrue(isinstance(account.password_updated_at, datetime.datetime)) 75 | 76 | def test_ensure_trial_not_active_before_delete(self): 77 | account = UserAccount.objects.create(**self.stub_data) 78 | account.has_trial = True 79 | account.save() 80 | self.assertRaises(CannotDeleteActiveTrial, account.delete) 81 | 82 | def test_email_after_delete(self): 83 | account = UserAccount.objects.create(**self.stub_data) 84 | mail.outbox = [] 85 | account.delete() 86 | self.assertEqual(len(mail.outbox), 1) 87 | self.assertEqual(mail.outbox[0].subject, "We have deleted your account") 88 | 89 | def test_only_call_hook_once(self): 90 | account = UserAccount.objects.create(**self.stub_data) 91 | account.first_name = "Waylon" 92 | account.last_name = "Smithers" 93 | account.save() 94 | self.assertEqual(account.name_changes, 1) 95 | 96 | def test_lowercase_email(self): 97 | data = self.stub_data 98 | data["email"] = "Homer.Simpson@SpringfieldNuclear.com" 99 | account = UserAccount.objects.create(**data) 100 | self.assertEqual(account.email, "homer.simpson@springfieldnuclear.com") 101 | 102 | def test_notify_org_name_change(self): 103 | org = Organization.objects.create(name="Hogwarts") 104 | UserAccount.objects.create(**self.stub_data, organization=org) 105 | mail.outbox = [] 106 | 107 | account = UserAccount.objects.get() 108 | 109 | with self.captureOnCommitCallbacks(execute=True) as callbacks: 110 | org.name = "Coursera Wizardry" 111 | org.save() 112 | 113 | account.save() 114 | 115 | self.assertEqual(len(callbacks), 3) 116 | self.assertEqual(len(mail.outbox), 1) 117 | self.assertEqual( 118 | mail.outbox[0].subject, "The name of your organization has changed!" 119 | ) 120 | 121 | def test_no_notify_sent_if_org_name_has_not_changed(self): 122 | org = Organization.objects.create(name="Hogwarts") 123 | UserAccount.objects.create(**self.stub_data, organization=org) 124 | mail.outbox = [] 125 | account = UserAccount.objects.get() 126 | account.save() 127 | self.assertEqual(len(mail.outbox), 0) 128 | 129 | def test_additional_notify_sent_for_specific_org_name_change(self): 130 | org = Organization.objects.create(name="Hogwarts") 131 | UserAccount.objects.create(**self.stub_data, organization=org) 132 | 133 | mail.outbox = [] 134 | 135 | with self.captureOnCommitCallbacks(execute=True) as callbacks: 136 | account = UserAccount.objects.get() 137 | org.name = "Hogwarts Online" 138 | org.save() 139 | account.save() 140 | 141 | self.assertEqual( 142 | len(callbacks), 143 | 3, 144 | msg="One hook and the _reset_initial_state (2) should be in the on_commit callbacks", 145 | ) 146 | self.assertEqual(len(mail.outbox), 2) 147 | self.assertEqual( 148 | mail.outbox[1].subject, "The name of your organization has changed!" 149 | ) 150 | self.assertEqual(mail.outbox[0].subject, "You were moved to our online school!") 151 | 152 | def test_email_user_about_name_change(self): 153 | account = UserAccount.objects.create(**self.stub_data) 154 | mail.outbox = [] 155 | account.first_name = "Homer the Great" 156 | account.save() 157 | self.assertEqual( 158 | mail.outbox[0].body, "You changed your first name or your last name" 159 | ) 160 | 161 | def test_does_not_email_user_about_name_change_when_name_excluded_from_update_fields( 162 | self, 163 | ): 164 | account = UserAccount.objects.create(**self.stub_data) 165 | mail.outbox = [] 166 | account.first_name = "Homer the Great" 167 | account.password = "New password!" 168 | 169 | old_password_updated_at = account.password_updated_at 170 | account.save(update_fields=["password"]) 171 | self.assertEqual( 172 | len(mail.outbox), 0 173 | ) # `first_name` change was skipped (as a hook). 174 | self.assertNotEqual( 175 | account.password_updated_at, old_password_updated_at 176 | ) # Ensure the other hook is fired. 177 | 178 | def test_emails_user_about_name_change_when_one_field_from_update_fields_intersects_with_condition( 179 | self, 180 | ): 181 | account = UserAccount.objects.create(**self.stub_data) 182 | mail.outbox = [] 183 | account.first_name = "Homer the Great" 184 | account.password = "New password!" 185 | 186 | old_password_updated_at = account.password_updated_at 187 | account.save(update_fields=["first_name", "password"]) 188 | self.assertEqual( 189 | mail.outbox[0].body, "You changed your first name or your last name" 190 | ) 191 | self.assertNotEqual( 192 | account.password_updated_at, old_password_updated_at 193 | ) # Both hooks fired. 194 | 195 | def test_empty_update_fields_does_not_fire_any_hooks(self): 196 | # In Django, an empty list supplied to `update_fields` means not updating any field. 197 | account = UserAccount.objects.create(**self.stub_data) 198 | mail.outbox = [] 199 | account.first_name = "Flanders" 200 | account.password = "new pass" 201 | 202 | old_password_updated_at = account.password_updated_at 203 | account.save(update_fields=[]) 204 | # Did not raise, so last name hook didn't fire. 205 | self.assertEqual(len(mail.outbox), 0) 206 | self.assertEqual( 207 | account.password_updated_at, old_password_updated_at 208 | ) # Password hook didn't fire either. 209 | 210 | def test_skip_hooks(self): 211 | """ 212 | Hooked method that auto-lowercases email should be skipped. 213 | """ 214 | account = UserAccount.objects.create(**self.stub_data) 215 | account.email = "Homer.Simpson@springfieldnuclear" 216 | account.save(skip_hooks=True) 217 | self.assertEqual(account.email, "Homer.Simpson@springfieldnuclear") 218 | 219 | def test_delete_should_return_default_django_value(self): 220 | """ 221 | Hooked method that auto-lowercases email should be skipped. 222 | """ 223 | UserAccount.objects.create(**self.stub_data) 224 | deleted, rows_count = UserAccount.objects.all().delete() 225 | 226 | self.assertEqual(deleted, 1) 227 | self.assertEqual(rows_count["testapp.UserAccount"], 1) 228 | -------------------------------------------------------------------------------- /django_lifecycle/mixins.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import threading 4 | from contextlib import contextmanager 5 | from functools import partial, lru_cache 6 | from inspect import isfunction 7 | from typing import Any, List 8 | from typing import Iterable 9 | from typing import TypeVar 10 | 11 | from django.db import transaction 12 | from django.db.models.fields.related_descriptors import ( 13 | ForwardManyToOneDescriptor, 14 | ReverseOneToOneDescriptor, 15 | ReverseManyToOneDescriptor, 16 | ManyToManyDescriptor, 17 | ForwardOneToOneDescriptor, 18 | ) 19 | from django.utils.functional import cached_property 20 | 21 | from .abstract import AbstractHookedMethod 22 | from .decorators import HookConfig 23 | from .hooks import ( 24 | BEFORE_CREATE, 25 | BEFORE_UPDATE, 26 | BEFORE_SAVE, 27 | BEFORE_DELETE, 28 | AFTER_CREATE, 29 | AFTER_UPDATE, 30 | AFTER_SAVE, 31 | AFTER_DELETE, 32 | ) 33 | from .model_state import ModelState 34 | from .utils import get_value 35 | from .utils import sanitize_field_name 36 | 37 | DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES = ( 38 | ForwardManyToOneDescriptor, 39 | ForwardOneToOneDescriptor, 40 | ManyToManyDescriptor, 41 | ReverseManyToOneDescriptor, 42 | ReverseOneToOneDescriptor, 43 | ) 44 | 45 | 46 | class LifecycleHookBypass: 47 | def __init__(self): 48 | self._state = threading.local() 49 | 50 | @staticmethod 51 | def get_model_full_name(model) -> str: 52 | return f"{model.__module__}:{model.__qualname__}" 53 | 54 | def set_bypass_for(self, model): 55 | setattr( 56 | self._state, 57 | self.get_model_full_name(model), 58 | True, 59 | ) 60 | 61 | def remove_bypass_for(self, model): 62 | delattr(self._state, self.get_model_full_name(model)) 63 | 64 | def is_bypassed_for(self, model) -> bool: 65 | return getattr(self._state, self.get_model_full_name(model), False) 66 | 67 | 68 | _bypass_state = LifecycleHookBypass() 69 | 70 | 71 | class HookedMethod(AbstractHookedMethod): 72 | @property 73 | def name(self) -> str: 74 | return self.method.__name__ 75 | 76 | def run(self, instance: Any) -> None: 77 | self.method(instance) 78 | 79 | 80 | class OnCommitHookedMethod(AbstractHookedMethod): 81 | """Hooked method that should run on_commit""" 82 | 83 | @property 84 | def name(self) -> str: 85 | # Append `_on_commit` to the existing method name to allow for firing 86 | # the same hook within the atomic transaction and on_commit 87 | return f"{self.method.__name__}_on_commit" 88 | 89 | def run(self, instance: Any) -> None: 90 | # Use partial to create a function closure that binds `self` 91 | # to ensure it's available to execute later. 92 | _on_commit_func = partial(self.method, instance) 93 | _on_commit_func.__name__ = self.name 94 | transaction.on_commit(_on_commit_func) 95 | 96 | 97 | def instantiate_hooked_method( 98 | method: Any, callback_specs: HookConfig 99 | ) -> AbstractHookedMethod: 100 | hooked_method_class = ( 101 | OnCommitHookedMethod if callback_specs.on_commit else HookedMethod 102 | ) 103 | return hooked_method_class( 104 | method=method, 105 | priority=callback_specs.priority, 106 | ) 107 | 108 | 109 | class LifecycleModelMixin(object): 110 | def __init__(self, *args, **kwargs): 111 | super().__init__(*args, **kwargs) 112 | self._initial_state = ModelState.from_instance(self) 113 | 114 | def _snapshot_state(self) -> dict: 115 | return ModelState.from_instance(self).initial_state 116 | 117 | @property 118 | def _diff_with_initial(self) -> dict: 119 | return self._initial_state.get_diff(self) 120 | 121 | def _sanitize_field_name(self, field_name: str) -> str: 122 | return sanitize_field_name(self, field_name) 123 | 124 | def _current_value(self, field_name: str) -> Any: 125 | return get_value(self, field_name) 126 | 127 | def initial_value(self, field_name: str) -> Any: 128 | """ 129 | Get initial value of field when model value instantiated. 130 | """ 131 | return self._initial_state.get_value(self, field_name) 132 | 133 | def has_changed(self, field_name: str) -> bool: 134 | """ 135 | Check if a field has changed since the model value instantiated. 136 | """ 137 | return self._initial_state.has_changed(self, field_name) 138 | 139 | def _clear_watched_fk_model_cache(self): 140 | """ """ 141 | for watched_field_name in self._watched_fk_models(): 142 | field = self._meta.get_field(watched_field_name) 143 | 144 | if field.is_relation and field.is_cached(self): 145 | field.delete_cached_value(self) 146 | 147 | def _reset_initial_state(self): 148 | self._initial_state = ModelState.from_instance(self) 149 | 150 | @transaction.atomic 151 | def save(self, *args, **kwargs): 152 | skip_hooks = kwargs.pop("skip_hooks", False) 153 | save = super().save 154 | 155 | skip_hooks_from_cm = _bypass_state.is_bypassed_for(self.__class__) 156 | if skip_hooks or skip_hooks_from_cm: 157 | save(*args, **kwargs) 158 | return 159 | 160 | self._clear_watched_fk_model_cache() 161 | is_new = self._state.adding 162 | 163 | if is_new: 164 | self._run_hooked_methods(BEFORE_CREATE, **kwargs) 165 | else: 166 | self._run_hooked_methods(BEFORE_UPDATE, **kwargs) 167 | 168 | self._run_hooked_methods(BEFORE_SAVE, **kwargs) 169 | save(*args, **kwargs) 170 | self._run_hooked_methods(AFTER_SAVE, **kwargs) 171 | 172 | if is_new: 173 | self._run_hooked_methods(AFTER_CREATE, **kwargs) 174 | else: 175 | self._run_hooked_methods(AFTER_UPDATE, **kwargs) 176 | 177 | transaction.on_commit(self._reset_initial_state) 178 | 179 | @transaction.atomic 180 | def delete(self, *args, **kwargs): 181 | self._run_hooked_methods(BEFORE_DELETE, **kwargs) 182 | value = super().delete(*args, **kwargs) 183 | self._run_hooked_methods(AFTER_DELETE, **kwargs) 184 | return value 185 | 186 | def refresh_from_db(self, *args, **kwargs): 187 | super().refresh_from_db(*args, **kwargs) 188 | self._initial_state = ModelState.from_instance(self) 189 | 190 | @classmethod 191 | @lru_cache(typed=True) 192 | def _potentially_hooked_methods(cls): 193 | skip = set(cls._get_unhookable_attribute_names()) 194 | collected = [] 195 | 196 | for name in dir(cls): 197 | if name in skip: 198 | continue 199 | try: 200 | attr = getattr(cls, name) 201 | if isfunction(attr) and hasattr(attr, "_hooked"): 202 | collected.append(attr) 203 | except AttributeError: 204 | pass 205 | 206 | return collected 207 | 208 | @classmethod 209 | @lru_cache(typed=True) 210 | def _watched_fk_model_fields(cls) -> List[str]: 211 | """ 212 | Gather up all field names (values in 'when' key) that correspond to 213 | field names on FK-related models. These will be strings that contain 214 | periods. 215 | """ 216 | watched = [] # List[str] 217 | 218 | for method in cls._potentially_hooked_methods(): 219 | for hook_config in method._hooked: 220 | if hook_config.when is not None and "." in hook_config.when: 221 | watched.append(hook_config.when) 222 | 223 | return watched 224 | 225 | @classmethod 226 | @lru_cache(typed=True) 227 | def _watched_fk_models(cls) -> List[str]: 228 | return [_.split(".")[0] for _ in cls._watched_fk_model_fields()] 229 | 230 | def _get_hooked_methods( 231 | self, hook: str, update_fields: Iterable[str] | None = None, **kwargs 232 | ) -> List[AbstractHookedMethod]: 233 | """ 234 | Iterate through decorated methods to find those that should be 235 | triggered by the current hook. If conditions exist, check them before 236 | adding it to the list of methods to fire. 237 | 238 | Then, sort the list. 239 | """ 240 | 241 | hooked_methods = [] 242 | 243 | for method in self._potentially_hooked_methods(): 244 | for callback_specs in method._hooked: 245 | if callback_specs.hook != hook: 246 | continue 247 | 248 | if callback_specs.condition(self, update_fields=update_fields): 249 | hooked_method = instantiate_hooked_method(method, callback_specs) 250 | hooked_methods.append(hooked_method) 251 | 252 | # Only store the method once per hook 253 | break 254 | 255 | return sorted(hooked_methods) 256 | 257 | def _run_hooked_methods(self, hook: str, **kwargs) -> List[str]: 258 | """Run hooked methods""" 259 | fired = [] 260 | 261 | for method in self._get_hooked_methods(hook, **kwargs): 262 | method.run(self) 263 | fired.append(method.name) 264 | 265 | return fired 266 | 267 | @classmethod 268 | def _get_model_property_names(cls) -> List[str]: 269 | """ 270 | Gather up properties and cached_properties which may be methods 271 | that were decorated. Need to inspect class versions b/c doing 272 | getattr on them could cause unwanted side effects. 273 | """ 274 | property_names = [] 275 | 276 | for name in dir(cls): 277 | attr = getattr(cls, name, None) 278 | 279 | if attr and ( 280 | isinstance(attr, property) or isinstance(attr, cached_property) 281 | ): 282 | property_names.append(name) 283 | 284 | return property_names 285 | 286 | @classmethod 287 | def _get_model_descriptor_names(cls) -> List[str]: 288 | """ 289 | Attributes which are Django descriptors. These represent a field 290 | which is a one-to-many or many-to-many relationship that is 291 | potentially defined in another model, and doesn't otherwise appear 292 | as a field on this model. 293 | """ 294 | 295 | descriptor_names = [] 296 | 297 | for name in dir(cls): 298 | attr = getattr(cls, name, None) 299 | 300 | if attr and isinstance(attr, DJANGO_RELATED_FIELD_DESCRIPTOR_CLASSES): 301 | descriptor_names.append(name) 302 | 303 | return descriptor_names 304 | 305 | @classmethod 306 | def _get_field_names(cls) -> List[str]: 307 | names = [] 308 | 309 | for f in cls._meta.get_fields(): 310 | names.append(f.name) 311 | 312 | field = cls._meta.get_field(f.name) 313 | try: 314 | internal_type = field.get_internal_type() 315 | except AttributeError: 316 | continue 317 | 318 | if internal_type == "ForeignKey" or internal_type == "OneToOneField": 319 | names.append(f.name + "_id") 320 | 321 | return names 322 | 323 | @classmethod 324 | def _get_unhookable_attribute_names(cls) -> List[str]: 325 | return ( 326 | cls._get_field_names() 327 | + cls._get_model_descriptor_names() 328 | + cls._get_model_property_names() 329 | + ["_run_hooked_methods"] 330 | ) 331 | 332 | 333 | T = TypeVar("T", bound=LifecycleHookBypass) 334 | 335 | 336 | @contextmanager 337 | def bypass_hooks_for(models: Iterable[T]): 338 | try: 339 | for model in models: 340 | _bypass_state.set_bypass_for(model) 341 | yield 342 | finally: 343 | for model in models: 344 | _bypass_state.remove_bypass_for(model) 345 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_mixin.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import django 4 | from django.test import TestCase 5 | 6 | from django_lifecycle import bypass_hooks_for 7 | from django_lifecycle.constants import NotSet 8 | from django_lifecycle.decorators import HookConfig 9 | from django_lifecycle.priority import DEFAULT_PRIORITY 10 | from tests.testapp.models import CannotRename 11 | from tests.testapp.models import ModelThatFailsIfTriggered 12 | from tests.testapp.models import Organization 13 | from tests.testapp.models import UserAccount 14 | 15 | if django.VERSION < (4, 0): 16 | from django_capture_on_commit_callbacks import TestCaseMixin 17 | else: 18 | 19 | class TestCaseMixin: 20 | """Dummy implementation for Django >= 4.0""" 21 | 22 | 23 | class LifecycleMixinTests(TestCaseMixin, TestCase): 24 | def setUp(self): 25 | UserAccount.objects.all().delete() 26 | Organization.objects.all().delete() 27 | 28 | @property 29 | def stub_data(self): 30 | return { 31 | "username": "homer.simpson", 32 | "first_name": "Homer", 33 | "last_name": "Simpson", 34 | "password": "donuts", 35 | } 36 | 37 | def test_snapshot_state(self): 38 | org = Organization.objects.create(name="Dunder Mifflin") 39 | UserAccount.objects.create(**self.stub_data, organization=org) 40 | user_account = UserAccount.objects.get() 41 | 42 | state = user_account._snapshot_state() 43 | 44 | self.assertEqual( 45 | state, 46 | { 47 | "id": user_account.id, 48 | "username": "homer.simpson", 49 | "first_name": "Homer", 50 | "last_name": "Simpson", 51 | "password": "donuts", 52 | "email": None, 53 | "password_updated_at": None, 54 | "joined_at": user_account.joined_at, 55 | "has_trial": False, 56 | "organization_id": org.id, 57 | "status": "active", 58 | "organization.name": "Dunder Mifflin", 59 | "name_changes": 0, 60 | "configurations": {}, 61 | }, 62 | ) 63 | 64 | def test_initial_value_for_fk_model_field(self): 65 | UserAccount.objects.create( 66 | **self.stub_data, 67 | organization=Organization.objects.create(name="Dunder Mifflin"), 68 | ) 69 | 70 | user_account = UserAccount.objects.get() 71 | self.assertEqual(user_account.initial_value("organization.name"), "Dunder Mifflin") 72 | 73 | def test_initial_value_if_field_has_changed(self): 74 | data = self.stub_data 75 | data["username"] = "Joe" 76 | UserAccount.objects.create(**data) 77 | user_account = UserAccount.objects.get() 78 | self.assertFalse(user_account.has_changed("username")) 79 | user_account.username = "Josephine" 80 | self.assertEqual(user_account.initial_value("username"), "Joe") 81 | 82 | def test_initial_value_if_field_has_not_changed(self): 83 | data = self.stub_data 84 | data["username"] = "Joe" 85 | UserAccount.objects.create(**data) 86 | user_account = UserAccount.objects.get() 87 | self.assertEqual(user_account.initial_value("username"), "Joe") 88 | 89 | def test_current_value_for_watched_fk_model_field(self): 90 | org = Organization.objects.create(name="Dunder Mifflin") 91 | UserAccount.objects.create(**self.stub_data, organization=org) 92 | user_account = UserAccount.objects.get() 93 | 94 | self.assertEqual(user_account._current_value("organization.name"), "Dunder Mifflin") 95 | 96 | org.name = "Dwight's Paper Empire" 97 | org.save() 98 | user_account._clear_watched_fk_model_cache() 99 | 100 | self.assertEqual(user_account._current_value("organization.name"), "Dwight's Paper Empire") 101 | 102 | def test_run_hooked_methods_for_when(self): 103 | instance = UserAccount(first_name="Bob") 104 | 105 | instance._potentially_hooked_methods = MagicMock( 106 | return_value=[ 107 | MagicMock( 108 | __name__="method_that_does_fires", 109 | _hooked=[ 110 | HookConfig( 111 | hook="after_create", 112 | when="first_name", 113 | when_any=None, 114 | has_changed=None, 115 | is_now="Bob", 116 | is_not=NotSet, 117 | was="*", 118 | was_not=NotSet, 119 | changes_to=NotSet, 120 | priority=DEFAULT_PRIORITY, 121 | ) 122 | ], 123 | ), 124 | MagicMock( 125 | __name__="method_that_does_not_fire", 126 | _hooked=[ 127 | HookConfig( 128 | hook="after_create", 129 | when="first_name", 130 | when_any=None, 131 | has_changed=None, 132 | is_now="Bill", 133 | is_not=NotSet, 134 | was="*", 135 | was_not=NotSet, 136 | changes_to=NotSet, 137 | priority=DEFAULT_PRIORITY, 138 | ) 139 | ], 140 | ), 141 | ] 142 | ) 143 | fired_methods = instance._run_hooked_methods("after_create") 144 | self.assertEqual(fired_methods, ["method_that_does_fires"]) 145 | 146 | def test_run_hooked_methods_for_when_any(self): 147 | instance = UserAccount(first_name="Bob") 148 | 149 | instance._potentially_hooked_methods = MagicMock( 150 | return_value=[ 151 | MagicMock( 152 | __name__="method_that_does_fires", 153 | _hooked=[ 154 | HookConfig( 155 | hook="after_create", 156 | when=None, 157 | when_any=["first_name", "last_name", "password"], 158 | has_changed=None, 159 | is_now="Bob", 160 | is_not=NotSet, 161 | was="*", 162 | was_not=NotSet, 163 | changes_to=NotSet, 164 | priority=DEFAULT_PRIORITY, 165 | ) 166 | ], 167 | ), 168 | MagicMock( 169 | __name__="method_that_does_not_fire", 170 | _hooked=[ 171 | HookConfig( 172 | hook="after_create", 173 | when="first_name", 174 | when_any=None, 175 | has_changed=None, 176 | is_now="Bill", 177 | is_not=NotSet, 178 | was="*", 179 | was_not=NotSet, 180 | changes_to=NotSet, 181 | priority=DEFAULT_PRIORITY, 182 | ) 183 | ], 184 | ), 185 | ] 186 | ) 187 | fired_methods = instance._run_hooked_methods("after_create") 188 | self.assertEqual(fired_methods, ["method_that_does_fires"]) 189 | 190 | def test_has_changed(self): 191 | data = self.stub_data 192 | data["username"] = "Joe" 193 | UserAccount.objects.create(**data) 194 | user_account = UserAccount.objects.get() 195 | self.assertFalse(user_account.has_changed("username")) 196 | user_account.username = "Josephine" 197 | self.assertTrue(user_account.has_changed("username")) 198 | 199 | def test_has_changed_when_refreshed_from_db(self): 200 | data = self.stub_data 201 | UserAccount.objects.create(**data) 202 | user_account = UserAccount.objects.get() 203 | UserAccount.objects.update(username="not " + user_account.username) 204 | user_account.refresh_from_db() 205 | user_account.username = data["username"] 206 | self.assertTrue(user_account.has_changed("username"), 207 | 'The initial state should get updated after refreshing the object from db') 208 | 209 | def test_has_changed_is_true_if_fk_related_model_field_has_changed(self): 210 | org = Organization.objects.create(name="Dunder Mifflin") 211 | UserAccount.objects.create(**self.stub_data, organization=org) 212 | user_account = UserAccount.objects.get() 213 | 214 | org.name = "Dwight's Paper Empire" 215 | org.save() 216 | user_account._clear_watched_fk_model_cache() 217 | self.assertTrue(user_account.has_changed("organization.name")) 218 | 219 | def test_has_changed_is_false_if_fk_related_model_field_has_not_changed(self): 220 | org = Organization.objects.create(name="Dunder Mifflin") 221 | UserAccount.objects.create(**self.stub_data, organization=org) 222 | user_account = UserAccount.objects.get() 223 | user_account._clear_watched_fk_model_cache() 224 | self.assertFalse(user_account.has_changed("organization.name")) 225 | 226 | def test_has_changed_specs(self): 227 | specs = HookConfig("before_update", when="first_name", has_changed=True) 228 | 229 | data = self.stub_data 230 | data["first_name"] = "Homer" 231 | UserAccount.objects.create(**data) 232 | user_account = UserAccount.objects.get() 233 | 234 | self.assertFalse(specs.condition(user_account)) 235 | user_account.first_name = "Ned" 236 | self.assertTrue(specs.condition(user_account)) 237 | 238 | def test_check_is_now_condition_wildcard_should_pass(self): 239 | specs = HookConfig("before_update", when="first_name", is_now="*") 240 | data = self.stub_data 241 | data["first_name"] = "Homer" 242 | UserAccount.objects.create(**data) 243 | user_account = UserAccount.objects.get() 244 | user_account.first_name = "Ned" 245 | self.assertTrue(specs.condition(user_account)) 246 | 247 | def test_check_is_now_condition_matching_value_should_pass(self): 248 | specs = HookConfig("before_update", when="first_name", is_now="Ned") 249 | data = self.stub_data 250 | data["first_name"] = "Homer" 251 | UserAccount.objects.create(**data) 252 | user_account = UserAccount.objects.get() 253 | user_account.first_name = "Ned" 254 | self.assertTrue(specs.condition(user_account)) 255 | 256 | def test_check_is_now_condition_not_matched_value_should_not_pass(self): 257 | specs = HookConfig("before_update", when="first_name", is_now="Bart") 258 | data = self.stub_data 259 | data["first_name"] = "Homer" 260 | UserAccount.objects.create(**data) 261 | user_account = UserAccount.objects.get() 262 | self.assertFalse(specs.condition(user_account)) 263 | 264 | def test_check_was_not_condition_should_pass_when_not_set(self): 265 | specs = HookConfig("before_update", when="first_name", was_not=NotSet) 266 | data = self.stub_data 267 | UserAccount.objects.create(**data) 268 | user_account = UserAccount.objects.get() 269 | self.assertTrue(specs.condition(user_account)) 270 | 271 | def test_check_was_not_condition_not_matching_value_should_pass(self): 272 | specs = HookConfig("before_update", when="first_name", was_not="Bart") 273 | 274 | data = self.stub_data 275 | data["first_name"] = "Homer" 276 | UserAccount.objects.create(**data) 277 | user_account = UserAccount.objects.get() 278 | self.assertTrue(specs.condition(user_account)) 279 | 280 | def test_check_was_not_condition_matched_value_should_not_pass(self): 281 | specs = HookConfig("before_update", when="first_name", was_not="Homer") 282 | 283 | data = self.stub_data 284 | data["first_name"] = "Homer" 285 | UserAccount.objects.create(**data) 286 | user_account = UserAccount.objects.get() 287 | self.assertFalse(specs.condition(user_account)) 288 | 289 | def test_check_was_condition_wildcard_should_pass(self): 290 | specs = HookConfig("before_update", when="first_name", was="*") 291 | 292 | data = self.stub_data 293 | data["first_name"] = "Homer" 294 | UserAccount.objects.create(**data) 295 | user_account = UserAccount.objects.get() 296 | self.assertTrue(specs.condition(user_account)) 297 | 298 | def test_check_was_condition_matching_value_should_pass(self): 299 | specs = HookConfig("before_update", when="first_name", was="Homer") 300 | 301 | data = self.stub_data 302 | data["first_name"] = "Homer" 303 | UserAccount.objects.create(**data) 304 | user_account = UserAccount.objects.get() 305 | self.assertTrue(specs.condition(user_account)) 306 | 307 | def test_check_was_condition_not_matched_value_should_not_pass(self): 308 | specs = HookConfig("before_update", when="first_name", was="Bart") 309 | 310 | data = self.stub_data 311 | data["first_name"] = "Homer" 312 | UserAccount.objects.create(**data) 313 | user_account = UserAccount.objects.get() 314 | self.assertFalse(specs.condition(user_account)) 315 | 316 | def test_is_not_condition_should_pass(self): 317 | specs = HookConfig("before_update", when="first_name", is_not="Ned") 318 | 319 | data = self.stub_data 320 | data["first_name"] = "Homer" 321 | UserAccount.objects.create(**data) 322 | user_account = UserAccount.objects.get() 323 | self.assertTrue(specs.condition(user_account)) 324 | 325 | def test_is_not_condition_should_not_pass(self): 326 | specs = HookConfig("before_update", when="first_name", is_not="Ned") 327 | 328 | data = self.stub_data 329 | data["first_name"] = "Ned" 330 | UserAccount.objects.create(**data) 331 | user_account = UserAccount.objects.get() 332 | self.assertFalse(specs.condition(user_account)) 333 | 334 | def test_changes_to_condition_should_pass(self): 335 | data = self.stub_data 336 | UserAccount.objects.create(**data) 337 | user_account = UserAccount.objects.get() 338 | with self.assertRaises(CannotRename, msg="Oh, not Flanders. Anybody but Flanders."): 339 | user_account.last_name = "Flanders" 340 | user_account.save() 341 | 342 | def test_changes_to_condition_included_in_update_fields_should_fire_hook(self): 343 | user_account = UserAccount.objects.create(**self.stub_data) 344 | user_account.first_name = "Flanders" 345 | user_account.last_name = "Flanders" 346 | with self.assertRaises(CannotRename, msg="Oh, not Flanders. Anybody but Flanders."): 347 | user_account.last_name = "Flanders" 348 | user_account.save(update_fields=["last_name"]) 349 | 350 | def test_changes_to_condition_not_included_in_update_fields_should_not_fire_hook( 351 | self, 352 | ): 353 | user_account = UserAccount.objects.create(**self.stub_data) 354 | user_account.first_name = "Flanders" 355 | user_account.last_name = "Flanders" 356 | user_account.save(update_fields=["first_name"]) # `CannotRename` exception is not raised 357 | 358 | user_account.refresh_from_db() 359 | self.assertEqual(user_account.first_name, "Flanders") 360 | self.assertNotEqual(user_account.last_name, "Flanders") 361 | 362 | def test_changes_to_condition_should_not_pass(self): 363 | data = self.stub_data 364 | data["first_name"] = "Marge" 365 | data["last_name"] = "Simpson" 366 | UserAccount.objects.create(**data) 367 | user_account = UserAccount.objects.get() 368 | user_account.last_name = "Bouvier" 369 | user_account.save() # `CannotRename` exception is not raised 370 | 371 | def test_should_not_call_cached_property(self): 372 | """ 373 | full_name is cached_property. Accessing _potentially_hooked_methods 374 | should not call it incidentally. 375 | """ 376 | data = self.stub_data 377 | data["first_name"] = "Bart" 378 | data["last_name"] = "Simpson" 379 | account = UserAccount.objects.create(**data) 380 | account._potentially_hooked_methods 381 | account.first_name = "Bartholomew" 382 | # Should be first time this property is accessed... 383 | self.assertEqual(account.full_name, "Bartholomew Simpson") 384 | 385 | def test_comparison_state_should_reset_after_save(self): 386 | data = self.stub_data 387 | data["first_name"] = "Marge" 388 | data["last_name"] = "Simpson" 389 | account = UserAccount.objects.create(**data) 390 | account.first_name = "Maggie" 391 | self.assertTrue(account.has_changed("first_name")) 392 | with self.captureOnCommitCallbacks(execute=True) as callbacks: 393 | account.save() 394 | self.assertEqual( 395 | len(callbacks), 396 | 1, 397 | msg="Only the _reset_initial_state should be in the on_commit callbacks", 398 | ) 399 | self.assertFalse(account.has_changed("first_name")) 400 | 401 | def test_run_hooked_methods_for_on_commit(self): 402 | instance = UserAccount(first_name="Bob") 403 | 404 | instance._potentially_hooked_methods = MagicMock( 405 | return_value=[ 406 | MagicMock( 407 | __name__="method_that_fires_on_commit", 408 | _hooked=[ 409 | HookConfig( 410 | hook="after_create", 411 | when=None, 412 | when_any=None, 413 | has_changed=None, 414 | is_now="*", 415 | is_not=NotSet, 416 | was="*", 417 | was_not=NotSet, 418 | changes_to=NotSet, 419 | on_commit=True, 420 | priority=DEFAULT_PRIORITY, 421 | ) 422 | ], 423 | ), 424 | MagicMock( 425 | __name__="method_that_fires_in_transaction", 426 | _hooked=[ 427 | HookConfig( 428 | hook="after_create", 429 | when=None, 430 | when_any=None, 431 | has_changed=None, 432 | is_now="*", 433 | is_not=NotSet, 434 | was="*", 435 | was_not=NotSet, 436 | changes_to=NotSet, 437 | on_commit=False, 438 | priority=DEFAULT_PRIORITY, 439 | ) 440 | ], 441 | ), 442 | MagicMock( 443 | __name__="method_that_fires_in_default", 444 | _hooked=[ 445 | HookConfig( 446 | hook="after_create", 447 | when=None, 448 | when_any=None, 449 | has_changed=None, 450 | is_now="*", 451 | is_not=NotSet, 452 | was="*", 453 | was_not=NotSet, 454 | changes_to=NotSet, 455 | on_commit=None, 456 | priority=DEFAULT_PRIORITY, 457 | ) 458 | ], 459 | ), 460 | MagicMock( 461 | __name__="after_save_method_that_fires_on_commit", 462 | _hooked=[ 463 | HookConfig( 464 | hook="after_save", 465 | when=None, 466 | when_any=None, 467 | has_changed=None, 468 | is_now="*", 469 | is_not=NotSet, 470 | was="*", 471 | was_not=NotSet, 472 | changes_to=NotSet, 473 | on_commit=True, 474 | priority=DEFAULT_PRIORITY, 475 | ) 476 | ], 477 | ), 478 | MagicMock( 479 | __name__="after_save_method_that_fires_if_changed_on_commit", 480 | _hooked=[HookConfig(hook="after_save", has_changed=True, on_commit=True)], 481 | ), 482 | ] 483 | ) 484 | 485 | fired_methods = instance._run_hooked_methods("after_create") 486 | self.assertEqual( 487 | fired_methods, 488 | [ 489 | "method_that_fires_on_commit_on_commit", 490 | "method_that_fires_in_transaction", 491 | "method_that_fires_in_default", 492 | ], 493 | ) 494 | 495 | fired_methods = instance._run_hooked_methods("after_save") 496 | self.assertEqual( 497 | fired_methods, 498 | [ 499 | "after_save_method_that_fires_on_commit_on_commit", 500 | "after_save_method_that_fires_if_changed_on_commit_on_commit", 501 | ], 502 | ) 503 | 504 | def test_bypass_hook_for(self): 505 | with bypass_hooks_for((ModelThatFailsIfTriggered,)): 506 | ModelThatFailsIfTriggered.objects.create() 507 | --------------------------------------------------------------------------------