Skip to content

Commit

Permalink
Added model dispatchers and SmartQuerySet
Browse files Browse the repository at this point in the history
  • Loading branch information
asgeirrr committed Jan 6, 2017
1 parent 7b5ae0b commit 9ad1f8d
Show file tree
Hide file tree
Showing 7 changed files with 259 additions and 9 deletions.
66 changes: 64 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ shortcuts, advanced datastructure, decoraters etc.). For more details see exampl

## Reference

### Forms
### 1. Forms

#### `chamber.forms.fields.DecimalField`

Expand All @@ -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`

Expand Down Expand Up @@ -50,6 +50,68 @@ 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.

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.

#### 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 `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
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`
Expand Down
15 changes: 15 additions & 0 deletions chamber/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = (
Expand All @@ -230,13 +243,15 @@ 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()

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()
Expand Down
74 changes: 74 additions & 0 deletions chamber/models/dispatchers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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 NotImplemented

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 NotImplemented


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 inspect.getargspec(self.handler).keywords:
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
19 changes: 19 additions & 0 deletions example/dj/apps/test_chamber/handlers.py
Original file line number Diff line number Diff line change
@@ -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()
36 changes: 35 additions & 1 deletion example/dj/apps/test_chamber/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -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
22 changes: 16 additions & 6 deletions example/dj/apps/test_chamber/tests/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

from chamber.models import Comparator, ChangedFields
from chamber.exceptions import PersistenceException
from test_chamber.models import ComparableModel, DiffModel, RelatedSmartModel, TestSmartModel

from .dispatchers import *
from .fields import *


Expand Down Expand Up @@ -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)
36 changes: 36 additions & 0 deletions example/dj/apps/test_chamber/tests/models/dispatchers.py
Original file line number Diff line number Diff line change
@@ -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, assert_false, assert_raises, assert_true

from test_chamber.models import TestDispatchersModel, TestSmartModel, TestFieldsModel, TestDispatchersModel


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)

0 comments on commit 9ad1f8d

Please sign in to comment.