From ec3fbe4577099f6fd893065ae1d10185f17cb689 Mon Sep 17 00:00:00 2001 From: Ahmet DAL Date: Wed, 16 Oct 2019 21:49:33 +0200 Subject: [PATCH] [#105] Handle hooks on DB --- river/apps.py | 9 - river/config.py | 5 - river/core/classworkflowobject.py | 14 -- river/core/instanceworkflowobject.py | 14 -- river/hooking/__init__.py | 1 - river/hooking/backends/__init__.py | 1 - river/hooking/backends/base.py | 31 ---- river/hooking/backends/database.py | 71 -------- river/hooking/backends/loader.py | 6 - river/hooking/backends/memory.py | 40 ----- river/hooking/completed.py | 16 -- river/hooking/hooking.py | 41 ----- river/hooking/transition.py | 28 --- river/migrations/0003_auto_20191015_1628.py | 86 +++++++++ river/models/__init__.py | 5 +- river/models/callback.py | 18 -- river/models/fields/state.py | 13 +- river/models/function.py | 51 ++++++ river/models/hook.py | 39 +++++ river/models/on_approved_hook.py | 13 ++ river/models/on_complete_hook.py | 6 + river/models/on_transit_hook.py | 14 ++ river/signals.py | 130 +++++++++----- river/step/__init__.py | 0 river/step/transition_step_executor.py | 33 ++++ river/tests/hooking/backends/__init__.py | 1 - .../test__database_hooking_backend.py | 151 ---------------- .../backends/test__memory_handler_backend.py | 96 ---------- river/tests/hooking/base_hooking_test.py | 84 +++++++++ river/tests/hooking/test__approved_hooking.py | 133 ++++++++++++++ .../tests/hooking/test__completed_hooking.py | 48 ++--- .../tests/hooking/test__transition_hooking.py | 164 ++++-------------- .../tests/models/test__transition_approval.py | 1 + setup.py | 2 +- 34 files changed, 606 insertions(+), 759 deletions(-) delete mode 100644 river/hooking/__init__.py delete mode 100644 river/hooking/backends/__init__.py delete mode 100644 river/hooking/backends/base.py delete mode 100644 river/hooking/backends/database.py delete mode 100644 river/hooking/backends/loader.py delete mode 100644 river/hooking/backends/memory.py delete mode 100644 river/hooking/completed.py delete mode 100644 river/hooking/hooking.py delete mode 100644 river/hooking/transition.py create mode 100644 river/migrations/0003_auto_20191015_1628.py delete mode 100644 river/models/callback.py create mode 100644 river/models/function.py create mode 100644 river/models/hook.py create mode 100644 river/models/on_approved_hook.py create mode 100644 river/models/on_complete_hook.py create mode 100644 river/models/on_transit_hook.py create mode 100644 river/step/__init__.py create mode 100644 river/step/transition_step_executor.py delete mode 100644 river/tests/hooking/backends/__init__.py delete mode 100644 river/tests/hooking/backends/test__database_hooking_backend.py delete mode 100644 river/tests/hooking/backends/test__memory_handler_backend.py create mode 100644 river/tests/hooking/base_hooking_test.py create mode 100644 river/tests/hooking/test__approved_hooking.py diff --git a/river/apps.py b/river/apps.py index 9cf80e1..4d71d2c 100644 --- a/river/apps.py +++ b/river/apps.py @@ -16,9 +16,6 @@ class RiverApp(AppConfig): def ready(self): - from river.hooking.backends.database import DatabaseHookingBackend - from river.hooking.backends.loader import callback_backend - for field_name in self._get_all_workflow_fields(): try: workflows = self.get_model('Workflow').objects.filter(field_name=field_name) @@ -27,12 +24,6 @@ def ready(self): except (OperationalError, ProgrammingError): pass - if isinstance(callback_backend, DatabaseHookingBackend): - try: - self.get_model('Callback').objects.exists() - callback_backend.initialize_callbacks() - except (OperationalError, ProgrammingError): - pass LOGGER.debug('RiverApp is loaded.') @classmethod diff --git a/river/config.py b/river/config.py index a3e4f9c..9c0104e 100644 --- a/river/config.py +++ b/river/config.py @@ -17,11 +17,6 @@ def load(self): self.USER_CLASS = getattr(settings, self.get_with_prefix('USER_CLASS'), settings.AUTH_USER_MODEL) self.PERMISSION_CLASS = getattr(settings, self.get_with_prefix('PERMISSION_CLASS'), Permission) self.GROUP_CLASS = getattr(settings, self.get_with_prefix('GROUP_CLASS'), Group) - self.HOOKING_BACKEND = getattr(settings, self.get_with_prefix('HOOKING_BACKEND'), {'backend': 'river.hooking.backends.database.DatabaseHookingBackend'}) - - # Generated - self.HOOKING_BACKEND_CLASS = self.HOOKING_BACKEND.get('backend') - self.HOOKING_BACKEND_CONFIG = self.HOOKING_BACKEND.get('config', {}) app_config = RiverConfig() diff --git a/river/core/classworkflowobject.py b/river/core/classworkflowobject.py index 6afba6f..175fe09 100644 --- a/river/core/classworkflowobject.py +++ b/river/core/classworkflowobject.py @@ -4,8 +4,6 @@ from django.db.models.functions import Cast from django_cte import With -from river.hooking.completed import PostCompletedHooking, PreCompletedHooking -from river.hooking.transition import PostTransitionHooking, PreTransitionHooking from river.models import State, TransitionApprovalMeta, TransitionApproval, PENDING, Workflow @@ -71,18 +69,6 @@ def final_states(self): final_approvals = TransitionApprovalMeta.objects.filter(workflow=self.workflow, children__isnull=True) return State.objects.filter(pk__in=final_approvals.values_list("destination_state", flat=True)) - def hook_post_transition(self, callback, *args, **kwargs): - PostTransitionHooking.register(callback, None, self.field_name, *args, **kwargs) - - def hook_pre_transition(self, callback, *args, **kwargs): - PreTransitionHooking.register(callback, None, self.field_name, *args, **kwargs) - - def hook_post_complete(self, callback): - PostCompletedHooking.register(callback, None, self.field_name) - - def hook_pre_complete(self, callback): - PreCompletedHooking.register(callback, None, self.field_name) - def _authorized_approvals(self, as_user): group_q = Q() for g in as_user.groups.all(): diff --git a/river/core/instanceworkflowobject.py b/river/core/instanceworkflowobject.py index fb15400..fa53c8e 100644 --- a/river/core/instanceworkflowobject.py +++ b/river/core/instanceworkflowobject.py @@ -7,8 +7,6 @@ from django.utils import timezone from river.config import app_config -from river.hooking.completed import PostCompletedHooking, PreCompletedHooking -from river.hooking.transition import PostTransitionHooking, PreTransitionHooking from river.models import TransitionApproval, PENDING, State, APPROVED, Workflow from river.signals import ApproveSignal, TransitionSignal, OnCompleteSignal from river.utils.error_code import ErrorCode @@ -141,18 +139,6 @@ def _transition_signal(self, has_transit, approval): def _on_complete_signal(self): return OnCompleteSignal(self.workflow_object, self.field_name) - def hook_post_transition(self, callback, *args, **kwargs): - PostTransitionHooking.register(callback, self.workflow_object, self.field_name, *args, **kwargs) - - def hook_pre_transition(self, callback, *args, **kwargs): - PreTransitionHooking.register(callback, self.workflow_object, self.field_name, *args, **kwargs) - - def hook_post_complete(self, callback): - PostCompletedHooking.register(callback, self.workflow_object, self.field_name) - - def hook_pre_complete(self, callback): - PreCompletedHooking.register(callback, self.workflow_object, self.field_name) - @property def _content_type(self): return ContentType.objects.get_for_model(self.workflow_object) diff --git a/river/hooking/__init__.py b/river/hooking/__init__.py deleted file mode 100644 index 0c9135a..0000000 --- a/river/hooking/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__author__ = 'ahmetdal' diff --git a/river/hooking/backends/__init__.py b/river/hooking/backends/__init__.py deleted file mode 100644 index 0c9135a..0000000 --- a/river/hooking/backends/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__author__ = 'ahmetdal' diff --git a/river/hooking/backends/base.py b/river/hooking/backends/base.py deleted file mode 100644 index 432626f..0000000 --- a/river/hooking/backends/base.py +++ /dev/null @@ -1,31 +0,0 @@ -from itertools import combinations, chain - -__author__ = 'ahmetdal' - - -def powerset(iterable): - xs = list(iterable) - # note we return an iterator rather than a list - return chain.from_iterable(combinations(xs, n) for n in range(len(xs) + 1)) - - -class BaseHookingBackend(object): - def register(self, hooking_cls, callback, workflow_object, field_name, override=False, *args, **kwargs): - raise NotImplementedError() - - def unregister(self, hooking_cls, workflow_object, field_name, *args, **kwargs): - raise NotImplementedError() - - def get_callbacks(self, hooking_cls, workflow_object, field_name, *args, **kwargs): - raise NotImplementedError() - - @staticmethod - def get_hooking_class(hooking_cls): - if isinstance(hooking_cls, str): - module, cls = hooking_cls.rsplit('.', 1) - hooking_cls = getattr(__import__(module, fromlist=[cls]), cls) - return hooking_cls - - @staticmethod - def get_hooking_class_prefix(hooking_cls): - return '%s.%s_' % (hooking_cls.__module__, hooking_cls.__name__) diff --git a/river/hooking/backends/database.py b/river/hooking/backends/database.py deleted file mode 100644 index b5c3efe..0000000 --- a/river/hooking/backends/database.py +++ /dev/null @@ -1,71 +0,0 @@ -import logging - -from river.hooking.backends.base import powerset -from river.hooking.backends.memory import MemoryHookingBackend -from river.models.callback import Callback - -__author__ = 'ahmetdal' - -LOGGER = logging.getLogger(__name__) - - -class DatabaseHookingBackend(MemoryHookingBackend): - - def initialize_callbacks(self): - self.__register(Callback.objects.filter(enabled=True)) - - def __register(self, callback_objs): - callbacks = [] - if callback_objs.exists(): - for callback in callback_objs: - if callback.hash not in self.callbacks: - module, method_name = callback.method.rsplit('.', 1) - try: - method = getattr(__import__(module, fromlist=[method_name]), method_name, None) - if method: - self.callbacks[callback.hash] = method - callbacks.append(method) - LOGGER.debug("Callback '%s' from database is registered initially from database as method '%s' and module '%s'. " % (callback.hash, method_name, module)) - else: - LOGGER.warning("Callback '%s' from database can not be registered. Because method '%s' is not in module '%s'. " % (callback.hash, method_name, module)) - except ImportError: - LOGGER.warning("Callback '%s' from database can not be registered. Because module '%s' does not exists. " % (callback.hash, module)) - return callbacks - - def register(self, hooking_cls, callback, workflow_object, field_name, override=False, *args, **kwargs): - callback_hash = super(DatabaseHookingBackend, self).register(hooking_cls, callback, workflow_object, field_name, override=override, *args, **kwargs) - callback_obj, created = Callback.objects.update_or_create( - hash=callback_hash, - defaults={ - 'method': '%s.%s' % (callback.__module__, callback.__name__), - 'hooking_cls': '%s.%s' % (hooking_cls.__module__, hooking_cls.__name__), - } - ) - if created: - LOGGER.debug("Callback '%s' is registered in database as method %s and module %s. " % (callback_obj.hash, callback.__name__, callback.__module__)) - else: - LOGGER.debug("Callback '%s' is already registered in database as method %s and module %s. " % (callback_obj.hash, callback.__name__, callback.__module__)) - - return callback_hash - - def unregister(self, hooking_cls, workflow_object, field_name, *args, **kwargs): - callback_hash, callback_method = super(DatabaseHookingBackend, self).unregister(hooking_cls, workflow_object, field_name, *args, **kwargs) - if callback_hash: - callback_obj = Callback.objects.filter(hash=callback_hash).first() - if callback_obj: - callback_obj.delete() - LOGGER.debug("Callback '%s' as method %s and module %s. is registered in database" % (callback_obj.hash, callback_method.__name__, callback_method.__module__)) - return callback_hash, callback_method - - def get_callbacks(self, hooking_cls, workflow_object, field_name, *args, **kwargs): - callbacks = super(DatabaseHookingBackend, self).get_callbacks(hooking_cls, workflow_object, field_name, *args, **kwargs) - if not callbacks: - hashes = [] - for c in powerset(kwargs.keys()): - skwargs = {} - for f in c: - skwargs[f] = kwargs.get(f) - hash = self.get_hooking_class(hooking_cls).get_hash(workflow_object, field_name, **skwargs) - hashes.append(self.get_hooking_class_prefix(self.get_hooking_class(hooking_cls)) + hash) - callbacks = self.__register(Callback.objects.filter(hash__in=hashes)) - return callbacks diff --git a/river/hooking/backends/loader.py b/river/hooking/backends/loader.py deleted file mode 100644 index c4d6768..0000000 --- a/river/hooking/backends/loader.py +++ /dev/null @@ -1,6 +0,0 @@ -from river.config import app_config - -__author__ = 'ahmetdal' - -handler_module, hooking_cls = app_config.HOOKING_BACKEND_CLASS.rsplit('.', 1) -callback_backend = getattr(__import__(handler_module, fromlist=[hooking_cls]), hooking_cls)(**app_config.HOOKING_BACKEND_CONFIG) diff --git a/river/hooking/backends/memory.py b/river/hooking/backends/memory.py deleted file mode 100644 index a82978d..0000000 --- a/river/hooking/backends/memory.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging -from river.hooking.backends.base import BaseHookingBackend, powerset - -__author__ = 'ahmetdal' - -LOGGER = logging.getLogger(__name__) - - -class MemoryHookingBackend(BaseHookingBackend): - def __init__(self): - self.callbacks = {} - - def register(self, hooking_cls, callback, workflow_object, field_name, override=False, *args, **kwargs): - callback_hash = self.get_hooking_class_prefix(hooking_cls) + hooking_cls.get_hash(workflow_object, field_name, *args, **kwargs) - if override or callback_hash not in self.callbacks: - self.callbacks[callback_hash] = callback - LOGGER.debug("Callback '%s'with method '%s' and module '%s' is registered from memory" % (callback_hash, callback.__name__, callback.__module__)) - return callback_hash - - def unregister(self, hooking_cls, workflow_object, field_name, *args, **kwargs): - callback_hash = self.get_hooking_class_prefix(hooking_cls) + hooking_cls.get_hash(workflow_object, field_name, *args, **kwargs) - - if callback_hash in self.callbacks: - callback_method = self.callbacks.pop(callback_hash) - LOGGER.debug("Callback '%s'with method '%s' and module '%s' is unregistered from memory. " % (callback_hash, callback_method.__name__, callback_method.__module__)) - return callback_hash, callback_method - else: - return None, None - - def get_callbacks(self, hooking_cls, workflow_object, field_name, *args, **kwargs): - callbacks = [] - for c in powerset(kwargs.keys()): - skwargs = {} - for f in c: - skwargs[f] = kwargs.get(f) - callback_hash = self.get_hooking_class(hooking_cls).get_hash(workflow_object, field_name, **skwargs) - callback = self.callbacks.get(self.get_hooking_class_prefix(self.get_hooking_class(hooking_cls)) + callback_hash) - if callback: - callbacks.append(callback) - return callbacks diff --git a/river/hooking/completed.py b/river/hooking/completed.py deleted file mode 100644 index dc0566c..0000000 --- a/river/hooking/completed.py +++ /dev/null @@ -1,16 +0,0 @@ -from river.hooking.hooking import Hooking -from river.signals import pre_on_complete, post_on_complete - -__author__ = 'ahmetdal' - - -class PreCompletedHooking(Hooking): - pass - - -class PostCompletedHooking(Hooking): - pass - - -pre_on_complete.connect(PreCompletedHooking.dispatch) -post_on_complete.connect(PostCompletedHooking.dispatch) diff --git a/river/hooking/hooking.py b/river/hooking/hooking.py deleted file mode 100644 index b2be055..0000000 --- a/river/hooking/hooking.py +++ /dev/null @@ -1,41 +0,0 @@ -import logging -from abc import abstractmethod - -from river.hooking.backends.loader import callback_backend - -__author__ = 'ahmetdal' - -LOGGER = logging.getLogger(__name__) - - -class Hooking(object): - - @staticmethod - def get_result_exclusions(): - return [] - - @classmethod - def dispatch(cls, workflow_object, field_name, *args, **kwargs): - LOGGER.debug("Hooking %s is dispatched for workflow object %s and field name %s" % (cls.__name__, workflow_object, field_name)) - kwargs.pop('signal', None) - kwargs.pop('sender', None) - - object_callbacks = callback_backend.get_callbacks(cls, workflow_object, field_name, *args, **kwargs) - class_callbacks = callback_backend.get_callbacks(cls, None, field_name, *args, **kwargs) - for callback in object_callbacks + class_callbacks: - exclusions = cls.get_result_exclusions() - callback(workflow_object, field_name, *args, **{k: v for k, v in kwargs.items() if k not in exclusions}) - LOGGER.debug( - "Hooking %s for workflow object %s and for field %s is found as method %s with args %s and kwargs %s" % (cls.__name__, workflow_object, field_name, callback.__name__, args, kwargs)) - - @classmethod - def register(cls, callback, workflow_object, field_name, override=False, *args, **kwargs): - callback_backend.register(cls, callback, workflow_object, field_name, override, *args, **kwargs) - - @classmethod - def unregister(cls, workflow_object, field_name, *args, **kwargs): - callback_backend.unregister(cls, workflow_object, field_name, *args, **kwargs) - - @classmethod - def get_hash(cls, workflow_object, field_name, *args, **kwargs): - return 'object' + (str(workflow_object.pk) if workflow_object else '') + '_field_name' + field_name diff --git a/river/hooking/transition.py b/river/hooking/transition.py deleted file mode 100644 index cc7b79c..0000000 --- a/river/hooking/transition.py +++ /dev/null @@ -1,28 +0,0 @@ -from river.hooking.hooking import Hooking -from river.signals import pre_transition, post_transition - -__author__ = 'ahmetdal' - - -class TransitionHooking(Hooking): - - @staticmethod - def get_result_exclusions(): - return ["source_state", "destination_state"] - - @classmethod - def get_hash(cls, workflow_object, field_name, source_state=None, destination_state=None, transition_approval=None, *args, **kwargs): - return super(TransitionHooking, cls).get_hash(workflow_object, field_name) + ('source_state' + str(source_state.pk) if source_state else '') + ( - 'destination_state' + str(destination_state.pk) if destination_state else '') + ('transition_approval' + str(transition_approval.pk) if transition_approval else '') - - -class PreTransitionHooking(TransitionHooking): - pass - - -class PostTransitionHooking(TransitionHooking): - pass - - -pre_transition.connect(PreTransitionHooking.dispatch) -post_transition.connect(PostTransitionHooking.dispatch) diff --git a/river/migrations/0003_auto_20191015_1628.py b/river/migrations/0003_auto_20191015_1628.py new file mode 100644 index 0000000..f84821f --- /dev/null +++ b/river/migrations/0003_auto_20191015_1628.py @@ -0,0 +1,86 @@ +# Generated by Django 2.1.4 on 2019-10-15 21:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('river', '0002_auto_20190920_1640'), + ] + + operations = [ + migrations.CreateModel( + name='Function', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date Created')), + ('date_updated', models.DateTimeField(auto_now=True, null=True, verbose_name='Date Updated')), + ('name', models.CharField(max_length=200, unique=True, verbose_name='Function Name')), + ('body', models.TextField(max_length=100000, verbose_name='Function Body')), + ('version', models.IntegerField(default=0, verbose_name='Function Version')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OnApprovedHook', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date Created')), + ('date_updated', models.DateTimeField(auto_now=True, null=True, verbose_name='Date Updated')), + ('object_id', models.PositiveIntegerField(blank=True, null=True)), + ('hook_type', models.CharField(choices=[('BEFORE', 'Before'), ('AFTER', 'After')], max_length=50, verbose_name='Status')), + ('callback_function', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='river_onapprovedhook_hooks', to='river.Function', verbose_name='Function')), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.ContentType')), + ('transition_approval_meta', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='on_approved_hooks', to='river.TransitionApprovalMeta', verbose_name='Transition Approval')), + ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='river_onapprovedhook_hooks', to='river.Workflow', verbose_name='Workflow')), + ], + ), + migrations.CreateModel( + name='OnCompleteHook', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date Created')), + ('date_updated', models.DateTimeField(auto_now=True, null=True, verbose_name='Date Updated')), + ('object_id', models.PositiveIntegerField(blank=True, null=True)), + ('hook_type', models.CharField(choices=[('BEFORE', 'Before'), ('AFTER', 'After')], max_length=50, verbose_name='Status')), + ('callback_function', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='river_oncompletehook_hooks', to='river.Function', verbose_name='Function')), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.ContentType')), + ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='river_oncompletehook_hooks', to='river.Workflow', verbose_name='Workflow')), + ], + ), + migrations.CreateModel( + name='OnTransitHook', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date Created')), + ('date_updated', models.DateTimeField(auto_now=True, null=True, verbose_name='Date Updated')), + ('object_id', models.PositiveIntegerField(blank=True, null=True)), + ('hook_type', models.CharField(choices=[('BEFORE', 'Before'), ('AFTER', 'After')], max_length=50, verbose_name='Status')), + ('callback_function', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='river_ontransithook_hooks', to='river.Function', verbose_name='Function')), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.ContentType')), + ('destination_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='on_transition_hook_as_destination', to='river.State', verbose_name='Next State')), + ('source_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='on_transition_hook_as_source', to='river.State', verbose_name='Source State')), + ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='river_ontransithook_hooks', to='river.Workflow', verbose_name='Workflow')), + ], + ), + migrations.DeleteModel( + name='Callback', + ), + migrations.AlterUniqueTogether( + name='ontransithook', + unique_together={('callback_function', 'workflow', 'source_state', 'destination_state', 'content_type', 'object_id')}, + ), + migrations.AlterUniqueTogether( + name='oncompletehook', + unique_together={('callback_function', 'workflow', 'content_type', 'object_id')}, + ), + migrations.AlterUniqueTogether( + name='onapprovedhook', + unique_together={('callback_function', 'workflow', 'transition_approval_meta', 'content_type', 'object_id')}, + ), + ] diff --git a/river/models/__init__.py b/river/models/__init__.py index 7543e2c..31b0eec 100644 --- a/river/models/__init__.py +++ b/river/models/__init__.py @@ -1,8 +1,11 @@ __author__ = 'ahmetdal' from .base_model import * -from .callback import * from .state import * from .workflow import * from .transitionapprovalmeta import * from .transitionapproval import * +from .function import * +from .on_approved_hook import * +from .on_transit_hook import * +from .on_complete_hook import * diff --git a/river/models/callback.py b/river/models/callback.py deleted file mode 100644 index 86b1fc2..0000000 --- a/river/models/callback.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.db import models -from django.utils.translation import ugettext_lazy as _ - -from river.models.base_model import BaseModel - -__author__ = 'ahmetdal' - - -class Callback(BaseModel): - class Meta: - app_label = 'river' - verbose_name = _("Callback") - verbose_name_plural = _("Callbacks") - - hash = models.CharField(_('Hash'), max_length=200, unique=True) - method = models.CharField(_('Callback Method'), max_length=200) - hooking_cls = models.CharField(_('HookingClass'), max_length=200) - enabled = models.BooleanField(_('Enabled'), default=True) diff --git a/river/models/fields/state.py b/river/models/fields/state.py index 61dc040..0e907d2 100644 --- a/river/models/fields/state.py +++ b/river/models/fields/state.py @@ -1,12 +1,13 @@ import logging +from django.contrib.contenttypes.models import ContentType from django.db.models import CASCADE from django.db.models.signals import post_save, post_delete from river.core.riverobject import RiverObject from river.core.workflowregistry import workflow_registry -from river.hooking.completed import PreCompletedHooking, PostCompletedHooking -from river.hooking.transition import PostTransitionHooking, PreTransitionHooking + +from river.models import OnApprovedHook, OnTransitHook, OnCompleteHook try: from django.contrib.contenttypes.fields import GenericRelation @@ -78,8 +79,6 @@ def _on_workflow_object_saved(sender, instance, created, *args, **kwargs): def _on_workflow_object_deleted(sender, instance, *args, **kwargs): - for field_name in instance.river.all_field_names(instance.__class__): - PreCompletedHooking.unregister(instance, field_name, *args, **kwargs) - PostCompletedHooking.unregister(instance, field_name, *args, **kwargs) - PreTransitionHooking.unregister(instance, field_name, *args, **kwargs) - PostTransitionHooking.unregister(instance, field_name, *args, **kwargs) + OnApprovedHook.objects.filter(object_id=instance.pk, content_type=ContentType.objects.get_for_model(instance.__class__)).delete() + OnTransitHook.objects.filter(object_id=instance.pk, content_type=ContentType.objects.get_for_model(instance.__class__)).delete() + OnCompleteHook.objects.filter(object_id=instance.pk, content_type=ContentType.objects.get_for_model(instance.__class__)).delete() diff --git a/river/models/function.py b/river/models/function.py new file mode 100644 index 0000000..03f4fb2 --- /dev/null +++ b/river/models/function.py @@ -0,0 +1,51 @@ +import inspect +import re + +from django.db import models +from django.db.models.signals import pre_save +from django.utils.translation import ugettext_lazy as _ + +from river.models import BaseModel + +loaded_functions = {} + + +class Function(BaseModel): + name = models.CharField(verbose_name=_("Function Name"), max_length=200, unique=True, null=False, blank=False) + body = models.TextField(verbose_name=_("Function Body"), max_length=100000, null=False, blank=False) + version = models.IntegerField(verbose_name=_("Function Version"), default=0) + + def get(self): + func = loaded_functions.get(self.name, None) + if not func or func["version"] != self.version: + func = {"function": self._load(), "version": self.version} + loaded_functions[self.pk] = func + return func["function"] + + def _load(self): + func_body = "def _wrapper(*args, **kwargs):\n" + for line in self.body.split("\n"): + func_body += "\t" + line + "\n" + func_body += "\thandle(*args,**kwargs)\n" + exec(func_body) + return eval("_wrapper") + + +def on_pre_save(sender, instance, *args, **kwargs): + instance.version += 1 + + +pre_save.connect(on_pre_save, Function) + + +def _normalize_callback(callback): + callback_str = inspect.getsource(callback).replace("def %s(" % callback.__name__, "def handle(") + space_size = callback_str.index('def handle(') + return re.sub(r'^\s{%s}' % space_size, '', inspect.getsource(callback).replace("def %s(" % callback.__name__, "def handle(")) + + +def create_function(callback): + return Function.objects.get_or_create( + name=callback.__module__ + "." + callback.__name__, + body=_normalize_callback(callback) + )[0] diff --git a/river/models/hook.py b/river/models/hook.py new file mode 100644 index 0000000..5db6783 --- /dev/null +++ b/river/models/hook.py @@ -0,0 +1,39 @@ +import logging + +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.db.models import PROTECT +from django.utils.translation import ugettext_lazy as _ + +from river.models import Workflow, GenericForeignKey, BaseModel +from river.models.function import Function + +BEFORE = "BEFORE" +AFTER = "AFTER" + +HOOK_TYPES = [ + (BEFORE, _('Before')), + (AFTER, _('After')), +] + +LOGGER = logging.getLogger(__name__) + + +class Hook(BaseModel): + class Meta: + abstract = True + + callback_function = models.ForeignKey(Function, verbose_name=_("Function"), related_name='%(app_label)s_%(class)s_hooks', on_delete=PROTECT) + workflow = models.ForeignKey(Workflow, verbose_name=_("Workflow"), related_name='%(app_label)s_%(class)s_hooks', on_delete=PROTECT) + + content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.SET_NULL) + object_id = models.PositiveIntegerField(blank=True, null=True) + workflow_object = GenericForeignKey('content_type', 'object_id') + + hook_type = models.CharField(_('Status'), choices=HOOK_TYPES, max_length=50) + + def execute(self, context): + try: + self.callback_function.get()(**context) + except Exception as e: + LOGGER.exception(e) diff --git a/river/models/on_approved_hook.py b/river/models/on_approved_hook.py new file mode 100644 index 0000000..b3d99bc --- /dev/null +++ b/river/models/on_approved_hook.py @@ -0,0 +1,13 @@ +from django.db import models +from django.db.models import CASCADE +from django.utils.translation import ugettext_lazy as _ + +from river.models import TransitionApprovalMeta +from river.models.hook import Hook + + +class OnApprovedHook(Hook): + class Meta: + unique_together = [('callback_function', 'workflow', 'transition_approval_meta', 'content_type', 'object_id')] + + transition_approval_meta = models.ForeignKey(TransitionApprovalMeta, verbose_name=_("Transition Approval"), related_name='on_approved_hooks', on_delete=CASCADE) diff --git a/river/models/on_complete_hook.py b/river/models/on_complete_hook.py new file mode 100644 index 0000000..c7fe137 --- /dev/null +++ b/river/models/on_complete_hook.py @@ -0,0 +1,6 @@ +from river.models.hook import Hook + + +class OnCompleteHook(Hook): + class Meta: + unique_together = [('callback_function', 'workflow', 'content_type', 'object_id')] diff --git a/river/models/on_transit_hook.py b/river/models/on_transit_hook.py new file mode 100644 index 0000000..2b581dc --- /dev/null +++ b/river/models/on_transit_hook.py @@ -0,0 +1,14 @@ +from django.db import models +from django.db.models import CASCADE +from django.utils.translation import ugettext_lazy as _ + +from river.models import State +from river.models.hook import Hook + + +class OnTransitHook(Hook): + class Meta: + unique_together = [('callback_function', 'workflow', 'source_state', 'destination_state', 'content_type', 'object_id')] + + source_state = models.ForeignKey(State, verbose_name=_("Source State"), related_name='on_transition_hook_as_source', on_delete=CASCADE) + destination_state = models.ForeignKey(State, verbose_name=_("Next State"), related_name='on_transition_hook_as_destination', on_delete=CASCADE) diff --git a/river/signals.py b/river/signals.py index 67823cc..a246702 100644 --- a/river/signals.py +++ b/river/signals.py @@ -1,7 +1,15 @@ import logging +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from django.dispatch import Signal +from river.models import Workflow +from river.models.hook import BEFORE, AFTER +from river.models.on_approved_hook import OnApprovedHook +from river.models.on_complete_hook import OnCompleteHook +from river.models.on_transit_hook import OnTransitHook + __author__ = 'ahmetdal' pre_on_complete = Signal(providing_args=["workflow_object", "field_name", ]) @@ -11,7 +19,7 @@ post_transition = Signal(providing_args=["workflow_object", "field_name", "source_state", "destination_state"]) pre_approve = Signal(providing_args=["workflow_object", "field_name", "transition_approval"]) -post_approve = Signal(providing_args=["workflow_object", "field_name", "transition_approval"]) +post_approve = Signal(providing_args=["workflow_object", "field_name", "transition_approval", "transition_approval_meta"]) LOGGER = logging.getLogger(__name__) @@ -22,60 +30,90 @@ def __init__(self, status, workflow_object, field_name, transition_approval): self.workflow_object = workflow_object self.field_name = field_name self.transition_approval = transition_approval + self.content_type = ContentType.objects.get_for_model(self.workflow_object.__class__) + self.workflow = Workflow.objects.get(content_type=self.content_type, field_name=self.field_name) def __enter__(self): if self.status: - pre_transition.send( - sender=TransitionSignal.__class__, - workflow_object=self.workflow_object, - field_name=self.field_name, - source_state=self.transition_approval.source_state, - destination_state=self.transition_approval.destination_state - ) + for hook in OnTransitHook.objects.filter( + (Q(object_id__isnull=True) | Q(object_id=self.workflow_object.pk, content_type=self.content_type)) & + Q( + workflow__field_name=self.field_name, + source_state=self.transition_approval.source_state, + destination_state=self.transition_approval.destination_state, + hook_type=BEFORE + ) + ): + hook.execute(self._get_context()) + LOGGER.debug("The signal that is fired right before the transition ( %s -> %s ) happened for %s" % ( self.transition_approval.source_state.label, self.transition_approval.destination_state.label, self.workflow_object)) def __exit__(self, type, value, traceback): if self.status: - post_transition.send( - sender=TransitionSignal.__class__, - workflow_object=self.workflow_object, - field_name=self.field_name, - source_state=self.transition_approval.source_state, - destination_state=self.transition_approval.destination_state, - transition_approval=self.transition_approval, - ) + for hook in OnTransitHook.objects.filter( + (Q(object_id__isnull=True) | Q(object_id=self.workflow_object.pk, content_type=self.content_type)) & + Q( + workflow=self.workflow, + source_state=self.transition_approval.source_state, + destination_state=self.transition_approval.destination_state, + hook_type=AFTER + ) + ): + hook.execute(self._get_context()) LOGGER.debug("The signal that is fired right after the transition ( %s -> %s ) happened for %s" % ( self.transition_approval.source_state.label, self.transition_approval.destination_state.label, self.workflow_object)) + def _get_context(self): + return { + "workflow": self.workflow, + "workflow_object": self.workflow_object, + "transition_approval": self.transition_approval + } + class ApproveSignal(object): def __init__(self, workflow_object, field_name, transition_approval): self.workflow_object = workflow_object self.field_name = field_name self.transition_approval = transition_approval + self.content_type = ContentType.objects.get_for_model(self.workflow_object.__class__) + self.workflow = Workflow.objects.get(content_type=self.content_type, field_name=self.field_name) def __enter__(self): - pre_approve.send( - sender=ApproveSignal.__class__, - workflow_object=self.workflow_object, - field_name=self.field_name, - transition_approval=self.transition_approval, - ) + for hook in OnApprovedHook.objects.filter( + (Q(object_id__isnull=True) | Q(object_id=self.workflow_object.pk, content_type=self.content_type)) & + Q( + workflow__field_name=self.field_name, + transition_approval_meta=self.transition_approval.meta, + hook_type=BEFORE + ) + ): + hook.execute(self._get_context()) + LOGGER.debug("The signal that is fired right before a transition approval is approved for %s due to transition %s -> %s" % ( self.workflow_object, self.transition_approval.source_state.label, self.transition_approval.destination_state.label)) def __exit__(self, type, value, traceback): - post_approve.send( - sender=ApproveSignal.__class__, - workflow_object=self.workflow_object, - field_name=self.field_name, - source_state=self.transition_approval.source_state, - destination_state=self.transition_approval.destination_state - ) + for hook in OnApprovedHook.objects.filter( + (Q(object_id__isnull=True) | Q(object_id=self.workflow_object.pk, content_type=self.content_type)) & + Q( + workflow__field_name=self.field_name, + transition_approval_meta=self.transition_approval.meta, + hook_type=AFTER + ) + ): + hook.execute(self._get_context()) LOGGER.debug("The signal that is fired right after a transition approval is approved for %s due to transition %s -> %s" % ( self.workflow_object, self.transition_approval.source_state.label, self.transition_approval.destination_state.label)) + def _get_context(self): + return { + "workflow": self.workflow, + "workflow_object": self.workflow_object, + "transition_approval": self.transition_approval + } + class OnCompleteSignal(object): def __init__(self, workflow_object, field_name): @@ -83,21 +121,35 @@ def __init__(self, workflow_object, field_name): self.field_name = field_name self.workflow = getattr(self.workflow_object.river, self.field_name) self.status = self.workflow.on_final_state + self.content_type = ContentType.objects.get_for_model(self.workflow_object.__class__) + self.workflow = Workflow.objects.get(content_type=self.content_type, field_name=self.field_name) def __enter__(self): if self.status: - pre_on_complete.send( - sender=OnCompleteSignal.__class__, - workflow_object=self.workflow_object, - field_name=self.field_name, - ) + for hook in OnCompleteHook.objects.filter( + (Q(object_id__isnull=True) | Q(object_id=self.workflow_object.pk, content_type=self.content_type)) & + Q( + workflow__field_name=self.field_name, + hook_type=BEFORE + ) + ): + hook.execute(self._get_context()) LOGGER.debug("The signal that is fired right before the workflow of %s is complete" % self.workflow_object) def __exit__(self, type, value, traceback): if self.status: - post_on_complete.send( - sender=OnCompleteSignal.__class__, - workflow_object=self.workflow_object, - field_name=self.field_name, - ) + for hook in OnCompleteHook.objects.filter( + (Q(object_id__isnull=True) | Q(object_id=self.workflow_object.pk, content_type=self.content_type)) & + Q( + workflow__field_name=self.field_name, + hook_type=AFTER + ) + ): + hook.execute(self._get_context()) LOGGER.debug("The signal that is fired right after the workflow of %s is complete" % self.workflow_object) + + def _get_context(self): + return { + "workflow": self.workflow, + "workflow_object": self.workflow_object, + } diff --git a/river/step/__init__.py b/river/step/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/river/step/transition_step_executor.py b/river/step/transition_step_executor.py new file mode 100644 index 0000000..7a82ce7 --- /dev/null +++ b/river/step/transition_step_executor.py @@ -0,0 +1,33 @@ +from abc import abstractmethod +from datetime import datetime + +from river.models.fields.state import classproperty +from river.models2.transition_step_execution import TransitionStepExecution + + +class TransitionStepExecutor(object): + + def __init__(self, transition, workflow_object, step): + self.transition = transition + self.workflow_object = workflow_object + assert isinstance(step.step_object, self.expected_step_type) + self.step = step + + @classproperty + @abstractmethod + def expected_step_type(self): + raise NotImplementedError() + + @abstractmethod + def _execute(self, transition_step_execution): + raise NotImplementedError() + + def execute(self): + transition_step_execution = TransitionStepExecution.objects.create( + transition_step=self.step, + workflow_object=self.workflow_object, + started_at=datetime.now() + ) + if self._execute(transition_step_execution): + transition_step_execution.is_done = True + transition_step_execution.save() diff --git a/river/tests/hooking/backends/__init__.py b/river/tests/hooking/backends/__init__.py deleted file mode 100644 index 0c9135a..0000000 --- a/river/tests/hooking/backends/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__author__ = 'ahmetdal' diff --git a/river/tests/hooking/backends/test__database_hooking_backend.py b/river/tests/hooking/backends/test__database_hooking_backend.py deleted file mode 100644 index ffd6a0f..0000000 --- a/river/tests/hooking/backends/test__database_hooking_backend.py +++ /dev/null @@ -1,151 +0,0 @@ -from django.contrib.contenttypes.models import ContentType -from django.test import TestCase -from hamcrest import is_not, assert_that, has_key, has_property, has_value, has_length, has_item - -from river.config import app_config -from river.hooking.backends.loader import callback_backend -from river.hooking.transition import PostTransitionHooking -from river.models.callback import Callback -from river.models.factories import WorkflowFactory, TransitionApprovalMetaFactory, StateObjectFactory, PermissionObjectFactory -from river.tests.models import BasicTestModel -from river.tests.models.factories import BasicTestModelObjectFactory - -__author__ = 'ahmetdal' - - -def test_callback(*args, **kwargs): - pass - - -# noinspection DuplicatedCode -class DatabaseHookingBackendTest(TestCase): - def setUp(self): - self.field_name = "my_field" - authorized_permission = PermissionObjectFactory() - - state1 = StateObjectFactory(label="state1") - state2 = StateObjectFactory(label="state2") - - content_type = ContentType.objects.get_for_model(BasicTestModel) - workflow = WorkflowFactory(initial_state=state1, content_type=content_type, field_name="my_field") - TransitionApprovalMetaFactory.create( - workflow=workflow, - source_state=state1, - destination_state=state2, - priority=0, - permissions=[authorized_permission] - ) - - app_config.HOOKING_BACKEND_CLASS = 'river.hooking.backends.database.DatabaseHookingBackend' - self.handler_backend = callback_backend - self.handler_backend.callbacks = {} - - def test_shouldRegisterAHooking(self): - workflow_objects = BasicTestModelObjectFactory.create_batch(2) - - hooking_hash = '%s.%s_object%s_field_name%s' % (PostTransitionHooking.__module__, PostTransitionHooking.__name__, workflow_objects[1].pk, self.field_name) - - assert_that(self.handler_backend.callbacks, is_not(has_key(hooking_hash))) - - self.handler_backend.register(PostTransitionHooking, test_callback, workflow_objects[1], self.field_name) - - assert_that(self.handler_backend.callbacks, has_key(hooking_hash)) - assert_that(self.handler_backend.callbacks, has_value(has_property("__name__", test_callback.__name__))) - - def test_shouldRegisterAHookingResilientlyToMultiProcessing(self): - workflow_objects = BasicTestModelObjectFactory.create_batch(2) - - from multiprocessing import Process, Queue - - assert_that(Callback.objects.all(), has_length(0)) - - self.handler_backend.register(PostTransitionHooking, test_callback, workflow_objects[1], self.field_name) - - assert_that(Callback.objects.all(), has_length(1)) - - def worker2(q): - second_handler_backend = callback_backend - handlers = second_handler_backend.get_callbacks(PostTransitionHooking, workflow_objects[1], self.field_name) - q.put([f.__name__ for f in handlers]) - - q = Queue() - p2 = Process(target=worker2, args=(q,)) - - p2.start() - - handlers = q.get(timeout=1) - - assert_that(handlers, has_length(1)) - assert_that(handlers, has_item(test_callback.__name__)) - - def test_shouldReturnTheRegisteredHooking(self): - workflow_objects = BasicTestModelObjectFactory.create_batch(2) - - self.handler_backend.register(PostTransitionHooking, test_callback, workflow_objects[1], self.field_name) - handlers = self.handler_backend.get_callbacks(PostTransitionHooking, workflow_objects[1], self.field_name) - assert_that(handlers, has_length(1)) - assert_that(handlers, has_item(has_property("__name__", test_callback.__name__))) - - def test_shouldReturnTheRegisteredHookingInMultiProcessing(self): - workflow_objects = BasicTestModelObjectFactory.create_batch(2) - - from multiprocessing import Process, Queue - - Callback.objects.update_or_create( - hash='%s.%s_object%s_field_name%s' % (PostTransitionHooking.__module__, PostTransitionHooking.__name__, workflow_objects[1].pk, self.field_name), - defaults={ - 'method': '%s.%s' % (test_callback.__module__, test_callback.__name__), - 'hooking_cls': '%s.%s' % (PostTransitionHooking.__module__, PostTransitionHooking.__name__), - } - ) - - def worker2(q): - handlers = self.handler_backend.get_callbacks(PostTransitionHooking, workflow_objects[1], self.field_name) - q.put([f.__name__ for f in handlers]) - - q = Queue() - p2 = Process(target=worker2, args=(q,)) - - p2.start() - - handlers = q.get(timeout=1) - assert_that(handlers, has_length(1)) - assert_that(handlers, has_item(test_callback.__name__)) - - def test_shouldUnregisterAHooking(self): - workflow_objects = BasicTestModelObjectFactory.create_batch(2) - - hooking_hash = '%s.%s_object%s_field_name%s' % (PostTransitionHooking.__module__, PostTransitionHooking.__name__, workflow_objects[1].pk, self.field_name) - - assert_that(self.handler_backend.callbacks, is_not(has_key(hooking_hash))) - - self.handler_backend.register(PostTransitionHooking, test_callback, workflow_objects[1], self.field_name) - - assert_that(self.handler_backend.callbacks, has_key(hooking_hash)) - assert_that(self.handler_backend.callbacks, has_value(has_property("__name__", test_callback.__name__))) - - self.handler_backend.unregister(PostTransitionHooking, workflow_objects[1], self.field_name) - - assert_that(self.handler_backend.callbacks, is_not(has_key(hooking_hash))) - assert_that(self.handler_backend.callbacks, is_not(has_value(has_property("__name__", test_callback.__name__)))) - - def test_shouldRemoveTheHookingAndCallbackFromDBWhenWorkflowObjectIsDeleted(self): - workflow_object = BasicTestModelObjectFactory().model - - hooking_hash = '%s.%s_object%s_field_name%s' % (PostTransitionHooking.__module__, PostTransitionHooking.__name__, workflow_object.pk, self.field_name) - - assert_that(self.handler_backend.callbacks, is_not(has_key(hooking_hash))) - assert_that(self.handler_backend.callbacks, is_not(has_value(has_property("__name__", test_callback.__name__)))) - - self.handler_backend.register(PostTransitionHooking, test_callback, workflow_object, self.field_name) - - assert_that(self.handler_backend.callbacks, has_key(hooking_hash)) - assert_that(self.handler_backend.callbacks, has_value(has_property("__name__", test_callback.__name__))) - assert_that(Callback.objects.filter(hash=hooking_hash), has_length(1)) - - workflow_object.delete() - - assert_that(self.handler_backend.callbacks, is_not(has_key(hooking_hash))) - assert_that(self.handler_backend.callbacks, is_not(has_value(has_property("__name__", test_callback.__name__)))) - - assert_that(Callback.objects.filter(hash=hooking_hash), has_length(0)) diff --git a/river/tests/hooking/backends/test__memory_handler_backend.py b/river/tests/hooking/backends/test__memory_handler_backend.py deleted file mode 100644 index 7cc2181..0000000 --- a/river/tests/hooking/backends/test__memory_handler_backend.py +++ /dev/null @@ -1,96 +0,0 @@ -from django.contrib.contenttypes.models import ContentType -from django.test import TestCase -from hamcrest import assert_that, has_value, is_not, has_key, has_property, has_length, has_item - -from river.config import app_config -from river.hooking.backends.loader import callback_backend -from river.hooking.transition import PostTransitionHooking -from river.models.factories import PermissionObjectFactory, StateObjectFactory, WorkflowFactory, TransitionApprovalMetaFactory -from river.tests.models import BasicTestModel -from river.tests.models.factories import BasicTestModelObjectFactory - -__author__ = 'ahmetdal' - - -def test_callback(*args, **kwargs): - pass - - -# noinspection DuplicatedCode -class MemoryHookingBackendTest(TestCase): - def setUp(self): - self.field_name = "my_field" - authorized_permission = PermissionObjectFactory() - - state1 = StateObjectFactory(label="state1") - state2 = StateObjectFactory(label="state2") - - content_type = ContentType.objects.get_for_model(BasicTestModel) - workflow = WorkflowFactory(initial_state=state1, content_type=content_type, field_name="my_field") - TransitionApprovalMetaFactory.create( - workflow=workflow, - source_state=state1, - destination_state=state2, - priority=0, - permissions=[authorized_permission] - ) - - app_config.HOOKING_BACKEND_CLASS = 'river.hooking.backends.memory.MemoryHookingBackend' - self.handler_backend = callback_backend - self.handler_backend.callbacks = {} - - def test_shouldRegisterAHooking(self): - workflow_objects = BasicTestModelObjectFactory.create_batch(2) - - hooking_hash = '%s.%s_object%s_field_name%s' % (PostTransitionHooking.__module__, PostTransitionHooking.__name__, workflow_objects[1].pk, self.field_name) - - assert_that(self.handler_backend.callbacks, is_not(has_key(hooking_hash))) - - self.handler_backend.register(PostTransitionHooking, test_callback, workflow_objects[1], self.field_name) - - assert_that(self.handler_backend.callbacks, has_key(hooking_hash)) - assert_that(self.handler_backend.callbacks, has_value(has_property("__name__", test_callback.__name__))) - - def test_shouldReturnTheRegisteredHooking(self): - workflow_object = BasicTestModelObjectFactory().model - - self.handler_backend.register(PostTransitionHooking, test_callback, workflow_object, self.field_name) - callbacks = self.handler_backend.get_callbacks(PostTransitionHooking, workflow_object, self.field_name) - - assert_that(callbacks, has_length(1)) - assert_that(callbacks, has_item(has_property("__name__", test_callback.__name__))) - - def test_shouldUnregisterAHooking(self): - workflow_objects = BasicTestModelObjectFactory.create_batch(2) - - hooking_hash = '%s.%s_object%s_field_name%s' % (PostTransitionHooking.__module__, PostTransitionHooking.__name__, workflow_objects[1].pk, self.field_name) - - assert_that(self.handler_backend.callbacks, is_not(has_key(hooking_hash))) - - self.handler_backend.register(PostTransitionHooking, test_callback, workflow_objects[1], self.field_name) - - assert_that(self.handler_backend.callbacks, has_key(hooking_hash)) - assert_that(self.handler_backend.callbacks, has_value(has_property("__name__", test_callback.__name__))) - - self.handler_backend.unregister(PostTransitionHooking, workflow_objects[1], self.field_name) - - assert_that(self.handler_backend.callbacks, is_not(has_key(hooking_hash))) - assert_that(self.handler_backend.callbacks, is_not(has_value(has_property("__name__", test_callback.__name__)))) - - def test_shouldRemoveTheHookingWhenWorkflowObjectIsDeleted(self): - workflow_object = BasicTestModelObjectFactory().model - - hooking_hash = '%s.%s_object%s_field_name%s' % (PostTransitionHooking.__module__, PostTransitionHooking.__name__, workflow_object.pk, self.field_name) - - assert_that(self.handler_backend.callbacks, is_not(has_key(hooking_hash))) - assert_that(self.handler_backend.callbacks, is_not(has_value(has_property("__name__", test_callback.__name__)))) - - self.handler_backend.register(PostTransitionHooking, test_callback, workflow_object, self.field_name) - - assert_that(self.handler_backend.callbacks, has_key(hooking_hash)) - assert_that(self.handler_backend.callbacks, has_value(has_property("__name__", test_callback.__name__))) - - workflow_object.delete() - - assert_that(self.handler_backend.callbacks, is_not(has_key(hooking_hash))) - assert_that(self.handler_backend.callbacks, is_not(has_value(has_property("__name__", test_callback.__name__)))) diff --git a/river/tests/hooking/base_hooking_test.py b/river/tests/hooking/base_hooking_test.py new file mode 100644 index 0000000..2933a39 --- /dev/null +++ b/river/tests/hooking/base_hooking_test.py @@ -0,0 +1,84 @@ +from uuid import uuid4 + +from django.test import TestCase + +from river.models import Function, OnTransitHook, OnApprovedHook, OnCompleteHook +from river.models.hook import BEFORE, AFTER + +callback_output = { + +} + +callback_method = """ +from river.tests.hooking.base_hooking_test import callback_output +def handle(*args, **kwargs): + print(kwargs) + callback_output['%s'] = { + "args": args, + "kwargs": kwargs + } +""" + + +class BaseHookingTest(TestCase): + + def setUp(self): + self.identifier = str(uuid4()) + self.callback_function = Function.objects.create(name=uuid4(), body=callback_method % self.identifier) + + def get_output(self): + return callback_output.get(self.identifier, None) + + def hook_pre_transition(self, workflow, source_state, destination_state, workflow_object=None): + OnTransitHook.objects.create( + workflow=workflow, + callback_function=self.callback_function, + source_state=source_state, + destination_state=destination_state, + hook_type=BEFORE, + workflow_object=workflow_object + ) + + def hook_post_transition(self, workflow, source_state, destination_state, workflow_object=None): + OnTransitHook.objects.create( + workflow=workflow, + callback_function=self.callback_function, + source_state=source_state, + destination_state=destination_state, + hook_type=AFTER, + workflow_object=workflow_object + ) + + def hook_pre_approve(self, workflow, transition_approval_meta, workflow_object=None): + OnApprovedHook.objects.create( + workflow=workflow, + callback_function=self.callback_function, + transition_approval_meta=transition_approval_meta, + hook_type=BEFORE, + workflow_object=workflow_object + ) + + def hook_post_approve(self, workflow, transition_approval_meta, workflow_object=None): + OnApprovedHook.objects.create( + workflow=workflow, + callback_function=self.callback_function, + transition_approval_meta=transition_approval_meta, + hook_type=AFTER, + workflow_object=workflow_object + ) + + def hook_pre_complete(self, workflow, workflow_object=None): + OnCompleteHook.objects.create( + workflow=workflow, + callback_function=self.callback_function, + hook_type=BEFORE, + workflow_object=workflow_object + ) + + def hook_post_complete(self, workflow, workflow_object=None): + OnCompleteHook.objects.create( + workflow=workflow, + callback_function=self.callback_function, + hook_type=AFTER, + workflow_object=workflow_object + ) diff --git a/river/tests/hooking/test__approved_hooking.py b/river/tests/hooking/test__approved_hooking.py new file mode 100644 index 0000000..aba5f1e --- /dev/null +++ b/river/tests/hooking/test__approved_hooking.py @@ -0,0 +1,133 @@ +from django.contrib.contenttypes.models import ContentType +from hamcrest import equal_to, assert_that, none, has_entry, all_of + +from river.models.factories import PermissionObjectFactory, UserObjectFactory, StateObjectFactory, WorkflowFactory, TransitionApprovalMetaFactory +from river.tests.hooking.base_hooking_test import BaseHookingTest +from river.tests.models import BasicTestModel +from river.tests.models.factories import BasicTestModelObjectFactory + +__author__ = 'ahmetdal' + + +# noinspection DuplicatedCode +class ApprovedHooking(BaseHookingTest): + + def test_shouldInvokeCallbackThatIsRegisteredWithInstanceWhenAnApprovingHappens(self): + authorized_permission = PermissionObjectFactory() + authorized_user = UserObjectFactory(user_permissions=[authorized_permission]) + + state1 = StateObjectFactory(label="state1") + state2 = StateObjectFactory(label="state2") + + content_type = ContentType.objects.get_for_model(BasicTestModel) + workflow = WorkflowFactory(initial_state=state1, content_type=content_type, field_name="my_field") + meta1 = TransitionApprovalMetaFactory.create( + workflow=workflow, + source_state=state1, + destination_state=state2, + priority=0, + permissions=[authorized_permission] + ) + + TransitionApprovalMetaFactory.create( + workflow=workflow, + source_state=state1, + destination_state=state2, + priority=1, + permissions=[authorized_permission] + ) + + workflow_object = BasicTestModelObjectFactory() + + self.hook_pre_approve(workflow, meta1, workflow_object=workflow_object.model) + + assert_that(self.get_output(), none()) + + assert_that(workflow_object.model.my_field, equal_to(state1)) + workflow_object.model.river.my_field.approve(as_user=authorized_user) + assert_that(workflow_object.model.my_field, equal_to(state1)) + + assert_that( + self.get_output(), has_entry( + "kwargs", + all_of( + has_entry(equal_to("workflow_object"), equal_to(workflow_object.model)), + has_entry(equal_to("transition_approval"), equal_to(meta1.transition_approvals.filter(priority=0).first())) + + ) + ) + ) + + workflow_object.model.river.my_field.approve(as_user=authorized_user) + assert_that(workflow_object.model.my_field, equal_to(state2)) + + assert_that( + self.get_output(), has_entry( + "kwargs", + all_of( + has_entry(equal_to("workflow_object"), equal_to(workflow_object.model)), + has_entry(equal_to("transition_approval"), equal_to(meta1.transition_approvals.filter(priority=0).first())) + + ) + ) + ) + + def test_shouldInvokeCallbackThatIsRegisteredWithoutInstanceWhenAnApprovingHappens(self): + authorized_permission = PermissionObjectFactory() + authorized_user = UserObjectFactory(user_permissions=[authorized_permission]) + + state1 = StateObjectFactory(label="state1") + state2 = StateObjectFactory(label="state2") + + content_type = ContentType.objects.get_for_model(BasicTestModel) + workflow = WorkflowFactory(initial_state=state1, content_type=content_type, field_name="my_field") + meta1 = TransitionApprovalMetaFactory.create( + workflow=workflow, + source_state=state1, + destination_state=state2, + priority=0, + permissions=[authorized_permission] + ) + + TransitionApprovalMetaFactory.create( + workflow=workflow, + source_state=state1, + destination_state=state2, + priority=1, + permissions=[authorized_permission] + ) + + workflow_object = BasicTestModelObjectFactory() + + self.hook_pre_approve(workflow, meta1) + + assert_that(self.get_output(), none()) + + assert_that(workflow_object.model.my_field, equal_to(state1)) + workflow_object.model.river.my_field.approve(as_user=authorized_user) + assert_that(workflow_object.model.my_field, equal_to(state1)) + + assert_that( + self.get_output(), has_entry( + "kwargs", + all_of( + has_entry(equal_to("workflow_object"), equal_to(workflow_object.model)), + has_entry(equal_to("transition_approval"), equal_to(meta1.transition_approvals.filter(priority=0).first())) + + ) + ) + ) + + workflow_object.model.river.my_field.approve(as_user=authorized_user) + assert_that(workflow_object.model.my_field, equal_to(state2)) + + assert_that( + self.get_output(), has_entry( + "kwargs", + all_of( + has_entry(equal_to("workflow_object"), equal_to(workflow_object.model)), + has_entry(equal_to("transition_approval"), equal_to(meta1.transition_approvals.filter(priority=0).first())) + + ) + ) + ) diff --git a/river/tests/hooking/test__completed_hooking.py b/river/tests/hooking/test__completed_hooking.py index d20b322..47ce2b2 100644 --- a/river/tests/hooking/test__completed_hooking.py +++ b/river/tests/hooking/test__completed_hooking.py @@ -1,20 +1,15 @@ from django.contrib.contenttypes.models import ContentType -from django.test import TestCase -from hamcrest import assert_that, equal_to, none +from hamcrest import assert_that, equal_to, has_entry, none from river.models.factories import PermissionObjectFactory, StateObjectFactory, WorkflowFactory, TransitionApprovalMetaFactory, UserObjectFactory +from river.tests.hooking.base_hooking_test import BaseHookingTest from river.tests.models import BasicTestModel from river.tests.models.factories import BasicTestModelObjectFactory -__author__ = 'ahmetdal' - # noinspection DuplicatedCode -class CompletedHookingTest(TestCase): - def setUp(self): - super(CompletedHookingTest, self).setUp() - - def test_shouldInvokeTheRegisteredViaInstanceApiCallBackWhenFlowIsCompleteForTheObject(self): +class CompletedHookingTest(BaseHookingTest): + def test_shouldInvokeCallbackThatIsRegisteredWithInstanceWhenFlowIsComplete(self): authorized_permission = PermissionObjectFactory() authorized_user = UserObjectFactory(user_permissions=[authorized_permission]) @@ -42,29 +37,21 @@ def test_shouldInvokeTheRegisteredViaInstanceApiCallBackWhenFlowIsCompleteForThe workflow_object = BasicTestModelObjectFactory() - self.test_args = None - self.test_kwargs = None - - def test_callback(*args, **kwargs): - self.test_args = args - self.test_kwargs = kwargs - - workflow_object.model.river.my_field.hook_post_complete(test_callback) - - assert_that(self.test_args, none()) + self.hook_post_complete(workflow, workflow_object=workflow_object.model) + assert_that(self.get_output(), none()) assert_that(workflow_object.model.my_field, equal_to(state1)) workflow_object.model.river.my_field.approve(as_user=authorized_user) assert_that(workflow_object.model.my_field, equal_to(state2)) - assert_that(self.test_args, none()) + assert_that(self.get_output(), none()) workflow_object.model.river.my_field.approve(as_user=authorized_user) assert_that(workflow_object.model.my_field, equal_to(state3)) - assert_that(self.test_args, equal_to((workflow_object.model, "my_field"))) + assert_that(self.get_output(), has_entry("kwargs", has_entry(equal_to("workflow_object"), equal_to(workflow_object.model)))) - def test_shouldInvokeTheRegisteredViaClassApiCallBackWhenFlowIsCompleteForTheObject(self): + def test_shouldInvokeCallbackThatIsRegisteredWithoutInstanceWhenFlowIsComplete(self): authorized_permission = PermissionObjectFactory() authorized_user = UserObjectFactory(user_permissions=[authorized_permission]) @@ -92,24 +79,15 @@ def test_shouldInvokeTheRegisteredViaClassApiCallBackWhenFlowIsCompleteForTheObj workflow_object = BasicTestModelObjectFactory() - self.test_args = None - self.test_kwargs = None - - def test_callback(*args, **kwargs): - self.test_args = args - self.test_kwargs = kwargs - - BasicTestModel.river.my_field.hook_post_complete(test_callback) - - assert_that(self.test_args, none()) + self.hook_post_complete(workflow) + assert_that(self.get_output(), none()) assert_that(workflow_object.model.my_field, equal_to(state1)) workflow_object.model.river.my_field.approve(as_user=authorized_user) assert_that(workflow_object.model.my_field, equal_to(state2)) - - assert_that(self.test_args, none()) + assert_that(self.get_output(), none()) workflow_object.model.river.my_field.approve(as_user=authorized_user) assert_that(workflow_object.model.my_field, equal_to(state3)) - assert_that(self.test_args, equal_to((workflow_object.model, "my_field"))) + assert_that(self.get_output(), has_entry("kwargs", has_entry(equal_to("workflow_object"), equal_to(workflow_object.model)))) diff --git a/river/tests/hooking/test__transition_hooking.py b/river/tests/hooking/test__transition_hooking.py index 254d9dd..a3c83bf 100644 --- a/river/tests/hooking/test__transition_hooking.py +++ b/river/tests/hooking/test__transition_hooking.py @@ -1,128 +1,17 @@ from django.contrib.contenttypes.models import ContentType -from django.test import TestCase -from hamcrest import equal_to, assert_that, none, has_entry +from hamcrest import equal_to, assert_that, has_entry, none, all_of from river.models import TransitionApproval from river.models.factories import PermissionObjectFactory, UserObjectFactory, StateObjectFactory, WorkflowFactory, TransitionApprovalMetaFactory +from river.tests.hooking.base_hooking_test import BaseHookingTest from river.tests.models import BasicTestModel from river.tests.models.factories import BasicTestModelObjectFactory -__author__ = 'ahmetdal' - # noinspection DuplicatedCode -class TransitionHooking(TestCase): - - def test_shouldInvokeTheRegisteredViaInstanceApiCallBackWhenATransitionHappens(self): - self.test_args = None - self.test_kwargs = None - - def test_callback(*args, **kwargs): - self.test_args = args - self.test_kwargs = kwargs - - authorized_permission = PermissionObjectFactory() - authorized_user = UserObjectFactory(user_permissions=[authorized_permission]) - - state1 = StateObjectFactory(label="state1") - state2 = StateObjectFactory(label="state2") - - content_type = ContentType.objects.get_for_model(BasicTestModel) - workflow = WorkflowFactory(initial_state=state1, content_type=content_type, field_name="my_field") - TransitionApprovalMetaFactory.create( - workflow=workflow, - source_state=state1, - destination_state=state2, - priority=0, - permissions=[authorized_permission] - ) - - TransitionApprovalMetaFactory.create( - workflow=workflow, - source_state=state1, - destination_state=state2, - priority=1, - permissions=[authorized_permission] - ) - - workflow_object = BasicTestModelObjectFactory() - - workflow_object.model.river.my_field.hook_post_transition(test_callback) - - assert_that(self.test_args, none()) - - assert_that(workflow_object.model.my_field, equal_to(state1)) - workflow_object.model.river.my_field.approve(as_user=authorized_user) - assert_that(workflow_object.model.my_field, equal_to(state1)) - - assert_that(self.test_args, none()) - - workflow_object.model.river.my_field.approve(as_user=authorized_user) - assert_that(workflow_object.model.my_field, equal_to(state2)) - assert_that(self.test_args, equal_to((workflow_object.model, "my_field"))) - - last_approval = TransitionApproval.objects.get(object_id=workflow_object.model.pk, source_state=state1, destination_state=state2, priority=1) - assert_that(self.test_kwargs, has_entry(equal_to("transition_approval"), equal_to(last_approval))) - - def test_shouldInvokeTheRegisteredViaClassApiCallBackWhenATransitionHappens(self): - self.test_args = None - self.test_kwargs = None - - def test_callback(*args, **kwargs): - self.test_args = args - self.test_kwargs = kwargs - - authorized_permission = PermissionObjectFactory() - authorized_user = UserObjectFactory(user_permissions=[authorized_permission]) - - state1 = StateObjectFactory(label="state1") - state2 = StateObjectFactory(label="state2") - - content_type = ContentType.objects.get_for_model(BasicTestModel) - workflow = WorkflowFactory(initial_state=state1, content_type=content_type, field_name="my_field") - TransitionApprovalMetaFactory.create( - workflow=workflow, - source_state=state1, - destination_state=state2, - priority=0, - permissions=[authorized_permission] - ) - - TransitionApprovalMetaFactory.create( - workflow=workflow, - source_state=state1, - destination_state=state2, - priority=1, - permissions=[authorized_permission] - ) - - workflow_object = BasicTestModelObjectFactory() - - BasicTestModel.river.my_field.hook_post_transition(test_callback) - - assert_that(self.test_args, none()) - - assert_that(workflow_object.model.my_field, equal_to(state1)) - workflow_object.model.river.my_field.approve(as_user=authorized_user) - assert_that(workflow_object.model.my_field, equal_to(state1)) - - assert_that(self.test_args, none()) - - workflow_object.model.river.my_field.approve(as_user=authorized_user) - assert_that(workflow_object.model.my_field, equal_to(state2)) - assert_that(self.test_args, equal_to((workflow_object.model, "my_field"))) - - last_approval = TransitionApproval.objects.get(object_id=workflow_object.model.pk, source_state=state1, destination_state=state2, priority=1) - assert_that(self.test_kwargs, has_entry(equal_to("transition_approval"), equal_to(last_approval))) - - def test_shouldInvokeTheRegisteredViaInstanceApiCallBackWhenASpecificTransitionHappens(self): - self.test_args = None - self.test_kwargs = None - - def test_callback(*args, **kwargs): - self.test_args = args - self.test_kwargs = kwargs +class TransitionHooking(BaseHookingTest): + def test_shouldInvokeCallbackThatIsRegisteredWithInstanceWhenTransitionHappens(self): authorized_permission = PermissionObjectFactory() authorized_user = UserObjectFactory(user_permissions=[authorized_permission]) @@ -150,31 +39,32 @@ def test_callback(*args, **kwargs): workflow_object = BasicTestModelObjectFactory() - workflow_object.model.river.my_field.hook_post_transition(test_callback, source_state=state2, destination_state=state3) + self.hook_post_transition(workflow, state2, state3, workflow_object=workflow_object.model) - assert_that(self.test_args, none()) + assert_that(self.get_output(), none()) assert_that(workflow_object.model.my_field, equal_to(state1)) workflow_object.model.river.my_field.approve(as_user=authorized_user) assert_that(workflow_object.model.my_field, equal_to(state2)) - assert_that(self.test_args, none()) + assert_that(self.get_output(), none()) workflow_object.model.river.my_field.approve(as_user=authorized_user) assert_that(workflow_object.model.my_field, equal_to(state3)) - assert_that(self.test_args, equal_to((workflow_object.model, "my_field"))) last_approval = TransitionApproval.objects.get(object_id=workflow_object.model.pk, source_state=state2, destination_state=state3) - assert_that(self.test_kwargs, has_entry(equal_to("transition_approval"), equal_to(last_approval))) - - def test_shouldInvokeTheRegisteredViaClassApiCallBackWhenASpecificTransitionHappens(self): - self.test_args = None - self.test_kwargs = None - - def test_callback(*args, **kwargs): - self.test_args = args - self.test_kwargs = kwargs + assert_that( + self.get_output(), has_entry( + "kwargs", + all_of( + has_entry(equal_to("workflow_object"), equal_to(workflow_object.model)), + has_entry(equal_to("transition_approval"), equal_to(last_approval)) + + ) + ) + ) + def test_shouldInvokeCallbackThatIsRegisteredWithoutInstanceWhenTransitionHappens(self): authorized_permission = PermissionObjectFactory() authorized_user = UserObjectFactory(user_permissions=[authorized_permission]) @@ -202,19 +92,27 @@ def test_callback(*args, **kwargs): workflow_object = BasicTestModelObjectFactory() - BasicTestModel.river.my_field.hook_post_transition(test_callback, source_state=state2, destination_state=state3) + self.hook_post_transition(workflow, state2, state3) - assert_that(self.test_args, none()) + assert_that(self.get_output(), none()) assert_that(workflow_object.model.my_field, equal_to(state1)) workflow_object.model.river.my_field.approve(as_user=authorized_user) assert_that(workflow_object.model.my_field, equal_to(state2)) - assert_that(self.test_args, none()) + assert_that(self.get_output(), none()) workflow_object.model.river.my_field.approve(as_user=authorized_user) assert_that(workflow_object.model.my_field, equal_to(state3)) - assert_that(self.test_args, equal_to((workflow_object.model, "my_field"))) last_approval = TransitionApproval.objects.get(object_id=workflow_object.model.pk, source_state=state2, destination_state=state3) - assert_that(self.test_kwargs, has_entry(equal_to("transition_approval"), equal_to(last_approval))) + assert_that( + self.get_output(), has_entry( + "kwargs", + all_of( + has_entry(equal_to("workflow_object"), equal_to(workflow_object.model)), + has_entry(equal_to("transition_approval"), equal_to(last_approval)) + + ) + ) + ) diff --git a/river/tests/models/test__transition_approval.py b/river/tests/models/test__transition_approval.py index 4fa60d7..2493dd5 100644 --- a/river/tests/models/test__transition_approval.py +++ b/river/tests/models/test__transition_approval.py @@ -5,6 +5,7 @@ from river.models import TransitionApproval, APPROVED from river.models.factories import WorkflowFactory, StateObjectFactory, TransitionApprovalMetaFactory + from river.tests.models import BasicTestModel from river.tests.models.factories import BasicTestModelObjectFactory diff --git a/setup.py b/setup.py index 9c4fe80..027f5c7 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ setup( name='django-river', - version='2.0.0', + version='2.1.0', author='Ahmet DAL', author_email='ceahmetdal@gmail.com', packages=find_packages(),