From aeceb318bc5159e58d5276bd360a5317c1e5e70d Mon Sep 17 00:00:00 2001 From: Oskar Hollmann Date: Fri, 6 Jan 2017 20:14:33 +0100 Subject: [PATCH] Added model dispatchers and SmartQuerySet --- README.md | 83 ++++++++++++++++++- chamber/models/__init__.py | 15 ++++ chamber/models/dispatchers.py | 75 +++++++++++++++++ example/dj/apps/test_chamber/handlers.py | 19 +++++ example/dj/apps/test_chamber/models.py | 36 +++++++- .../test_chamber/tests/models/__init__.py | 22 +++-- .../test_chamber/tests/models/dispatchers.py | 36 ++++++++ 7 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 chamber/models/dispatchers.py create mode 100644 example/dj/apps/test_chamber/handlers.py create mode 100644 example/dj/apps/test_chamber/tests/models/dispatchers.py diff --git a/README.md b/README.md index 90c4b49..0f10ff6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ shortcuts, advanced datastructure, decoraters etc.). For more details see exampl ## Reference -### Forms +### 1. Forms #### `chamber.forms.fields.DecimalField` @@ -21,7 +21,7 @@ shortcuts, advanced datastructure, decoraters etc.). For more details see exampl Widget for safe rendering of readonly form values. -### Models +### 2. Models #### `chamber.models.fields.SouthMixin` @@ -50,6 +50,85 @@ Maximum upload size can be specified in project settings under `MAX_FILE_UPLOAD_ `django.db.models.CharField` that stores `NULL` but returns ''. +### 3. SmartQuerySet `chamber.models.SmartQuerySet` +SmartModel introduced to Chamber uses by default a modified QuerySet with some convenience filters. + +If you are overriding model manager of a SmartModel, you should incorporate `SmartQuerySet` in order not to lose its benefits and to follow the Rule of the Least Surprise (everyone using your SmartModel will assume the custom filters to be there). + +1. If the manager is created using the `QuerySet.as_manager()` method, your custom queryset should subclass `SmartQuerySet` instead the one from Django. +2. If you have a new manager created by subclassing `models.Manager` from Django, you should override the `get_queryset` method as shown in Django docs [here](https://docs.djangoproject.com/en/1.10/topics/db/managers/#calling-custom-queryset-methods-from-the-manager). + +List of the added filters follows. + +#### 3.1 `fast_distinct()` +Returns same result as regular `distinct()` but is much faster especially in PostgreSQL which performs distinct on all DB columns. The optimization is achieved by doing a second query and the `__in` operator. If you have queryset `qs` of `MyModel` then `fast_distinct()` equals to calling +```python +MyModel.objects.filter(pk__in=qs.values_list('pk', flat=True)) +``` + + +### 4. Model Dispatchers + +Model dispatchers are a way to reduce complexity of `_pre_save` and `_post_save` methods of the SmartModel. A common use-case of these methods is to perform some action based on the change of the model, e.g. send a notification e-mail when the state of the invoice changes. + +Better alternative is to define a handler function encapsulating the action that should happen when the model changes in a certain way. This handler is registered on the model using a proper dispatcher. + +So far, there are two types of dispatchers but you are free to subclass the `BaseDispatcher` class to create your own, see the code as reference. During save of the model, the `__call__` method of all dispatchers is invoked with following parameters: + +1. `obj` ... instance of the model that is being saved +2. `changed_fields` ... list of field names that was changed since the last save +3. `*args` ... custom arguments passed to the save method (can be used to pass additional arguments to your custom dispatchers) +4. `**kwargs` ... custom keyword arguments passed to the save method + +The moment when the handler should be fired may be important. Therefore, you can register the dispatcher either in the `pre_save_dispatchers` group or `post_save_dispatchers` group. Both groups are dispatched immediately after the `_pre_save` or `_post_save` method respectively. + +When the handler is fired, it is passed a single argument -- the instance of the SmartModel that is being saved. Here is an example of a handler that is registered on a `User` model: +``` +def send_email(user): + # Code that actually sends the e-mail + send_html_email(recipient=user.email, subject='Your profile was updated') +``` + +#### 4.1 Property Dispatcher +`chamber.models.dispatchers.StateDispatcher` is a versatile dispatcher that fires the given handler when a specified property of the model evaluates to `True`. + +The example shows how to to register the aforementioned `send_email` handler to be dispatched after saving the object if the property `should_send_email` returns `True`. +```python +class MySmartModel(chamber_models.SmartModel): + + email_sent = models.BooleanField() + + post_save_dispatchers = ( + PropertyDispatcher(send_email, 'should_send_email'), + ) + + @property + def should_send_email(self): + return not self.email_sent +``` + +#### 4.2 State Dispatcher +In the following example, where we register `my_handler` function to be dispatched during `_pre_save` method when the state changes to `SECOND`. This is done using `chamber.models.dispatchers.StateDispatcher`. + +```python +def my_handler(my_smart_model): + # Do that useful stuff + pass + + +class MySmartModel(chamber_models.SmartModel): + + STATE = ChoicesNumEnum( + ('FIRST', _('first'), 1), + ('SECOND', _('second'), 2), + ) + state = models.IntegerField(choices=STATE.choices, default=STATE.FIRST) + + pre_save_dispatchers = ( + StateDispatcher(my_handler, STATE, state, STATE.SECOND), + ) +``` + ### Utils #### `chamber.utils.remove_accent` diff --git a/chamber/models/__init__.py b/chamber/models/__init__.py index ad23f1b..a9607e6 100644 --- a/chamber/models/__init__.py +++ b/chamber/models/__init__.py @@ -155,8 +155,16 @@ def send(self): [fun(self.obj) for fun in self.connected_functions] +class SmartQuerySet(models.QuerySet): + + def fast_distinct(self): + return self.model.objects.filter(pk__in=self.values_list('pk', flat=True)) + + class SmartModel(AuditModel): + objects = SmartQuerySet.as_manager() + def __init__(self, *args, **kwargs): super(SmartModel, self).__init__(*args, **kwargs) self.changed_fields = ChangedFields(self) @@ -217,6 +225,11 @@ def _get_save_extra_kwargs(self): def _pre_save(self, *args, **kwargs): pass + def _call_dispatcher_group(self, group_name, changed_fields, *args, **kwargs): + if hasattr(self, group_name): + for dispatcher in getattr(self, group_name): + dispatcher(self, changed_fields, *args, **kwargs) + def _save(self, is_cleaned_pre_save=None, is_cleaned_post_save=None, force_insert=False, force_update=False, using=None, update_fields=None, *args, **kwargs): is_cleaned_pre_save = ( @@ -230,6 +243,7 @@ def _save(self, is_cleaned_pre_save=None, is_cleaned_post_save=None, force_inser kwargs.update(self._get_save_extra_kwargs()) self._pre_save(change, self.changed_fields, *args, **kwargs) + self._call_dispatcher_group('pre_save_dispatchers', self.changed_fields, *args, **kwargs) if is_cleaned_pre_save: self._clean_pre_save() @@ -237,6 +251,7 @@ def _save(self, is_cleaned_pre_save=None, is_cleaned_post_save=None, force_inser super(SmartModel, self).save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) self._post_save(change, self.changed_fields, *args, **kwargs) + self._call_dispatcher_group('post_save_dispatchers', self.changed_fields, *args, **kwargs) if is_cleaned_post_save: self._clean_post_save() diff --git a/chamber/models/dispatchers.py b/chamber/models/dispatchers.py new file mode 100644 index 0000000..3aac925 --- /dev/null +++ b/chamber/models/dispatchers.py @@ -0,0 +1,75 @@ +from __future__ import unicode_literals + +import inspect + +from django.core.exceptions import ImproperlyConfigured + + +class BaseDispatcher(object): + """ + Base dispatcher class that can be subclassed to call a handler based on a change in some a SmartModel. + If you subclass, be sure the __call__ method does not change signature. + """ + def _validate_init_params(self): + raise NotImplementedError + + def __init__(self, handler, *args, **kwargs): + self.handler = handler + self._validate_init_params() + + def __call__(self, obj, *args, **kwargs): + """ + `obj` ... instance of the SmartModel where the handler is being called + Some dispatchers require additional params to evaluate the handler can be dispatched, + these are hidden in args and kwargs. + """ + if self._can_dispatch(obj, *args, **kwargs): + self.handler(obj) + + def _can_dispatch(self, obj, *args, **kwargs): + raise NotImplementedError + + +class PropertyDispatcher(BaseDispatcher): + """ + Use this class to register a handler to dispatch during save if the given property evaluates to True. + """ + + def _validate_init_params(self): + """ + No validation is done as it would require to pass the whole model to the dispatcher. + If the property is not defined, a clear error is shown at runtime. + """ + pass + + def __init__(self, handler, property_name): + self.property_name = property_name + super(PropertyDispatcher, self).__init__(handler, property_name) + + def _can_dispatch(self, obj, *args, **kwargs): + return getattr(obj, self.property_name) + + +class StateDispatcher(BaseDispatcher): + + """ + Use this class to register a handler for transition of a model to a certain state. + """ + def _validate_init_params(self): + if self.field_value not in {value for value, _ in self.enum.choices}: + raise ImproperlyConfigured('Enum of FieldDispatcher does not contain {}.'.format(self.field_value)) + if not hasattr(self.handler, '__call__'): + raise ImproperlyConfigured('Handler of FieldDispatcher must be callable.') + if (len(inspect.getargspec(self.handler).args) != 1 or # pylint: disable=W1505 + inspect.getargspec(self.handler).keywords): # pylint: disable=W1505 + raise ImproperlyConfigured('Handler of FieldDispatcher must be a function of one parameter.') + + def __init__(self, handler, enum, field, field_value): + self.enum = enum + self.field = field + self.field_value = field_value + + super(StateDispatcher, self).__init__(handler, enum, field, field_value) + + def _can_dispatch(self, obj, changed_fields, *args, **kwargs): + return self.field.get_attname() in changed_fields and getattr(obj, self.field.get_attname()) == self.field_value diff --git a/example/dj/apps/test_chamber/handlers.py b/example/dj/apps/test_chamber/handlers.py new file mode 100644 index 0000000..234de52 --- /dev/null +++ b/example/dj/apps/test_chamber/handlers.py @@ -0,0 +1,19 @@ +from __future__ import unicode_literals + + +def create_test_smart_model_handler(obj): + from .models import TestSmartModel + + TestSmartModel.objects.create(name='name') + + +def create_test_fields_model_handler(obj): + from .models import TestFieldsModel + + TestFieldsModel.objects.create() + + +def create_test_dispatchers_model_handler(obj): + from .models import TestDispatchersModel + + TestDispatchersModel.objects.create() diff --git a/example/dj/apps/test_chamber/models.py b/example/dj/apps/test_chamber/models.py index eaec832..8bfdd7e 100644 --- a/example/dj/apps/test_chamber/models.py +++ b/example/dj/apps/test_chamber/models.py @@ -6,7 +6,11 @@ from chamber import models as chamber_models from chamber.models import fields as chamber_fields -from chamber.utils.datastructures import SubstatesChoicesNumEnum, ChoicesNumEnum, SequenceChoicesNumEnum +from chamber.models.dispatchers import PropertyDispatcher, StateDispatcher +from chamber.utils.datastructures import ChoicesNumEnum, SequenceChoicesNumEnum, SubstatesChoicesNumEnum + +from .handlers import (create_test_dispatchers_model_handler, create_test_fields_model_handler, + create_test_smart_model_handler) class ShortcutsModel(models.Model): @@ -29,6 +33,10 @@ class TestSmartModel(chamber_models.SmartModel): name = models.CharField(max_length=100) +class RelatedSmartModel(chamber_models.SmartModel): + test_smart_model = models.ForeignKey(TestSmartModel, related_name='test_smart_models') + + class BackendUser(AbstractBaseUser): pass @@ -76,3 +84,29 @@ class TestFieldsModel(chamber_models.SmartModel): default=STATE.NOT_OK) state_graph = chamber_models.EnumSequencePositiveIntegerField(verbose_name=_('graph'), null=True, blank=True, enum=GRAPH) + + +class TestDispatchersModel(chamber_models.SmartModel): + + STATE = ChoicesNumEnum( + ('FIRST', _('first'), 1), + ('SECOND', _('second'), 2), + ) + state = models.IntegerField(null=True, blank=False, choices=STATE.choices, default=STATE.FIRST) + + pre_save_dispatchers = ( + StateDispatcher(create_test_smart_model_handler, STATE, state, STATE.SECOND), + ) + + post_save_dispatchers = ( + PropertyDispatcher(create_test_fields_model_handler, 'always_dispatch'), + PropertyDispatcher(create_test_dispatchers_model_handler, 'never_dispatch'), + ) + + @property + def always_dispatch(self): + return True + + @property + def never_dispatch(self): + return False diff --git a/example/dj/apps/test_chamber/tests/models/__init__.py b/example/dj/apps/test_chamber/tests/models/__init__.py index 08b557a..f5dfc7c 100644 --- a/example/dj/apps/test_chamber/tests/models/__init__.py +++ b/example/dj/apps/test_chamber/tests/models/__init__.py @@ -2,17 +2,18 @@ from datetime import timedelta -from django.test import TransactionTestCase, TestCase -from django.utils import timezone from django.core.exceptions import ValidationError +from django.test import TransactionTestCase +from django.utils import timezone -from germanium.tools import assert_equal, assert_raises, assert_true, assert_false +from chamber.exceptions import PersistenceException +from chamber.models import ChangedFields, Comparator -from test_chamber.models import DiffModel, ComparableModel, TestSmartModel, CSVRecord +from germanium.tools import assert_equal, assert_false, assert_raises, assert_true # pylint: disable=E0401 -from chamber.models import Comparator, ChangedFields -from chamber.exceptions import PersistenceException +from test_chamber.models import ComparableModel, DiffModel, RelatedSmartModel, TestSmartModel # pylint: disable=E0401 +from .dispatchers import * from .fields import * @@ -265,3 +266,12 @@ def test_smart_model_post_delete(self): assert_equal(obj.name, 'test post save') obj.delete() assert_equal(obj.name, 'test post delete') + + def test_smart_queryset_fast_distinct(self): + t = TestSmartModel.objects.create(name='name') + RelatedSmartModel.objects.create(test_smart_model=t) + RelatedSmartModel.objects.create(test_smart_model=t) + qs = TestSmartModel.objects.filter(test_smart_models__test_smart_model=t) + assert_equal(qs.count(), 2) + assert_equal(tuple(qs.values_list('pk', flat=True)), (t.pk, t.pk)) + assert_equal(qs.fast_distinct().count(), 1) diff --git a/example/dj/apps/test_chamber/tests/models/dispatchers.py b/example/dj/apps/test_chamber/tests/models/dispatchers.py new file mode 100644 index 0000000..d1e2c18 --- /dev/null +++ b/example/dj/apps/test_chamber/tests/models/dispatchers.py @@ -0,0 +1,36 @@ +from __future__ import unicode_literals + +from django.test import TransactionTestCase + +from chamber.shortcuts import change_and_save + +from germanium.tools import assert_equal # pylint: disable=E0401 + +from test_chamber.models import TestDispatchersModel, TestFieldsModel, TestSmartModel # pylint: disable=E0401 + + +class DispatchersTestCase(TransactionTestCase): + + def test_state_dispatcher(self): + m = TestDispatchersModel.objects.create() + + # Moving TestDispatcher model to SECOND state should create new TestSmartModel instance + assert_equal(TestSmartModel.objects.count(), 0) + change_and_save(m, state=TestDispatchersModel.STATE.SECOND) + assert_equal(TestSmartModel.objects.count(), 1) + + # But subsequent saves should not create more instances + change_and_save(m, state=TestDispatchersModel.STATE.SECOND) + assert_equal(TestSmartModel.objects.count(), 1) + + # Moving back and forth between the states creates another instance + change_and_save(m, state=TestDispatchersModel.STATE.FIRST) + change_and_save(m, state=TestDispatchersModel.STATE.SECOND) + assert_equal(TestSmartModel.objects.count(), 2) + + def test_property_dispatcher(self): + # Saving the model should always fire up the one property handler, not the second + assert_equal(TestFieldsModel.objects.count(), 0) + TestDispatchersModel.objects.create() + assert_equal(TestFieldsModel.objects.count(), 1) + assert_equal(TestDispatchersModel.objects.count(), 1)