diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a77050..02aceb7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: fail-fast: false max-parallel: 5 matrix: - python-version: ['3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12'] services: postgres: diff --git a/CHANGELOG.md b/CHANGELOG.md index b9dfc09..1a64a05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## 0.0.8 (2024-05-31) +## 0.1.0 (2024-05-31) - `post_create_signal()`, `post_update_signal()`, and `post_delete_signal()` were not waiting for transaction to commit before sending signals. This was causing the signals to be sent before the object was actually created/updated/deleted, Causing a race condition. This has been fixed by using `transaction.on_commit()`. diff --git a/README.md b/README.md index 307345b..4c4d593 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,167 @@ Run tests pip install -r requirements_dev.txt pytest ``` +Here's the provided text translated from reStructuredText (.rst) format to Markdown (.md): + +Usage +===== + +`django-bulk-tracker` will emit a signal whenever you update, create, or delete a record in the database. + +`django-bulk-tracker` supports bulk operations: + +- `queryset.update()` +- `queryset.bulk_update()` +- `queryset.bulk_create()` +- `queryset.delete()` + +and single operations: + +- `create()` +- `save() # update and create` +- `delete()` + +All you need to do is define your Model and inherit from: + +```python +from bulk_tracker.models import BulkTrackerModel + +class Author(BulkTrackerModel): + first_name = models.CharField(max_length=200) + last_name = models.CharField(max_length=200) +``` + +OR if you have a custom queryset inherit from or Don't want to support single-operation: + +```python +from bulk_tracker.managers import BulkTrackerQuerySet + +class MyModelQuerySet(BulkTrackerQuerySet): + def do_something_custom(self): + pass +``` + +Now you can listen to the signals `post_update_signal`, `post_create_signal`, `post_delete_signal`: + +```python +@receiver(post_update_signal, sender=MyModel) +def i_am_a_receiver_function( + sender, + objects: list[ModifiedObject[MyModel]], + tracking_info_: TrackingInfo | None = None, + **kwargs, +): + do_stuff() +``` + +**Hint:** All signals have the same signature for consistency and also in case you want to assign one function to listen to multiple signals. + +`ModifiedObject` is a very simple object, it contains 2 attributes: +1. `instance` this is your model instance after it has been updated, or created +2. `changed_values` is a dict[str, Any] which contains the changed fields only in the case of `post_update_signal`, in the case of `post_create_signal` and `post_delete_signal`, `changed_values` will be an empty dict `{}`. + +**Optionally** you can pass `tracking_info_` to your functions, as in: + +```python +from bulk_tracker.helper_objects import TrackingInfo + +def a_function_that_updates_records(): + user = self.request.user + MyModel.objects.filter(name='john').update( + name='jack', + tracking_info_=TrackingInfo(user=user, comment="Updated from a function", kwargs={'app-build':'1.1.8'}, is_robust=True), + ) +``` + +**Hint:** `tracking_info_` has a trailing underscore to avoid collision with your actual fields. You can use `TrackingInfo` to implement any kind of behavior like logging in your signal handlers and you need to capture more info about the operation that is happening. + +For single operations as well +----------------------------- + +- `create()` +- `save() # update and create` +- `delete()` + +To support, we rely on the amazing library `django-model-utils` to track the model instances: + +1. Do the above +2. You need to inherit your model from `BulkTrackerModel` +3. Add `tracker = FieldTracker()` to your model + +As in: + +```python +from bulk_tracker.models import BulkTrackerModel +from model_utils import FieldTracker + +class MyModel(BulkTrackerModel): + objects = MyModelQuerySet.as_manager() # MyModelManager() if you have + tracker = FieldTracker() +``` + +Robust Send +---------- + +`robust_send` if you have multiple receivers for the same signal, and you want to make sure that all of them are executed, even if one of them raises an exception. You can add `TrackingInfo(is_robust=True)` in your operation. You can read more about `robust_send` in the [official documentation](https://docs.djangoproject.com/en/5.0/topics/signals/#sending-signals). + +As in: + +```python +MyModel.objects.filter(name='john').update( + name='jack', + tracking_info_=TrackingInfo(is_robust=True), +) +``` + +Complete Example +================ + +```python +# models.py +from bulk_tracker.models import BulkTrackerModel +from model_utils import FieldTracker + +from myapp.managers import MyModelManager + +class MyModel(BulkTrackerModel): + first_field = models.CharField() + second_field = models.CharField() + + objects = MyModelManager() + tracker = FieldTracker() +``` + +```python +# managers.py +from bulk_tracker.managers import BulkTrackerQuerySet # optional + +class MyModelQuerySet(BulkTrackerQuerySet): + pass + +class MyModelManager(BulkTrackerManager.from_queryset(MyModelQuerySet)): # optional + pass +``` + +```python +# signal_handlers.py +from bulk_tracker.signals import post_update_signal +from bulk_tracker.helper_objects import ModifiedObject, TrackingInfo + +@receiver(post_update_signal, sender=MyModel) +def i_am_a_receiver_function( + sender, + objects: list[ModifiedObject[MyModel]], + tracking_info_: TrackingInfo | None = None, + **kwargs, +): + user = tracking_info_.user if tracking_info_ else None + for modified_object in modified_objects: + if 'name' in modified_object.changed_values: + log(f"field 'name' has changed by {user or ''}") + notify_user() +``` + + Contribute ========== diff --git a/bulk_tracker/__init__.py b/bulk_tracker/__init__.py index a73339b..3dc1f76 100644 --- a/bulk_tracker/__init__.py +++ b/bulk_tracker/__init__.py @@ -1 +1 @@ -__version__ = "0.0.8" +__version__ = "0.1.0" diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 14addd9..84a1484 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -14,7 +14,7 @@ The repository can be found at https://github.com/hassaanalansary/django-bulk-tr - Python 3.9 or higher - Django 3.2 or higher -``django-bulk-tracker`` is currently tested with Python 3.9+ and Django 3.2 and 4.0+. +``django-bulk-tracker`` is currently tested with Python 3.9+ and Django 3.2, 4.0+ and 5.0+. Adding django-bulk-tracker to your Django application ----------------------------------------------------- diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 442ed3e..6fd8edf 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -63,7 +63,7 @@ in case of ``post_create_signal`` and ``post_delete_signal``, ``changed_values`` user = self.request.user MyModel.objects.filter(name='john').update( name='jack', - tracking_info_=TrackingInfo(user=user, comment="Updated from a function", kwargs={'app-build':1.1.8}), + tracking_info_=TrackingInfo(user=user, comment="Updated from a function", kwargs={'app-build':'1.1.8'}, is_robust=True), ) .. hint:: @@ -99,7 +99,19 @@ as in :: tracker = FieldTracker() +robust_send +---------- +``robust_send`` if you have multiple receivers for the same signal, and you want to make sure that all of them are executed, even if one of them raise an exception. +you can add ``TrackingInfo(is_robust=True)`` in your operation. +you can read more about robust_send in the [official documentation](https://docs.djangoproject.com/en/5.0/topics/signals/#sending-signals) + +as in:: + + MyModel.objects.filter(name='john').update( + name='jack', + tracking_info_=TrackingInfo(is_robust=True), + ) Complete Example ================ diff --git a/tests/test_create_signal.py b/tests/test_create_signal.py index 5c3cf33..a12fc10 100644 --- a/tests/test_create_signal.py +++ b/tests/test_create_signal.py @@ -116,7 +116,6 @@ def post_create_receiver( @patch("bulk_tracker.signals.post_create_signal.send") def test_model_save_should_only_emit_post_create_signal_once(self, mocked_signal): # Arrange - signal_called_with = {} def post_create_receiver( sender, @@ -124,8 +123,7 @@ def post_create_receiver( tracking_info_: TrackingInfo | None = None, **kwargs, ): - signal_called_with["objects"] = objects - signal_called_with["tracking_info_"] = tracking_info_ + pass post_create_signal.connect(post_create_receiver, sender=Post) @@ -133,7 +131,7 @@ def post_create_receiver( Post(title="Sound of Winter", publish_date="1998-01-08", author=self.author_john).save() # Assert - self.assertEqual(mocked_signal.call_count, 1, msg="The signal wasn't called once") + mocked_signal.assert_called_once() @patch("bulk_tracker.signals.post_create_signal.send") def test_model_create_should_only_emit_post_create_signal_once(self, mocked_signal): @@ -155,7 +153,7 @@ def post_create_receiver( Post.objects.create(title="Sound of Winter", publish_date="1998-03-08", author=self.author_john) # Assert - self.assertEqual(mocked_signal.call_count, 1, msg="The signal wasn't called once") + mocked_signal.assert_called_once() @patch("bulk_tracker.signals.post_create_signal.send") def test_model_bulk_create_should_only_emit_post_create_signal_once(self, mocked_signal): @@ -182,7 +180,7 @@ def post_create_receiver( Post.objects.bulk_create(posts) # Assert - self.assertEqual(mocked_signal.call_count, 1, msg="The signal wasn't called once") + mocked_signal.assert_called_once() @patch("bulk_tracker.signals.post_create_signal.send") def test_create_signal_should_be_only_emitted_after_transaction_commit(self, mocked_signal): diff --git a/tox.ini b/tox.ini index 8f5c0e2..31aaf0e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = py{39,310,311}-dj{32,40,41} - py{310,311}-dj{42,main} + py{310,311,312}-dj{42,50,main} isort black toxworkdir = /tmp/tox/ @@ -11,6 +11,7 @@ python = 3.9: py39, flake8, isort 3.10: py310 3.11: py311 + 3.12: py312 [testenv] deps = @@ -19,6 +20,8 @@ deps = dj32: Django==3.2.* dj40: Django==4.0.* dj41: Django==4.1.* + dj42: Django==4.2.* + dj50: Django==5.0.* djmain: https://github.com/django/django/archive/main.tar.gz passenv =