Newer
Older
from typing import Iterable, Iterator, List, Sequence, Type, Union
from django.utils.text import slugify
This file defines classes which allow you to compose workflows based on the following structure:
Workflow -> Stage -> Phase -> Action
"""
Todd Dembrey
committed
def phase_name(stage: 'Stage', phase: Union['Phase', str], step: int) -> str:
# Build the identifiable name for a phase
if not isinstance(phase, str):
phase_name = phase._internal
else:
phase_name = phase
Todd Dembrey
committed
return '__'.join([stage.name, phase_name, str(step)])
A Workflow is a collection of Stages an application goes through. When a Stage is complete,
it will return the next Stage in the list or `None` if no such Stage exists.
name: str = ''
stage_classes: Sequence[Type['Stage']] = list()
def __init__(self, forms: Sequence[Form]) -> None:
if len(self.stage_classes) != len(forms):
raise ValueError('Number of forms does not equal the number of stages')
self.stages = [stage(form) for stage, form in zip(self.stage_classes, forms)]
def __iter__(self) -> Iterator['Phase']:
for stage in self.stages:
yield from stage
def current(self, current_phase: Union[str, 'Phase']) -> Union['Phase', None]:
if isinstance(current_phase, Phase):
return current_phase
if not current_phase:
return self.first()
for stage in self.stages:
phase = stage.get_phase(current_phase)
if phase:
return phase
def first(self) -> 'Phase':
return self.stages[0].next()
def process(self, current_phase: str, action: str) -> Union['Phase', None]:
phase = self.current(current_phase)
new_phase = phase.process(action)
if not new_phase:
new_stage = self.next_stage(phase.stage)
if new_stage:
return new_stage.first()
return new_phase
def next_stage(self, current_stage: 'Stage') -> 'Stage':
for i, stage in enumerate(self.stages):
if stage == current_stage:
try:
except IndexError:
pass
return None
def next(self, current_phase: Union[str, 'Phase']=None) -> Union['Phase', None]:
if not current_phase:
return self.first()
phase = self.current(current_phase)
for stage in self.stages:
if stage == phase.stage:
next_phase = stage.next(phase)
if not next_phase:
continue
return next_phase
next_stage = self.next_stage(phase.stage)
if next_stage:
return stage.next()
return None
def __str__(self) -> str:
return self.name
"""
Holds the Phases that are progressed through as part of the workflow process
"""
name: str = 'Stage'
phases: list = list()
def __init__(self, form: Form, name: str='') -> None:
if name:
self.name = name
# For OTF each stage is associated with a form submission
# So each time they start a stage they should submit new information
# TODO: consider removing form from stage as the stage is generic and
# shouldn't care about forms.
Todd Dembrey
committed
self.steps = len(self.phases)
# Make the phases new instances to prevent errors with mutability
Todd Dembrey
committed
self.phases = self.copy_phases(self.phases)
def copy_phases(self, phases):
new_phases = list()
for step, phase in enumerate(self.phases):
try:
new_phases.append(self.copy_phase(phase, step))
except AttributeError:
# We have a step with multiple equivalent phases
for sub_phase in phase:
new_phases.append(self.copy_phase(sub_phase, step))
return new_phases
def copy_phase(self, phase, step: int):
phase.stage = self
phase.step = step
return copy.copy(phase)
def __iter__(self) -> Iterator['Phase']:
yield from self.phases
def __str__(self) -> str:
return self.name
def get_phase(self, phase_name: str) -> 'Phase':
for phase in self.phases:
if str(phase) == phase_name:
return phase
return None
def first(self) -> 'Phase':
return self.phases[0]
def next(self, current_phase: 'Phase'=None) -> 'Phase':
if not current_phase:
return self.first()
for i, phase in enumerate(self.phases):
if phase == current_phase:
try:
except IndexError:
pass
return None
Holds the Actions which a user can perform at each stage. A Phase with no actions is
essentially locked
"""
actions: Sequence['Action'] = list()
def __init__(self, name: str='', public_name: str ='') -> None:
if public_name:
self.public_name = public_name
elif not self.public_name:
self.public_name = self.name
self._internal = slugify(self.name)
self._actions = {action.name: action for action in self.actions}
Todd Dembrey
committed
self.step : int = 0
def __eq__(self, other: Union[object, str]) -> bool:
if isinstance(other, str):
return str(self) == other
Todd Dembrey
committed
to_match = ['name', 'step']
return all(getattr(self, attr) == getattr(other, attr) for attr in to_match)
def action_names(self) -> List[str]:
return list(self._actions.keys())
def __str__(self) -> str:
Todd Dembrey
committed
return phase_name(self.stage, self, self.step)
def __getitem__(self, value: str) -> 'Action':
return self._actions[value]
def process(self, action: str) -> Union['Phase', None]:
return self[action].process(self)
Actions return the Phase within the current Stage which the workflow should progress to next.
A value of `None` will allow the Stage to progress.
"""
def __init__(self, name: str) -> None:
def process(self, phase: 'Phase') -> Union['Phase', None]:
# Use this to define the behaviour of the action
raise NotImplementedError
class ChangePhaseAction(Action):
def __init__(self, phase: Union['Phase', str], *args: str, **kwargs: str) -> None:
self.target_phase = phase
super().__init__(*args, **kwargs)
def process(self, phase: 'Phase') -> Union['Phase', None]:
if isinstance(self.target_phase, str):
return phase.stage.get_phase(phase_name(phase.stage, self.target_phase, 0))
# Change to the next action in the current Stage
def process(self, phase: 'Phase') -> Union['Phase', None]:
return phase.stage.next(phase)
reject_action = ChangePhaseAction('rejected', 'Reject')
accept_action = ChangePhaseAction('accepted', 'Accept')
progress_stage = ChangePhaseAction(None, 'Invite to Proposal')
next_phase = NextPhaseAction('Progress')
actions = [NextPhaseAction('Close Review')]
class DiscussionWithProgressionPhase(Phase):
actions = [progress_stage, reject_action]
class DiscussionPhase(Phase):
name = 'Under Discussion'
public_name = 'In review'
actions = [accept_action, reject_action]
class DiscussionWithNextPhase(Phase):
actions = [NextPhaseAction('Open Review'), reject_action]
rejected = Phase(name='Rejected')
accepted = Phase(name='Accepted')
class RequestStage(Stage):
name = 'Request'
phases = [
DiscussionWithNextPhase(),
ReviewPhase(),
DiscussionPhase(),
Todd Dembrey
committed
[accepted, rejected]
]
class ConceptStage(Stage):
name = 'Concept'
DiscussionWithNextPhase(),
Todd Dembrey
committed
[DiscussionWithProgressionPhase(), rejected]
class ProposalStage(Stage):
name = 'Proposal'
DiscussionWithNextPhase(),
DiscussionWithNextPhase(),
ReviewPhase('AC Review', public_name='In AC review'),
DiscussionPhase(public_name='In AC review'),
Todd Dembrey
committed
[accepted, rejected,]
class SingleStage(Workflow):
name = 'Single Stage'
stage_classes = [RequestStage]
class DoubleStage(Workflow):
name = 'Two Stage'
stage_classes = [ConceptStage, ProposalStage]
statuses = set(phase.name for phase in Phase.__subclasses__())
status_options = [(slugify(opt), opt) for opt in statuses]