Skip to content

Commit

Permalink
Merge f6c8318 into b73aae1
Browse files Browse the repository at this point in the history
  • Loading branch information
emil-balashov committed Dec 2, 2019
2 parents b73aae1 + f6c8318 commit 6b092a0
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 183 deletions.
2 changes: 1 addition & 1 deletion django_logic/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .process import Process
from .transition import Transition
from .commands import Permissions, Conditions, Command
from .commands import Permissions, Conditions, SideEffects, Callbacks
52 changes: 39 additions & 13 deletions django_logic/commands.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,54 @@
class BaseCommand:
def __init__(self, commands=None):
class BaseTransitionCommand(object):
"""
Command descriptor
"""
def __init__(self, commands=None, transition=None, *args, **kwargs):
self.commands = commands or []
self.transition = transition

def __get__(self, transition, owner):
if self.transition is None:
self.transition = transition
return self

def __set__(self, transition, commands):
if self.transition is None:
self.transition = transition
self.commands = commands

def __delete__(self, instance):
del self.commands

def execute(self, *args, **kwargs):
raise NotImplementedError


class CeleryCommand(BaseCommand):
def execute(self):
# TODO: https://github.com/Borderless360/django-logic/issues/1
pass
class SideEffects(BaseTransitionCommand):
def execute(self, instance: any, field_name):
try:
for command in self.commands:
command(instance)
except Exception:
self.transition.fail_transition(instance, field_name)
else:
self.transition.complete_transition(instance, field_name)


class Command(BaseCommand):
def execute(self, instance: any):
for command in self.commands:
command(instance)
class Callbacks(BaseTransitionCommand):
def execute(self, instance, field_name):
try:
for command in self.commands:
command(instance)
except Exception:
# TODO: logger
pass


class Conditions(BaseCommand):
class Conditions(BaseTransitionCommand):
def execute(self, instance: any):
return all(command(instance) for command in self.commands)


class Permissions(BaseCommand):
class Permissions(BaseTransitionCommand):
def execute(self, instance: any, user: any):
return all(command(instance, user) for command in self.commands)
return all(command(instance, user) for command in self.commands)
53 changes: 25 additions & 28 deletions django_logic/process.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import logging
from functools import partial


from django_logic.commands import Conditions, Permissions
from django_logic.exceptions import ManyTransitions, TransitionNotAllowed

logger = logging.getLogger(__name__)


class Process:
class Process(object):
"""
Process should be explicitly defined as a class and used as an object.
- process name
Expand All @@ -22,14 +22,14 @@ class Process:
states = []
nested_processes = []
transitions = []
conditions = None
permissions = None
conditions = Conditions()
permissions = Permissions()

def __init__(self, state_field: str, instance=None):
def __init__(self, field_name: str, instance=None):
"""
:param state_field:
:param field_name:
"""
self.state_field = state_field
self.field_name = field_name
self.instance = instance

def __get__(self, instance, owner):
Expand All @@ -38,15 +38,12 @@ def __get__(self, instance, owner):
return self

def __getattr__(self, item):
transitions = list(filter(
lambda transition: transition.action_name == item,
self.get_available_transitions()
))
transitions = list(self.get_available_transitions(action_name=item))

if len(transitions) == 1:
return partial(transitions[0].change_state,
instance=self.instance,
state_field=self.state_field)
field_name=self.field_name)

# This exceptions should be handled otherwise it will be very annoying
elif transitions:
Expand All @@ -65,35 +62,34 @@ def validate(self, user=None) -> bool:
:param user: any object used to pass permissions
:return: True or False
"""
if self.permissions is not None:
if not self.permissions.execute(self.instance, user):
return False

if self.conditions is not None:
if not self.conditions.execute(self.instance):
return False
return (self.permissions.execute(self.instance, user) and
self.conditions.execute(self.instance))

return True

def get_available_transitions(self, user=(None or any)):
def get_available_transitions(self, user=None, action_name=None):
"""
It returns all available transition which meet conditions and pass permissions.
Including nested processes.
:param action_name:
:param user: any object which used to validate permissions
:return: yield `django_logic.Transition`
"""
if not self.validate(user):
return

for transition in self.transitions:
state = getattr(self.instance, self.state_field)
if state in transition.sources and transition.validate(user):
state = getattr(self.instance, self.field_name) # TODO: get state from db
if action_name is not None and transition.action_name != action_name:
continue

if state in transition.sources and transition.validate(self.instance,
self.field_name,
user):
yield transition

for sub_process_class in self.nested_processes:
sub_process = sub_process_class(state_field=self.state_field,
instance=self.instance)
for transition in sub_process.get_available_transitions(user):
sub_process = sub_process_class(instance=self.instance, field_name=self.field_name)
for transition in sub_process.get_available_transitions(user=user,
action_name=action_name):
yield transition


Expand All @@ -105,5 +101,6 @@ def bind_state_fields(cls, **kwargs):
if not issubclass(process_class, Process):
raise TypeError('Must be a sub class of Process')
process_name = '{}_process'.format(state_field)
parameters[process_name] = process_class(state_field)
parameters[process_name] = property(lambda self: process_class(field_name=state_field,
instance=self))
return type('Process', (cls, ), parameters)
99 changes: 48 additions & 51 deletions django_logic/transition.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from django.core.cache import cache

from django_logic.commands import SideEffects, Callbacks, Permissions, Conditions
from django_logic.exceptions import TransitionNotAllowed


class Transition:
class Transition(object):
"""
Transition could be defined as a class or as an object and used as an object
- action name
Expand All @@ -13,86 +14,82 @@ class Transition:
- validation if the action is available throughout permissions and conditions
- run side effects and call backs
"""
side_effects = SideEffects()
callbacks = Callbacks()
permissions = Permissions()
conditions = Conditions()

def __init__(self, action_name, sources, target, **kwargs):
self.action_name = action_name
self.target = target
self.sources = sources
self.side_effects = kwargs.get('side_effects')
self.callbacks = kwargs.get('callbacks')
self.in_progress_state = kwargs.get('in_progress_state')
self.failed_state = kwargs.get('failed_state')
self.failure_handler = kwargs.get('failure_handler')
self.processing_state = kwargs.get('processing_state')
self.permissions = kwargs.get('permissions')
self.conditions = kwargs.get('conditions')
self.parent_process = None # initialised by process
self.side_effects = kwargs.get('side_effects', [])
self.callbacks = kwargs.get('callbacks', [])
self.permissions = kwargs.get('permissions', [])
self.conditions = kwargs.get('conditions', [])

def __str__(self):
return "Transition: {} to {}".format(self.action_name, self.target)

def validate(self, instance: any, user=None) -> bool:
def validate(self, instance: any, field_name: str, user=None) -> bool:
"""
It validates this process to meet conditions and pass permissions
:param field_name:
:param instance: any instance used to meet conditions
:param user: any object used to pass permissions
:return: True or False
"""
if self.permissions is not None:
if not self.permissions.execute(instance, user):
return False

if self.conditions is not None:
if not self.conditions().execute(instance):
return False

return True

def change_state(self, instance, state_field):
# TODO: consider adding the process as it also has side effects and callback (or remove them from it)
# run the conditions and permissions
# Lock state
# run side effects
# change state via transition to the next state
# run callbacks
if self._is_locked(instance, state_field):
raise TransitionNotAllowed("State is locked")
return (not self._is_locked(instance, field_name) and
self.permissions.execute(instance, user) and
self.conditions.execute(instance))

self._lock(instance, state_field)
# self.side_effects.add(success(self))
try:
self.side_effects.execute()
except Exception as ex:
pass

self._set_state(instance, state_field, self.target)
self._unlock(instance, state_field)

def _get_hash(self, instance, state_field):
def _get_hash(self, instance, field_name):
# TODO: https://github.com/Borderless360/django-logic/issues/3
return "{}-{}-{}-{}".format(instance._meta.app_label,
instance._meta.model_name,
state_field,
field_name,
instance.pk)

def _lock(self, instance, state_field: str):
cache.set(self._get_hash(instance, state_field), True)
def _lock(self, instance, field_name: str):
cache.set(self._get_hash(instance, field_name), True)

def _unlock(self, instance, state_field: str):
cache.delete(self._get_hash(instance, state_field))
def _unlock(self, instance, field_name: str):
cache.delete(self._get_hash(instance, field_name))

def _is_locked(self, instance, state_field: str):
return cache.get(self._get_hash(instance, state_field)) or False
def _is_locked(self, instance, field_name: str):
return cache.get(self._get_hash(instance, field_name)) or False

@staticmethod
def _get_db_state(instance, state_field):
def _get_db_state(self, instance, field_name):
"""
Fetches state directly from db instead of model instance.
"""
return instance._meta.model.objects.values_list(state_field, flat=True).get(pk=instance.id)
return instance._meta.model.objects.values_list(field_name, flat=True).get(pk=instance.id)

@staticmethod
def _set_state(instance, state_field, state):
def _set_state(self, instance, field_name, state):
"""
Sets intermediate state to instance's field until transition is over.
"""
# TODO: how would it work if it's used within another transaction?
instance._meta.model.objects.filter(pk=instance.id).update(**{state_field: state})
instance._meta.model.objects.filter(pk=instance.id).update(**{field_name: state})
instance.refresh_from_db()

def change_state(self, instance, field_name):
if self._is_locked(instance, field_name):
raise TransitionNotAllowed("State is locked")

self._lock(instance, field_name)
if self.in_progress_state:
self._set_state(instance, field_name, self.in_progress_state)
self.side_effects.execute(instance, field_name)

def complete_transition(self, instance, field_name):
self._set_state(instance, field_name, self.target)
self._unlock(instance, field_name)
self.callbacks.execute(instance, field_name)

def fail_transition(self, instance, field_name):
self._set_state(instance, field_name, self.failed_state)
self._unlock(instance, field_name)
4 changes: 3 additions & 1 deletion tests/app/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from django.db import models

from django_logic.process import ProcessManager
from app.process import InvoiceProcess

from django_logic.process import ProcessManager


class Invoice(ProcessManager.bind_state_fields(status=InvoiceProcess), models.Model):
status = models.CharField(choices=InvoiceProcess.states, max_length=16, blank=True)
is_available = models.BooleanField(default=True)

def __str__(self):
return self.status
Expand Down

0 comments on commit 6b092a0

Please sign in to comment.