diff --git a/.travis.yml b/.travis.yml index 0ce7253..fe880a8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,16 +6,11 @@ python: - "3.5" env: - - DJANGO_VERSION=1.7 - DJANGO_VERSION=1.8 - DJANGO_VERSION=1.9 matrix: exclude: - - python: "3.5" - env: DJANGO_VERSION=1.6 - - python: "3.5" - env: DJANGO_VERSION=1.7 - python: "3.3" env: DJANGO_VERSION=1.9 diff --git a/chamber/models/__init__.py b/chamber/models/__init__.py index 8641672..6f595a8 100644 --- a/chamber/models/__init__.py +++ b/chamber/models/__init__.py @@ -1,20 +1,27 @@ from __future__ import unicode_literals +import copy + import collections from itertools import chain +import six + from six import python_2_unicode_compatible from django.core.exceptions import ValidationError from django.db import models, transaction +from django.db.models.base import ModelBase from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import force_text from chamber.exceptions import PersistenceException from chamber.patch import Options +from chamber.shortcuts import change_and_save, change, bulk_change_and_save from .fields import * # NOQA exposing classes and functions as a module API +from .signals import dispatcher_post_save, dispatcher_pre_save def many_to_many_field_to_dict(field, instance): @@ -60,18 +67,25 @@ def model_to_dict(instance, fields=None, exclude=None): @python_2_unicode_compatible class ChangedFields(object): + """ + Class stores changed fields and its initial and current values. + """ - def __init__(self, instance): - self.instance = instance - self.initial_values = self.get_instance_dict(instance) + def __init__(self, initial_dict): + self._initial_dict = initial_dict - def get_instance_dict(self, instance): - return model_to_dict(instance, fields=(field.name for field in instance._meta.fields)) + @property + def initial_values(self): + return self._initial_dict + + @property + def current_values(self): + raise NotImplementedError @property def diff(self): d1 = self.initial_values - d2 = self.get_instance_dict(self.instance) + d2 = self.current_values return {k: ValueChange(v, d2[k]) for k, v in d1.items() if v != d2[k]} def __setitem__(self, key, item): @@ -126,6 +140,40 @@ def __str__(self): return repr(self.diff) +class DynamicChangedFields(ChangedFields): + """ + Dynamic changed fields are changed with the instance changes. + """ + + def __init__(self, instance): + super(DynamicChangedFields, self).__init__(self._get_instance_dict(instance)) + self.instance = instance + + def _get_instance_dict(self, instance): + return model_to_dict(instance, fields=(field.name for field in instance._meta.fields)) + + @property + def current_values(self): + return self._get_instance_dict(self.instance) + + def get_static_changes(self): + return StaticChangedFields(self.initial_values, self.current_values) + + +class StaticChangedFields(ChangedFields): + """ + Static changed fields are immutable. The origin instance changes will not have an affect. + """ + + def __init__(self, initial_dict, current_dict): + super(StaticChangedFields, self).__init__(initial_dict) + self._current_dict = current_dict + + @property + def current_values(self): + return self._current_dict + + class ComparableModelMixin(object): def equals(self, obj, comparator): @@ -145,6 +193,7 @@ def compare(self, a, b): class AuditModel(models.Model): + created_at = models.DateTimeField(verbose_name=_('created at'), null=False, blank=False, auto_now_add=True, db_index=True) changed_at = models.DateTimeField(verbose_name=_('changed at'), null=False, blank=False, auto_now=True, @@ -170,16 +219,44 @@ def send(self): class SmartQuerySet(models.QuerySet): def fast_distinct(self): + """ + Because standard distinct used on the all fields are very slow and works only with PostgreSQL database + this method provides alternative to the standard distinct method. + :return: qs with unique objects + """ return self.model.objects.filter(pk__in=self.values_list('pk', flat=True)) + def change_and_save(self, **chaned_fields): + """ + Changes a given `changed_fields` on each object in the queryset, saves objects + and returns the changed objects in the queryset. + """ + bulk_change_and_save(self, **chaned_fields) + return self.filter() + + +class SmartModelBase(ModelBase): + """ + Smart model meta class that register dispatchers to the post or pre save signals. + """ + + def __new__(cls, name, bases, attrs): + + new_cls = super(SmartModelBase, cls).__new__(cls, name, bases, attrs) + for dispatcher in new_cls.dispatchers: + dispatcher.connect(new_cls) + return new_cls -class SmartModel(AuditModel): + +class SmartModel(six.with_metaclass(SmartModelBase, AuditModel)): objects = SmartQuerySet.as_manager() + dispatchers = [] + def __init__(self, *args, **kwargs): super(SmartModel, self).__init__(*args, **kwargs) - self.changed_fields = ChangedFields(self) + self.changed_fields = DynamicChangedFields(self) self.post_save = Signal(self) @property @@ -241,13 +318,9 @@ def _pre_save(self, *args, **kwargs): def _call_pre_save(self, *args, **kwargs): self._pre_save(*args, **kwargs) - def _call_dispatcher_group(self, group_name, change, changed_fields, *args, **kwargs): - if hasattr(self, group_name): - for dispatcher in getattr(self, group_name): - dispatcher(self, change, 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 = ( self._smart_meta.is_cleaned_pre_save if is_cleaned_pre_save is None else is_cleaned_pre_save ) @@ -255,20 +328,25 @@ def _save(self, is_cleaned_pre_save=None, is_cleaned_post_save=None, force_inser self._smart_meta.is_cleaned_post_save if is_cleaned_post_save is None else is_cleaned_post_save ) + origin = self.__class__ + change = bool(self.pk) kwargs.update(self._get_save_extra_kwargs()) self._call_pre_save(change, self.changed_fields, *args, **kwargs) if is_cleaned_pre_save: self._clean_pre_save(*args, **kwargs) - self._call_dispatcher_group('pre_save_dispatchers', change, self.changed_fields, *args, **kwargs) - + dispatcher_pre_save.send(sender=origin, instance=self, change=change, + changed_fields=self.changed_fields.get_static_changes(), + *args, **kwargs) super(SmartModel, self).save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) self._call_post_save(change, self.changed_fields, *args, **kwargs) if is_cleaned_post_save: self._clean_post_save(*args, **kwargs) - self._call_dispatcher_group('post_save_dispatchers', change, self.changed_fields, *args, **kwargs) + dispatcher_post_save.send(sender=origin, instance=self, change=change, + changed_fields=self.changed_fields.get_static_changes(), + *args, **kwargs) self.post_save.send() def _post_save(self, *args, **kwargs): @@ -283,7 +361,7 @@ def save(self, *args, **kwargs): self._save(*args, **kwargs) else: self._save(*args, **kwargs) - self.changed_fields = ChangedFields(self) + self.changed_fields = DynamicChangedFields(self) def _pre_delete(self, *args, **kwargs): pass @@ -318,6 +396,28 @@ def delete(self, *args, **kwargs): else: self._delete(*args, **kwargs) + def refresh_from_db(self, *args, **kwargs): + super().refresh_from_db(*args, **kwargs) + return self + + def change(self, **changed_fields): + """ + Changes a given `changed_fields` on this object and returns itself. + :param changed_fields: fields to change + :return: self + """ + change(self, **changed_fields) + return self + + def change_and_save(self, **changed_fields): + """ + Changes a given `changed_fields` on this object, saves it and returns itself. + :param changed_fields: fields to change + :return: self + """ + change_and_save(self, **changed_fields) + return self + class Meta: abstract = True diff --git a/chamber/models/dispatchers.py b/chamber/models/dispatchers.py index 2c5c034..afee035 100644 --- a/chamber/models/dispatchers.py +++ b/chamber/models/dispatchers.py @@ -1,31 +1,47 @@ from __future__ import unicode_literals +from collections import defaultdict + from django.core.exceptions import ImproperlyConfigured +from chamber.utils.transaction import ( + on_success, OnSuccessHandler, OneTimeOnSuccessHandler, InstanceOneTimeOnSuccessHandler +) + +from .signals import dispatcher_post_save + 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. """ + + signal = None + def _validate_init_params(self): if not callable(self.handler): raise ImproperlyConfigured('Registered handler must be a callable.') - def __init__(self, handler, *args, **kwargs): + def __init__(self, handler, signal=None): self.handler = handler self._validate_init_params() + self._connected = defaultdict(list) + self._signal = signal if signal is not None else self.signal + + def connect(self, sender): + self._signal.connect(self, sender=sender) - def __call__(self, obj, *args, **kwargs): + def __call__(self, instance, **kwargs): """ - `obj` ... instance of the SmartModel where the handler is being called + `instance` ... 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) + if self._can_dispatch(instance, **kwargs): + self.handler(instance=instance, **kwargs) - def _can_dispatch(self, obj, *args, **kwargs): + def _can_dispatch(self, instance, *args, **kwargs): raise NotImplementedError @@ -41,12 +57,12 @@ def _validate_init_params(self): """ pass - def __init__(self, handler, property_name): + def __init__(self, handler, property_name, signal=None): self.property_name = property_name - super(PropertyDispatcher, self).__init__(handler, property_name) + super(PropertyDispatcher, self).__init__(handler, signal) - def _can_dispatch(self, obj, *args, **kwargs): - return getattr(obj, self.property_name) + def _can_dispatch(self, instance, **kwargs): + return getattr(instance, self.property_name) class CreatedDispatcher(BaseDispatcher): @@ -54,26 +70,29 @@ class CreatedDispatcher(BaseDispatcher): Calls registered handler if and only if an instance of the model is being created. """ - def _can_dispatch(self, obj, change, *args, **kwargs): + def _can_dispatch(self, instance, change, **kwargs): return not change class StateDispatcher(BaseDispatcher): - """ Use this class to register a handler for transition of a model to a certain state. """ + def _validate_init_params(self): super(StateDispatcher, self)._validate_init_params() 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)) - def __init__(self, handler, enum, field, field_value): + def __init__(self, handler, enum, field, field_value, signal=None): self.enum = enum self.field = field self.field_value = field_value - super(StateDispatcher, self).__init__(handler, enum, field, field_value) + super(StateDispatcher, self).__init__(handler, signal=signal) - def _can_dispatch(self, obj, change, changed_fields, *args, **kwargs): - return self.field.get_attname() in changed_fields and getattr(obj, self.field.get_attname()) == self.field_value + def _can_dispatch(self, instance, change, changed_fields, *args, **kwargs): + return ( + self.field.get_attname() in changed_fields and + getattr(instance, self.field.get_attname()) == self.field_value + ) diff --git a/chamber/models/signals.py b/chamber/models/signals.py new file mode 100644 index 0000000..aafad8f --- /dev/null +++ b/chamber/models/signals.py @@ -0,0 +1,5 @@ +from django.dispatch import Signal + + +dispatcher_pre_save = Signal() +dispatcher_post_save = Signal() diff --git a/chamber/templatetags/__init__.py b/chamber/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chamber/utils/transaction.py b/chamber/utils/transaction.py index 9b5c181..82aa9ab 100644 --- a/chamber/utils/transaction.py +++ b/chamber/utils/transaction.py @@ -1,11 +1,197 @@ -from django.db import transaction +import logging + +from collections import OrderedDict + +from django.conf import settings +from django.db import transaction, DEFAULT_DB_ALIAS +from django.db.transaction import get_connection +from django.utils.decorators import ContextDecorator + + +logger = logging.getLogger(__name__) def atomic(func): + """ + Decorator helper that overrides django atomic decorator and automatically adds create revision. + """ try: from reversion.revisions import create_revision return transaction.atomic(create_revision()(func)) except ImportError: - return transaction.atomic(func) + + +class TransactionSignalsContext(object): + """ + Context object that stores handlers and call it after successful pass trough surrounded code block + with "transaction_signals decorator. Handlers can be unique or standard. Unique handlers are registered + and executed only once. + """ + + def __init__(self): + self._unique_handlers = OrderedDict() + self._handlers = [] + + def register(self, handler): + if getattr(handler, 'is_unique', False): + if hash(handler) in self._unique_handlers: + self._unique_handlers.get(hash(handler)).join(handler) + else: + self._unique_handlers[hash(handler)] = handler + self._handlers.append(handler) + else: + self._handlers.append(handler) + + def handle_all(self): + for handler in self._handlers: + handler() + + def join(self, transaction_signals_context): + for handler in transaction_signals_context._handlers: + self.register(handler) + + +class TransactionSignals(ContextDecorator): + """ + Context decorator that supports usage python keyword "with". + Decorator that adds transaction context to the connection on input. + Finally handlers are called on the output. + """ + + def __init__(self, using): + self.using = using + + def __enter__(self): + connection = get_connection(self.using) + + if not hasattr(connection, 'transaction_signals_context_list'): + connection.transaction_signals_context_list = [] + + connection.transaction_signals_context_list.append(TransactionSignalsContext()) + + def __exit__(self, exc_type, exc_value, traceback): + connection = get_connection(self.using) + transaction_signals_context = connection.transaction_signals_context_list.pop() + if not exc_value: + if len(connection.transaction_signals_context_list) == 0: + transaction_signals_context.handle_all() + else: + connection.transaction_signals_context_list[-1].join(transaction_signals_context) + + +def on_success(handler, using=None): + """ + Register a handler or a function to be called after successful code pass. + If transaction signals are not active the handler/function is called immediately. + :param handler: handler or function that will be called. + :param using: name of the database + """ + + connection = get_connection(using) + if getattr(connection, 'transaction_signals_context_list', False): + connection.transaction_signals_context_list[-1].register(handler) + else: + if settings.DEBUG: + logger.warning( + 'For on success signal should be activated transaction signals via transaction_signals decorator.' + 'Function is called immediately now.' + ) + handler() + + +def transaction_signals(using=None): + """ + Decorator that adds transaction context to the connection on input. + Finally handlers are called on the output. + :param using: name of the database + """ + if callable(using): + return TransactionSignals(DEFAULT_DB_ALIAS)(using) + else: + return TransactionSignals(using) + + +def atomic_with_signals(func): + """ + Atomic decorator with transaction signals. + """ + try: + from reversion.revisions import create_revision + + return transaction.atomic(create_revision()(transaction_signals(func))) + except ImportError: + return transaction.atomic(transaction_signals(func)) + + +class OnSuccessHandler(object): + """ + Handler class that is used for performing on success operations. + """ + + is_unique = False + + def __init__(self, using=None, **kwargs): + self.kwargs = kwargs + on_success(self, using=using) + + def __call__(self): + self.handle(**self.kwargs) + + def handle(self, **kwargs): + """ + There should be implemented handler operations. + :param kwargs: input data that was send during hanlder creation. + """ + raise NotImplementedError + + +class OneTimeOnSuccessHandler(OnSuccessHandler): + """ + One time handler class that is used for performing on success operations. + Handler is called only once, but data of all calls are stored inside list (kwargs_list). + """ + + is_unique = True + + def __init__(self, using=None, **kwargs): + self.kwargs_list = (kwargs,) + on_success(self, using=using) + + def join(self, handler): + """ + Joins two unique handlers. + """ + self.kwargs_list += handler.kwargs_list + + def _get_unique_id(self): + """ + Unique handler must be identified with some was + :return: + """ + return None + + def __hash__(self): + return hash((self.__class__, self._get_unique_id())) + + def __call__(self): + self.handle(self.kwargs_list) + + def handle(self, kwargs_list): + raise NotImplementedError + + +class InstanceOneTimeOnSuccessHandler(OneTimeOnSuccessHandler): + """ + Use this class to create handler that will be unique per instance and will be called only once per instance. + """ + + def _get_instance(self): + instance = self.kwargs_list[0]['instance'] + instance.refresh_from_db() + return instance + + def _get_unique_id(self): + instance = self.kwargs_list[0]['instance'] + return hash((instance.__class__, instance.pk)) diff --git a/docs/dispatchers.rst b/docs/dispatchers.rst index cb602e8..216aa8e 100644 --- a/docs/dispatchers.rst +++ b/docs/dispatchers.rst @@ -20,13 +20,11 @@ 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 +3. ``**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. +Therefore, you can select signal that defines when dispatcher will be infoked. There is two signals: +``chamber.models.signals.dispatcher_pre_save`` or ``chamber.models.signals.dispatcher_post_save`` Both groups are dispatched immediately after the ``_pre_save`` or ``_post_save`` method respectively. @@ -40,7 +38,7 @@ When the handler is fired, it is passed a single argument -- the instance of the .. class:: chamber.models.dispatchers.PropertyDispatcher -``chamber.models.dispatchers.PropertyDispatcher`` is a versatile +``PropertyDispatcher`` is a versatile dispatcher that fires the given handler when a specified property of the model evaluates to ``True``. @@ -54,8 +52,8 @@ handler to be dispatched after saving the object if the property email_sent = models.BooleanField() - post_save_dispatchers = ( - PropertyDispatcher(send_email, 'should_send_email'), + dispatchers = ( + PropertyDispatcher(send_email, 'should_send_email', dispatcher_post_save), ) @property @@ -77,13 +75,12 @@ be dispatched during the ``_pre_save`` method when the state changes to 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), + dispatchers = ( + StateDispatcher(my_handler, STATE, state, STATE.SECOND, signal=dispatcher_pre_save), ) diff --git a/docs/index.rst b/docs/index.rst index e604338..da24371 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -72,5 +72,6 @@ Content forms models dispatchers + transactions utils shortcuts diff --git a/docs/models.rst b/docs/models.rst index b6dd417..9a5faf6 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -72,6 +72,106 @@ The ``django.db.models.FileField`` with ``RestrictedFileFieldMixin`` options. Mixin for automatic South migration of custom model fields. +SmartModel +---------- + +``chamber.models.SmartModel`` improved django Model class with several features that simplify development of complex applications + +.. class:: chamber.models.SmartModel + + .. attribute:: created_at + + Because our experience has shown us that datetime of creation is very useful this field ``django.models.DateTimeField`` with ``auto_add_no`` set to ``True`` is added to every model that inherits from ``SmartModel`` + + .. attribute:: changed_at + + This model field is same case as ``created_at`` with the difference that there is used ``auto_now=True`` therefore every date and time of change is stored here. + + .. attribute:: dispatchers + + List of defined pre or post save dispatchers. More obout it will find _dispatchers + + .. property:: has_changed + + Returns ``True`` or ``False`` depending on whether instance was changed + + .. property:: initial_values + + Returns initial values of the object from loading instance from database. It should represent actual state of the object in the database + + .. method:: clean_() + + Like a django form field you can use your own method named by field name for cleaning input value. You can too raise ``ValidationError`` if input value is invalid + + .. method:: _pre_save() + + Method that is called before saving instance. You can here change instance structure or call some operations before saving object + + .. method:: _post_save() + + Method that is called after saving instance. You can here change instance structure or call some operations after saving object + + .. method:: _pre_delete() + + Method that is called before removing instance. You can here change instance structure or call some operations before removing object + + .. method:: _post_delete() + + Method that is called after removing instance. You can here change instance structure or call some operations after removing object + + .. method:: refresh_from_db() + + There is used implementation from django ``refresh_from_db`` method with small change that method returns refreshed instance + + .. method:: change(**changed_fields) + + Update instance field values with values send in ``changed_fields`` + + .. method:: change_and_save(**changed_fields) + + Update instance field values with values send in ``changed_fields`` and finally instance is saved + + +SmartMeta +--------- + +SmartMeta similar like django meta is defined inside ``SmartModel`` and is accessible via ``_smart_meta`` attribute. Its purpose is define default ``SmartModel`` behavior. + +.. class:: SmartMeta + + .. attribute:: is_cleaned_pre_save + + Defines if ``SmartModel`` will be automatically validated before saving. Default value is ``True`` + + .. attribute:: is_cleaned_pre_save + + Defines if ``SmartModel`` will be automatically validated after saving. Default value is ``False`` + + .. attribute:: is_cleaned_pre_delete + + Defines if ``SmartModel`` will be automatically validated before removing. Default value is ``False`` + + .. attribute:: is_cleaned_pre_delete + + Defines if ``SmartModel`` will be automatically validated after removing. Default value is ``False`` + + .. attribute:: is_save_atomic + + Defines if ``SmartModel`` will be saved in transaction atomic block ``False`` + + .. attribute:: is_delete_atomic + + Defines if ``SmartModel`` will be removed in transaction atomic block ``False`` + +.. code:: python + + class SmartModelWithMeta(SmartModel): + + class SmartMeta: + is_cleaned_pre_save = True + is_cleaned_pre_delete = True + + SmartQuerySet ------------- @@ -98,3 +198,11 @@ will assume the custom filters to be there). .. code:: python MyModel.objects.filter(pk__in=qs.values_list('pk', flat=True)) + + .. method:: change_and_save(**changed_fields) + + Change selected fields on the selected queryset and saves it, finnaly is returned changed objects in the queryset. Difference from update is that there is called save method on the instance, but it is slower. + + + + diff --git a/docs/transactions.rst b/docs/transactions.rst new file mode 100644 index 0000000..399e824 --- /dev/null +++ b/docs/transactions.rst @@ -0,0 +1,67 @@ +Transaction helpers +=================== + + +``chamber.utils.transaction.atomic`` +------------------------------------ + +.. function:: atomic() + + Like django ``transaction.atomic()`` decorator chamber atomic can be used for surrounding method, function or block of code with db atomic block. But because we often uses reversion the atomic is surrounded with ``create_revision`` decorator + +.. function:: transaction_signals() + + Decorator that is used for automatic invoking on success signals. Function or handler registered with ``on_success`` function is executed if block of code will not thrown exception. + Its behaviour is very similar to atomic block, if you will inherit these decorators the event will be invoked until after the completion of last decorated code. + +.. function:: atomic_with_signals() + + Combination of ``atomic`` and ``transaction_signals``. + +.. function:: on_success() + + Function for on success handlers registration. If thransaction signals are not activated (decorator ``transaction_signals`` is not used) the handler will be invoked immediately. + +``chamber.utils.transaction.OnSuccessHandler`` +---------------------------------------------- + +Handler that can be used for implementation tasks that should be called only after successful pass throught code. Hanlder is automatically registered inside its constructior. + +.. class:: chamber.utils.transaction.OnSuccessHandler + + .. method:: __init__(using=None, **kwargs) + + Constructor of success handler accepts input data in kwargs and using where you can define database + + .. method:: handle(**kwargs) + + There should be implemented code that will be invoked after success pass though the code + + +``chamber.utils.transaction.OneTimeOnSuccessHandler`` +----------------------------------------------------- + +One time handler is registered and called only once. But all input parameters are stored inside list of kwargs. + +.. class:: chamber.utils.transaction.OneTimeOnSuccessHandler + + .. method:: handle(**kwargs_list) + + There should be implemented code that will be invoked after success pass though the code. Difference from ``OnSuccessHandler.handle`` is that kwargs is stored inside list in the order how handlers was created + + .. method:: _get_unique_id() + + The uniqueness of the handler must be somehow defined. You must implement this method to define unique identifier of the handler. By default it is identified with has of the class + + +``chamber.utils.transaction.InstanceOneTimeOnSuccessHandler`` +------------------------------------------------------------- + +Special type of unique handler that is identified with iteslf and model instance of the input model object. + +.. class:: chamber.utils.transaction.InstanceOneTimeOnSuccessHandler + + .. method:: _get_instance() + + Returns instance stored in input kwargs which is refreshed from database to get actual state of the model object + diff --git a/docs/utils.rst b/docs/utils.rst index e46c6e4..4056db5 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -10,6 +10,7 @@ Utility Functions remove_accent('ěščřžýáíé') # 'escrzyaie' .. function:: chamber.utils.get_class_method(cls_or_inst, method_name) + Returns a method of a given class or instance. @@ -29,12 +30,15 @@ Enums ----- .. class:: chamber.utils.datastructures.AbstractEnum + Base enumeration class with controlled ``__getattr__``. .. class:: chamber.utils.datastructures.Enum + Python's ``set`` with ``AbstractEnum`` behaviour. .. class:: chamber.utils.datastructures.NumEnum + Python's ``dict`` with ``AbstractEnum`` behaviour. :: @@ -43,9 +47,11 @@ Python's ``dict`` with ``AbstractEnum`` behaviour. {'a': 1, 'b': 2} .. class:: chamber.utils.datastructures.AbstractChoicesEnum + Base choices class (can be used as a model field's ``choices`` argument). .. class:: chamber.utils.datastructures.ChoicesEnum + ``django.utils.datastructures.SortedDict`` with ``AbstractEnum`` and ``AbstractChoicesEnum`` behaviour. Useful for string based choices. @@ -78,6 +84,7 @@ Decorators ---------- .. decorator:: chamber.utils.decorators.classproperty + Decorator that turns a class method into a property of the class. :: diff --git a/example/dj/apps/test_chamber/handlers.py b/example/dj/apps/test_chamber/handlers.py index f3c2e96..63fda2d 100644 --- a/example/dj/apps/test_chamber/handlers.py +++ b/example/dj/apps/test_chamber/handlers.py @@ -1,25 +1,25 @@ from __future__ import unicode_literals -def create_test_smart_model_handler(obj): +def create_test_smart_model_handler(instance, **kwargs): from .models import TestSmartModel # NOQA TestSmartModel.objects.create(name='name') -def create_test_fields_model_handler(obj): +def create_test_fields_model_handler(instance, **kwargs): from .models import TestFieldsModel # NOQA TestFieldsModel.objects.create() -def create_test_dispatchers_model_handler(obj): +def create_test_dispatchers_model_handler(instance, **kwargs): from .models import TestDispatchersModel # NOQA TestDispatchersModel.objects.create() -def create_csv_record_handler(obj): +def create_csv_record_handler(instance, **kwargs): from .models import CSVRecord # NOQA CSVRecord.objects.create() diff --git a/example/dj/apps/test_chamber/models.py b/example/dj/apps/test_chamber/models.py index ea791ec..b1f7949 100644 --- a/example/dj/apps/test_chamber/models.py +++ b/example/dj/apps/test_chamber/models.py @@ -7,6 +7,7 @@ from chamber import models as chamber_models from chamber.models import fields as chamber_fields from chamber.models.dispatchers import CreatedDispatcher, PropertyDispatcher, StateDispatcher +from chamber.models.signals import dispatcher_pre_save, dispatcher_post_save from chamber.utils.datastructures import ChoicesNumEnum, SequenceChoicesNumEnum, SubstatesChoicesNumEnum from .handlers import (create_test_dispatchers_model_handler, create_test_fields_model_handler, @@ -99,14 +100,11 @@ class TestDispatchersModel(chamber_models.SmartModel): ) state = models.IntegerField(null=True, blank=False, choices=STATE.choices, default=STATE.FIRST) - pre_save_dispatchers = ( - CreatedDispatcher(create_csv_record_handler, STATE, state, STATE.SECOND), - 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'), + dispatchers = ( + CreatedDispatcher(create_csv_record_handler, signal=dispatcher_pre_save), + StateDispatcher(create_test_smart_model_handler, STATE, state, STATE.SECOND, signal=dispatcher_pre_save), + PropertyDispatcher(create_test_fields_model_handler, 'always_dispatch', signal=dispatcher_post_save), + PropertyDispatcher(create_test_dispatchers_model_handler, 'never_dispatch', signal=dispatcher_post_save), ) @property diff --git a/example/dj/apps/test_chamber/tests/models/__init__.py b/example/dj/apps/test_chamber/tests/models/__init__.py index 0533db1..0be4c01 100644 --- a/example/dj/apps/test_chamber/tests/models/__init__.py +++ b/example/dj/apps/test_chamber/tests/models/__init__.py @@ -7,7 +7,7 @@ from django.utils import timezone from chamber.exceptions import PersistenceException -from chamber.models import ChangedFields, Comparator +from chamber.models import DynamicChangedFields, Comparator from germanium.tools import assert_equal, assert_false, assert_raises, assert_true # pylint: disable=E0401 @@ -70,17 +70,20 @@ class ModelsTestCase(TransactionTestCase): def test_smart_model_changed_fields(self): obj = TestProxySmartModel.objects.create(name='a') - changed_fields = ChangedFields(obj) + changed_fields = DynamicChangedFields(obj) assert_equal(len(changed_fields), 0) obj.name = 'b' assert_equal(len(changed_fields), 1) assert_equal(changed_fields['name'].initial, 'a') assert_equal(changed_fields['name'].current, 'b') + static_changed_fields = changed_fields.get_static_changes() obj.save() # Initial values is not changed assert_equal(len(changed_fields), 2) + assert_equal(len(static_changed_fields), 1) assert_equal(set(changed_fields.keys()), {'name', 'changed_at'}) + assert_equal(set(static_changed_fields.keys()), {'name'}) assert_equal(changed_fields['name'].initial, 'a') assert_equal(changed_fields['name'].current, 'b') @@ -91,6 +94,8 @@ def test_smart_model_changed_fields(self): assert_raises(AttributeError, changed_fields.clear) assert_raises(AttributeError, changed_fields.pop, 'name') + obj.name = 'b' + def test_model_diff(self): obj = DiffModel.objects.create(name='test', datetime=timezone.now(), number=2) assert_false(obj.has_changed) diff --git a/example/dj/apps/test_chamber/tests/transaction.py b/example/dj/apps/test_chamber/tests/transaction.py new file mode 100644 index 0000000..a6b0c8e --- /dev/null +++ b/example/dj/apps/test_chamber/tests/transaction.py @@ -0,0 +1,121 @@ +from __future__ import unicode_literals + +from django.test import TestCase + +from chamber.utils.transaction import ( + on_success, transaction_signals, OnSuccessHandler, OneTimeOnSuccessHandler +) + +from germanium.tools import assert_equal # pylint: disable=E0401 + + +def add_number(numbers_list, number): + numbers_list.append(number) + + +class TransactionsTestCase(TestCase): + + def test_on_success_is_called_after_successful_pass(self): + numbers_list = [] + + with transaction_signals(): + on_success(lambda: add_number(numbers_list, 0)) + + assert_equal(len(numbers_list), 0) + + assert_equal(len(numbers_list), 1) + + def test_on_success_is_not_called_after_not_successful_pass(self): + numbers_list = [] + + try: + with transaction_signals(): + on_success(lambda: add_number(numbers_list, 0)) + + assert_equal(len(numbers_list), 0) + raise Exception() + except Exception: + pass + + assert_equal(len(numbers_list), 0) + + def test_on_success_inheritance(self): + numbers_list = [] + + with transaction_signals(): + with transaction_signals(): + on_success(lambda: add_number(numbers_list, 0)) + + assert_equal(len(numbers_list), 0) + assert_equal(len(numbers_list), 0) + try: + with transaction_signals(): + on_success(lambda: add_number(numbers_list, 1)) + + assert_equal(len(numbers_list), 0) + raise Exception() + except Exception: + pass + on_success(lambda: add_number(numbers_list, 2)) + assert_equal(len(numbers_list), 0) + + assert_equal(numbers_list, [0,2]) + + def test_on_success_handler(self): + numbers_list = [] + + class AddNumberOnSuccessHandler(OnSuccessHandler): + + def handle(self, numbers_list, number): + numbers_list.append(number) + + with transaction_signals(): + AddNumberOnSuccessHandler(numbers_list=numbers_list, number=1) + AddNumberOnSuccessHandler(numbers_list=numbers_list, number=2) + + assert_equal(len(numbers_list), 0) + + assert_equal(numbers_list, [1, 2]) + + def test_on_success_one_time_handler(self): + numbers_list = [] + + class AddNumberOneTimeOnSuccessHandler(OneTimeOnSuccessHandler): + + def handle(self, kwargs_list): + kwargs_list[-1]['numbers_list'].append(3) + + with transaction_signals(): + for i in range(5): + AddNumberOneTimeOnSuccessHandler(numbers_list=numbers_list, number=i) + + assert_equal(len(numbers_list), 0) + + assert_equal(numbers_list, [3]) + + def test_on_success_one_time_handler_inheritance(self): + numbers_list = [] + + class AddNumberOneTimeOnSuccessHandler(OneTimeOnSuccessHandler): + + def handle(self, kwargs_list): + for kwargs in kwargs_list: + kwargs_list[-1]['numbers_list'].append(kwargs['number']) + + with transaction_signals(): + AddNumberOneTimeOnSuccessHandler(numbers_list=numbers_list, number=1) + try: + with transaction_signals(): + AddNumberOneTimeOnSuccessHandler(numbers_list=numbers_list, number=2) + + assert_equal(len(numbers_list), 0) + raise Exception() + except Exception: + pass + with transaction_signals(): + AddNumberOneTimeOnSuccessHandler(numbers_list=numbers_list, number=3) + + assert_equal(len(numbers_list), 0) + assert_equal(len(numbers_list), 0) + + assert_equal(numbers_list, [1, 3])