├── django_trigger ├── __init__.py ├── tests │ ├── triggerapp │ │ ├── __init__.py │ │ ├── models.py │ │ └── triggers.py │ └── __init__.py ├── signals.py ├── triggers.py ├── models.py └── diffmodelmixin.py ├── README.md └── .gitignore /django_trigger/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-trigger-model 2 | -------------------------------------------------------------------------------- /django_trigger/tests/triggerapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | *.orig 3 | *.pyc 4 | *.pyo 5 | *~ 6 | *.DS_Store 7 | .installed.cfg 8 | .bash* 9 | .ipython 10 | .mysql_history 11 | .psql_history 12 | .ssh 13 | .bashrc 14 | .profile 15 | .subversion 16 | .sass-cache/ 17 | -------------------------------------------------------------------------------- /django_trigger/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django_trigger.tests.triggerapp.models import Article 4 | from django_trigger.tests.triggerapp.models import Section 5 | 6 | 7 | class TriggerTest(TestCase): 8 | 9 | def setUp(self): 10 | Article.objects.create(title="article test") 11 | 12 | def test_one(self): 13 | self.assertEqual('yo', 'yo') 14 | -------------------------------------------------------------------------------- /django_trigger/tests/triggerapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_trigger.models import TriggerModel 4 | from django_trigger.tests.triggerapp.triggers import SectionTriggerAction 5 | from django_trigger.tests.triggerapp.triggers import ArticleTriggerAction 6 | 7 | 8 | class Article(TriggerModel): 9 | title = models.CharField(max_length=250) 10 | 11 | _triggers = ArticleTriggerAction() 12 | 13 | 14 | class Section(TriggerModel): 15 | title = models.CharField(max_length=250) 16 | articles = models.ManyToManyField(Article) 17 | 18 | _triggers = SectionTriggerAction() 19 | 20 | 21 | class TriggerLog(models.Model): 22 | action = models.CharField(max_length=250) 23 | model = models.CharField(max_length=250) 24 | -------------------------------------------------------------------------------- /django_trigger/tests/triggerapp/triggers.py: -------------------------------------------------------------------------------- 1 | from django_trigger.triggers import TriggerAction 2 | 3 | 4 | class ArticleTriggerAction(TriggerAction): 5 | 6 | fields = { 7 | '_change_title': [ 8 | 'title' 9 | ] 10 | } 11 | 12 | signals = { 13 | 'post_save': [ 14 | '_change_title' 15 | ] 16 | } 17 | 18 | def _change_title(self): 19 | print "_change_title" 20 | #from django_trigger.tests.models import TriggerLog 21 | #TriggerLog 22 | 23 | 24 | class SectionTriggerAction(TriggerAction): 25 | 26 | fields = { 27 | '_change_title': [ 28 | 'title' 29 | ] 30 | } 31 | 32 | signals = { 33 | 'post_save': [ 34 | '_change_title' 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /django_trigger/signals.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | def trigger_signal(action, *args, **kwargs): 4 | instance = kwargs['instance'] 5 | instance.dispatch_tracker(action, *args, **kwargs) 6 | 7 | 8 | def trigger_pre_save(*args, **kwargs): 9 | trigger_signal('pre_save', *args, **kwargs) 10 | 11 | 12 | def trigger_post_save(*args, **kwargs): 13 | trigger_signal('post_save', *args, **kwargs) 14 | instance = kwargs['instance'] 15 | setattr(instance, "__initial", instance._dict) 16 | 17 | 18 | def trigger_pre_delete(*args, **kwargs): 19 | trigger_signal('pre_delete', *args, **kwargs) 20 | 21 | 22 | def trigger_post_delete(*args, **kwargs): 23 | trigger_signal('post_delete', *args, **kwargs) 24 | 25 | 26 | def trigger_m2m_changed(*args, **kwargs): 27 | trigger_signal('m2m_changed', *args, **kwargs) 28 | -------------------------------------------------------------------------------- /django_trigger/triggers.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | """ 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | 7 | class TriggerAction(object): 8 | """ 9 | 10 | """ 11 | 12 | fields = {} 13 | signals = {} 14 | 15 | def __init__(self): 16 | 17 | classname = self.__class__.__name__ 18 | 19 | # check fields 20 | if not isinstance(self.fields, dict): 21 | raise ImproperlyConfigured( 22 | '%s fields must be a dict' % classname 23 | ) 24 | 25 | for k, v in self.fields.items(): 26 | if not isinstance(v, list): 27 | raise ImproperlyConfigured( 28 | '%s fields["%s"] must be a list' % (classname, v) 29 | ) 30 | 31 | # check signals: 32 | if not isinstance(self.signals, dict): 33 | raise ImproperlyConfigured( 34 | '%s signals must be a dict' % classname 35 | ) 36 | 37 | for k, v in self.signals.items(): 38 | if not isinstance(v, list): 39 | raise ImproperlyConfigured( 40 | '%s signals["%s"] must be a list' % (classname, k) 41 | ) 42 | 43 | super(TriggerAction, self).__init__() 44 | 45 | @property 46 | def protected(self): 47 | return [ 48 | 'pre_init', 49 | 'post_init', 50 | 'post_syncdb', 51 | 'class_prepared', 52 | #'m2m_changed' 53 | ] 54 | 55 | def get_callbacks(self, signal_type=None, changed_fields=[]): 56 | callbacks = [] 57 | 58 | # check if there is actions registered to 59 | # this signal type 60 | if signal_type not in self.signals.keys(): 61 | return callbacks 62 | 63 | for func_name in self.signals[signal_type]: 64 | if func_name in self.fields.keys(): 65 | fields = set(self.fields[func_name]) 66 | if fields.intersection(changed_fields): 67 | func = getattr(self, func_name, None) 68 | if func: 69 | callbacks.append(func) 70 | 71 | return callbacks 72 | -------------------------------------------------------------------------------- /django_trigger/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Trigger models 3 | """ 4 | import logging 5 | 6 | from django.db.models import signals 7 | 8 | from django_trigger.diffmodelmixin import ModelDiffMixin 9 | from django_trigger.triggers import TriggerAction 10 | from django_trigger import signals as trigger_signals 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class TriggerModel(ModelDiffMixin): 17 | 18 | _triggers = TriggerAction() 19 | 20 | class Meta: 21 | abstract = True 22 | 23 | def dispatch_tracker(self, signal_type, *args, **kwargs): 24 | # changed field 25 | changed_fields = self.changed_fields 26 | if not changed_fields: 27 | return 28 | 29 | callbacks = self._triggers.get_callbacks(signal_type, changed_fields) 30 | 31 | for callback in callbacks: 32 | try: 33 | callback(*args, **kwargs) 34 | except Exception as e: 35 | msg = "%s : %s" % (callback.__name__, e) 36 | logger.error(msg) 37 | continue 38 | 39 | 40 | def subscribe_to_tracker(sender, **kwargs): 41 | if issubclass(sender, TriggerModel): 42 | for signal in sender._triggers.signals.keys(): 43 | 44 | # we don't handle pre_init 45 | if signal in sender._triggers.protected: 46 | continue 47 | 48 | if hasattr(signals, signal): 49 | # dispatch uid 50 | dispatch_uid = "{0}_trigger.{1}.{2}".format( 51 | signal, 52 | sender.__module__, 53 | sender.__name__ 54 | ) 55 | 56 | # signal 57 | sig = getattr(signals, signal) 58 | func = getattr(trigger_signals, 'trigger_%s' % signal, None) 59 | if func: 60 | sig.connect( 61 | func, 62 | sender=sender, 63 | weak=False, 64 | dispatch_uid=dispatch_uid 65 | ) 66 | 67 | # subscripe class_prepared signal 68 | signals.class_prepared.connect(subscribe_to_tracker) 69 | -------------------------------------------------------------------------------- /django_trigger/diffmodelmixin.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Aug 16, 2014 3 | Enable a DJango database object to be modified by a data form or batch process 4 | and only save to disk when data was actually changed. 5 | Based on http://stackoverflow.com/a/13842223 6 | Improvements: 7 | * removed python 2 compatibility to simplify code 8 | * proper handling of floating point 9 | @author: Stanley@stanleyknutson.com 10 | ''' 11 | from django.forms.models import model_to_dict 12 | from django.db import models 13 | from django.db.models.fields.files import FieldFile 14 | from decimal import Decimal 15 | 16 | 17 | class ModelDiffMixin(models.Model): 18 | """ 19 | A model mixin that tracks model fields' values and provide useful API 20 | to know what fields have been changed. 21 | 22 | The main value is to allow simply changing the values and then saving the 23 | object only if it is "really changed" 24 | """ 25 | 26 | def __init__(self, *args, **kwargs): 27 | super(ModelDiffMixin, self).__init__(*args, **kwargs) 28 | setattr(self, '__initial', self._dict) 29 | 30 | @property 31 | def diff(self): 32 | d1 = getattr(self, '__initial') 33 | d2 = self._dict 34 | # original version did not deal with 35 | # floating point rounding (decimal(9,6) in database) 36 | # diffs = [(k, (v, d2[k])) for k, v in d1.items() if v != d2[k]] 37 | diffs = {} 38 | for k, v1 in d1.items(): 39 | v2 = d2[k] 40 | if isinstance(v1, Decimal): 41 | v1 = float(v1) 42 | if isinstance(v2, Decimal): 43 | v2 = float(v2) 44 | elif isinstance(v2, float) or isinstance(v1, float): 45 | # CAUTION: we assume the field is stored in db 46 | # with 5 or more digits of precision 47 | # should really get the field definition and 48 | # find the number of digits 49 | change = self.is_float_changed(v1, v2) 50 | else: 51 | change = v1 != v2 52 | if change: 53 | diffs[k] = (v1, v2) 54 | 55 | return dict(diffs) 56 | 57 | def is_float_changed(self, v1, v2): 58 | ''' Compare two floating point or decimal values to the proper 59 | precision Default precision is 5 digits 60 | Override this method if all fload/decimal fields 61 | have fewer digits in database 62 | ''' 63 | return abs(round(v1 - v2, 5)) > 0.00001 64 | 65 | @property 66 | def has_changed(self): 67 | return bool(self.diff) 68 | 69 | @property 70 | def changed_fields(self): 71 | return self.diff.keys() 72 | 73 | def get_field_diff(self, field_name): 74 | """ 75 | Returns a diff for field if it's changed and None otherwise. 76 | """ 77 | return self.diff.get(field_name, None) 78 | 79 | # def save(self, *args, **kwargs): 80 | # """ 81 | # Saves model and set initial state. 82 | # """ 83 | # super(ModelDiffMixin, self).save(*args, **kwargs) 84 | # self.__initial = self._dict 85 | 86 | @property 87 | def _dict(self): 88 | object_dict = model_to_dict(self, fields=[field.name for field in 89 | self._meta.fields]) 90 | 91 | for field in object_dict: 92 | 93 | # for FileFields 94 | if issubclass(object_dict[field].__class__, FieldFile): 95 | try: 96 | object_dict[field] = object_dict[field].path 97 | except: 98 | object_dict[field] = object_dict[field].name 99 | 100 | return object_dict 101 | 102 | class Meta: 103 | abstract = True 104 | --------------------------------------------------------------------------------