From fa96d4989395a893eec042c450c3db48a0618cee Mon Sep 17 00:00:00 2001 From: Sumit Singh Date: Mon, 20 Nov 2023 16:04:40 +0530 Subject: [PATCH 1/4] feat: allow end-users to override tracker class --- tests/models.py | 30 +++++++++++++++++++++++++++++- tests/test_tracking_model.py | 28 +++++++++++++++++++++++++++- tracking_model/__init__.py | 2 +- tracking_model/mixins.py | 15 ++++++++++++++- 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/tests/models.py b/tests/models.py index f15c1d8..daa25b0 100644 --- a/tests/models.py +++ b/tests/models.py @@ -3,7 +3,7 @@ from django.contrib.postgres.fields import ArrayField from django.db import models -from tracking_model import TrackingModelMixin +from tracking_model import TrackingModelMixin, Tracker class ModelB(TrackingModelMixin, models.Model): @@ -36,3 +36,31 @@ class NarrowTrackedModel(TrackingModelMixin, models.Model): TRACKED_FIELDS = ["first"] first = models.TextField(null=True) second = models.TextField(null=True) + + +class CustomTracker(Tracker): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def has_changed(self, field): + if field not in self.tracked_fields: + raise ValueError("%s is not tracked" % field) + return field in self.changed + + +class WithCustomTrackerModel(TrackingModelMixin, models.Model): + tracker_class = CustomTracker + TRACKED_FIELDS = ["first"] + first = models.TextField(null=True) + second = models.TextField(null=True) + + +class InvalidTracker: + pass + + +class WithInvalidTrackerModel(TrackingModelMixin, models.Model): + tracker_class = InvalidTracker + TRACKED_FIELDS = ["first"] + first = models.TextField(null=True) + second = models.TextField(null=True) diff --git a/tests/test_tracking_model.py b/tests/test_tracking_model.py index 668bfb7..9d8f169 100644 --- a/tests/test_tracking_model.py +++ b/tests/test_tracking_model.py @@ -3,7 +3,8 @@ from django.db.models.query_utils import DeferredAttribute from django.test import TestCase -from .models import ModelA, ModelB, SignalModel, MutableModel, NarrowTrackedModel +from .models import ModelA, ModelB, SignalModel, MutableModel, NarrowTrackedModel, WithCustomTrackerModel, \ + WithInvalidTrackerModel from .signals import * @@ -211,3 +212,28 @@ def test_only_track_first(self): self.obj.first = "Ciao ciao" self.obj.second = "Italiano" self.assertDictEqual(self.obj.tracker.changed, {"first": "Ciao"}) + + +class OverrideTrackerTests(TestCase): + + def test_tracking_mixin_raises_error_if_tracker_class_is_invalid(self): + with self.assertRaises(TypeError) as e: + WithInvalidTrackerModel(first="Joh", second="Doe") + + self.assertEqual( + str(e.exception), + "tracker_class must be subclass of Tracker.", + ) + + def test_instance_can_use_new_methods_of_tracker_class(self): + instance = WithCustomTrackerModel(first="John", second="Doe") + instance.first = "Mary" + instance.second = "Jane" + self.assertEqual(instance.tracker.has_changed("first"), True) + + with self.assertRaises(ValueError) as e: + instance.tracker.has_changed("second") + self.assertEqual( + str(e.exception), + "second is not tracked", + ) \ No newline at end of file diff --git a/tracking_model/__init__.py b/tracking_model/__init__.py index 0c1b1f0..fb5f8f6 100644 --- a/tracking_model/__init__.py +++ b/tracking_model/__init__.py @@ -1 +1 @@ -from .mixins import TrackingModelMixin +from .mixins import TrackingModelMixin, Tracker diff --git a/tracking_model/mixins.py b/tracking_model/mixins.py index 02daaae..3b514ed 100644 --- a/tracking_model/mixins.py +++ b/tracking_model/mixins.py @@ -12,11 +12,24 @@ def __init__(self, instance): class TrackingModelMixin(object): TRACKED_FIELDS = None + tracker_class = Tracker def __init__(self, *args, **kwargs): + self._validate_tracker_class() super(TrackingModelMixin, self).__init__(*args, **kwargs) self._initialized = True + def _validate_tracker_class(self): + if not self.tracker_class: + raise AttributeError( + "Please set tracker_class attribute." + ) + + if not issubclass(self.tracker_class, Tracker): + raise TypeError( + "tracker_class must be subclass of Tracker." + ) + @property def tracker(self): if hasattr(self._state, "_tracker"): @@ -27,7 +40,7 @@ def tracker(self): if not self.TRACKED_FIELDS: instance_class = type(self) instance_class.TRACKED_FIELDS = {f.attname for f in instance_class._meta.concrete_fields} - tracker = self._state._tracker = Tracker(self) + tracker = self._state._tracker = self.tracker_class(self) return tracker def save( From f6273c5aae51fe490c57828dd0a7cad548ab18b7 Mon Sep 17 00:00:00 2001 From: Sumit Singh Date: Mon, 20 Nov 2023 16:06:23 +0530 Subject: [PATCH 2/4] chore: simplify if conditions --- tracking_model/mixins.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tracking_model/mixins.py b/tracking_model/mixins.py index 3b514ed..296b3f7 100644 --- a/tracking_model/mixins.py +++ b/tracking_model/mixins.py @@ -58,17 +58,16 @@ def save( self.tracker.changed = {} def __setattr__(self, name, value): - if hasattr(self, "_initialized"): - if name in self.tracker.tracked_fields: - if name not in self.tracker.changed: - if name in self.__dict__: - old_value = getattr(self, name) - if value != old_value: - self.tracker.changed[name] = old_value - else: - self.tracker.changed[name] = DeferredAttribute - else: - if value == self.tracker.changed[name]: - self.tracker.changed.pop(name) + if hasattr(self, "_initialized") and name in self.tracker.tracked_fields: + if name in self.tracker.changed: + if value == self.tracker.changed[name]: + self.tracker.changed.pop(name) + + elif name in self.__dict__: + old_value = getattr(self, name) + if value != old_value: + self.tracker.changed[name] = old_value + else: + self.tracker.changed[name] = DeferredAttribute super(TrackingModelMixin, self).__setattr__(name, value) From dd70bb95cadb2a371ac68f235250010480402a5f Mon Sep 17 00:00:00 2001 From: Sumit Singh Date: Mon, 20 Nov 2023 16:10:39 +0530 Subject: [PATCH 3/4] chore: run black --- tests/test_tracking_model.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_tracking_model.py b/tests/test_tracking_model.py index 9d8f169..8ea8a12 100644 --- a/tests/test_tracking_model.py +++ b/tests/test_tracking_model.py @@ -3,8 +3,15 @@ from django.db.models.query_utils import DeferredAttribute from django.test import TestCase -from .models import ModelA, ModelB, SignalModel, MutableModel, NarrowTrackedModel, WithCustomTrackerModel, \ - WithInvalidTrackerModel +from .models import ( + ModelA, + ModelB, + SignalModel, + MutableModel, + NarrowTrackedModel, + WithCustomTrackerModel, + WithInvalidTrackerModel, +) from .signals import * @@ -215,7 +222,6 @@ def test_only_track_first(self): class OverrideTrackerTests(TestCase): - def test_tracking_mixin_raises_error_if_tracker_class_is_invalid(self): with self.assertRaises(TypeError) as e: WithInvalidTrackerModel(first="Joh", second="Doe") @@ -236,4 +242,4 @@ def test_instance_can_use_new_methods_of_tracker_class(self): self.assertEqual( str(e.exception), "second is not tracked", - ) \ No newline at end of file + ) From 5a57cf260419aaf2b18fee4a2a287d8cdfd4255b Mon Sep 17 00:00:00 2001 From: drozdowsky Date: Sat, 27 Jan 2024 18:51:51 +0100 Subject: [PATCH 4/4] Improvements --- README.md | 12 ++++++++++++ tests/models.py | 7 ++----- tests/test_tracking_model.py | 4 ++-- tracking_model/mixins.py | 25 +++++++++---------------- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 45f6031..c6deb25 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Out[1]: ["I", "am", "your", "father"] ``` DTM handles deferred fields well. ```python +# from django.db.models.query_utils import DeferredAttribute In [1]: e = Example.objects.only("array").first() In [2]: e.text = "I am not your father" In [3]: e.tracker.changed @@ -84,6 +85,17 @@ class Example(models.Model): first = models.TextField() second = models.TextField() ``` +You can also implement your own Tracker class: +```python +from tracking_model import Tracker + +class SuperTracker(Tracker): + def has_changed(self, field): + return field in self.changed + +class Example(models.Model): + TRACKER_CLASS = SuperTracker +``` ## Requirements * Python >= 2.7, <= 3.11 diff --git a/tests/models.py b/tests/models.py index daa25b0..ec4dcee 100644 --- a/tests/models.py +++ b/tests/models.py @@ -39,9 +39,6 @@ class NarrowTrackedModel(TrackingModelMixin, models.Model): class CustomTracker(Tracker): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - def has_changed(self, field): if field not in self.tracked_fields: raise ValueError("%s is not tracked" % field) @@ -49,7 +46,7 @@ def has_changed(self, field): class WithCustomTrackerModel(TrackingModelMixin, models.Model): - tracker_class = CustomTracker + TRACKER_CLASS = CustomTracker TRACKED_FIELDS = ["first"] first = models.TextField(null=True) second = models.TextField(null=True) @@ -60,7 +57,7 @@ class InvalidTracker: class WithInvalidTrackerModel(TrackingModelMixin, models.Model): - tracker_class = InvalidTracker + TRACKER_CLASS = InvalidTracker TRACKED_FIELDS = ["first"] first = models.TextField(null=True) second = models.TextField(null=True) diff --git a/tests/test_tracking_model.py b/tests/test_tracking_model.py index 8ea8a12..04830af 100644 --- a/tests/test_tracking_model.py +++ b/tests/test_tracking_model.py @@ -224,11 +224,11 @@ def test_only_track_first(self): class OverrideTrackerTests(TestCase): def test_tracking_mixin_raises_error_if_tracker_class_is_invalid(self): with self.assertRaises(TypeError) as e: - WithInvalidTrackerModel(first="Joh", second="Doe") + WithInvalidTrackerModel(first="Joh", second="Doe").tracker self.assertEqual( str(e.exception), - "tracker_class must be subclass of Tracker.", + "TRACKER_CLASS must be a subclass of Tracker.", ) def test_instance_can_use_new_methods_of_tracker_class(self): diff --git a/tracking_model/mixins.py b/tracking_model/mixins.py index 296b3f7..5963ff0 100644 --- a/tracking_model/mixins.py +++ b/tracking_model/mixins.py @@ -10,37 +10,30 @@ def __init__(self, instance): class TrackingModelMixin(object): - TRACKED_FIELDS = None - tracker_class = Tracker + TRACKER_CLASS = Tracker def __init__(self, *args, **kwargs): - self._validate_tracker_class() super(TrackingModelMixin, self).__init__(*args, **kwargs) self._initialized = True - def _validate_tracker_class(self): - if not self.tracker_class: - raise AttributeError( - "Please set tracker_class attribute." - ) - - if not issubclass(self.tracker_class, Tracker): - raise TypeError( - "tracker_class must be subclass of Tracker." - ) - @property def tracker(self): if hasattr(self._state, "_tracker"): tracker = self._state._tracker else: + # validate possibility of changing tracker class + if not issubclass(self.TRACKER_CLASS, Tracker): + raise TypeError("TRACKER_CLASS must be a subclass of Tracker.") + # populate tracked fields for the first time # by default all fields if not self.TRACKED_FIELDS: instance_class = type(self) - instance_class.TRACKED_FIELDS = {f.attname for f in instance_class._meta.concrete_fields} - tracker = self._state._tracker = self.tracker_class(self) + instance_class.TRACKED_FIELDS = { + f.attname for f in instance_class._meta.concrete_fields + } + tracker = self._state._tracker = self.TRACKER_CLASS(self) return tracker def save(