Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions django_lifecycle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -28,4 +29,5 @@
"BEFORE_DELETE",
"AFTER_DELETE",
"NotSet",
"bypass_hooks_for",
]
21 changes: 20 additions & 1 deletion django_lifecycle/mixins.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -38,6 +40,8 @@
ReverseOneToOneDescriptor,
)

_MARKER = "_bypass_hooks"


class HookedMethod(AbstractHookedMethod):
@property
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Comment on lines +311 to +319
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't tried it yet, but I'm not sure if this would work in every situation. What happens if you set that marker to a model, but what happens if that model is imported in another file? Would that work?

I mean something like this:

# utils.py
from .models import MyModel


def update_model_timestamp(pk: int):
    instance = MyModel.objects.get(pk=pk)
    instance.timestamp = now()
    instance.save()
# models.py
from utils import update_model_timestamp

def foo():
    with bypass_hooks_for((MyModel,)):
        update_model_timestamp(pk=1)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, it's working also that way. I can testimony it's working in our project, running with cpython.
I'm fairly confident we can treat modules and their classes as singletons, the default import system is storing the modules in a global dict.
https://docs.python.org/3/faq/programming.html#how-do-i-share-global-variables-across-modules

17 changes: 17 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
24 changes: 24 additions & 0 deletions tests/testapp/migrations/0007_modelthatfailsiftriggered.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
6 changes: 6 additions & 0 deletions tests/testapp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions tests/testapp/tests/test_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions tests/testapp/tests/test_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()