diff --git a/django_lifecycle/__init__.py b/django_lifecycle/__init__.py index e9fee2c..d3e0a23 100644 --- a/django_lifecycle/__init__.py +++ b/django_lifecycle/__init__.py @@ -13,6 +13,7 @@ from .hooks import BEFORE_SAVE from .hooks import BEFORE_UPDATE from .mixins import LifecycleModelMixin +from .mixins import bypass_hooks_for from .models import LifecycleModel __all__ = [ @@ -28,4 +29,5 @@ "BEFORE_DELETE", "AFTER_DELETE", "NotSet", + "bypass_hooks_for", ] diff --git a/django_lifecycle/mixins.py b/django_lifecycle/mixins.py index 3b5b6b6..b944512 100644 --- a/django_lifecycle/mixins.py +++ b/django_lifecycle/mixins.py @@ -1,8 +1,10 @@ from __future__ import annotations +from contextlib import contextmanager from functools import partial, lru_cache from inspect import isfunction from typing import Any, List from typing import Iterable +from typing import TypeVar from django.db import transaction from django.db.models.fields.related_descriptors import ( @@ -38,6 +40,8 @@ ReverseOneToOneDescriptor, ) +_MARKER = "_bypass_hooks" + class HookedMethod(AbstractHookedMethod): @property @@ -123,7 +127,8 @@ def save(self, *args, **kwargs): skip_hooks = kwargs.pop("skip_hooks", False) save = super().save - if skip_hooks: + skip_hooks_from_cm = hasattr(self.__class__, _MARKER) + if skip_hooks or skip_hooks_from_cm: save(*args, **kwargs) return @@ -298,3 +303,17 @@ def _get_unhookable_attribute_names(cls) -> List[str]: + cls._get_model_property_names() + ["_run_hooked_methods"] ) + + +T = TypeVar("T", bound=LifecycleModelMixin) + + +@contextmanager +def bypass_hooks_for(models: Iterable[T]): + try: + for model in models: + setattr(model, _MARKER, True) + yield + finally: + for model in models: + delattr(model, _MARKER) diff --git a/docs/advanced.md b/docs/advanced.md index 00416d8..ee3c305 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -81,3 +81,20 @@ To prevent the hooked methods from being called, pass `skip_hooks=True` when cal ```python account.save(skip_hooks=True) ``` + +Or, you can rely on the `bypass_hooks_for` context manager: + +```python +from django_lifecycle import bypass_hooks_for + + +class MyModel(LifecycleModel): + @hook(AFTER_CREATE) + def trigger(self): + pass + +with bypass_hooks_for((MyModel,)): + model = MyModel() + model.save() # will not invoke model.trigger() method + +``` diff --git a/tests/testapp/migrations/0007_modelthatfailsiftriggered.py b/tests/testapp/migrations/0007_modelthatfailsiftriggered.py new file mode 100644 index 0000000..4bff5a9 --- /dev/null +++ b/tests/testapp/migrations/0007_modelthatfailsiftriggered.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1 on 2025-03-13 17:34 + +import django_lifecycle.mixins +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('testapp', '0006_useraccount_configurations'), + ] + + operations = [ + migrations.CreateModel( + name='ModelThatFailsIfTriggered', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'abstract': False, + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + ] diff --git a/tests/testapp/models.py b/tests/testapp/models.py index c99a6f0..5a05b86 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -177,3 +177,9 @@ class ModelWithGenericForeignKey(LifecycleModel): @hook(AFTER_SAVE, when="content_object", has_changed=True, on_commit=True) def do_something(self): print("Hey there! I am using django-lifecycle") + + +class ModelThatFailsIfTriggered(LifecycleModel): + @hook("after_create") + def one_hook(self): + raise RuntimeError diff --git a/tests/testapp/tests/test_decorator.py b/tests/testapp/tests/test_decorator.py index ae5a6af..e69b3da 100644 --- a/tests/testapp/tests/test_decorator.py +++ b/tests/testapp/tests/test_decorator.py @@ -5,6 +5,7 @@ from django_lifecycle.conditions import Always from django_lifecycle.decorators import DjangoLifeCycleException from django_lifecycle.priority import HIGHEST_PRIORITY, LOWER_PRIORITY +from tests.testapp.models import ModelThatFailsIfTriggered class DecoratorTests(TestCase): diff --git a/tests/testapp/tests/test_mixin.py b/tests/testapp/tests/test_mixin.py index a9058ee..93f7813 100644 --- a/tests/testapp/tests/test_mixin.py +++ b/tests/testapp/tests/test_mixin.py @@ -3,10 +3,12 @@ import django from django.test import TestCase +from django_lifecycle import bypass_hooks_for from django_lifecycle.constants import NotSet from django_lifecycle.decorators import HookConfig from django_lifecycle.priority import DEFAULT_PRIORITY from tests.testapp.models import CannotRename +from tests.testapp.models import ModelThatFailsIfTriggered from tests.testapp.models import Organization from tests.testapp.models import UserAccount @@ -498,3 +500,7 @@ def test_run_hooked_methods_for_on_commit(self): "after_save_method_that_fires_if_changed_on_commit_on_commit", ], ) + + def test_bypass_hook_for(self): + with bypass_hooks_for((ModelThatFailsIfTriggered,)): + ModelThatFailsIfTriggered.objects.create()