Skip to content
Snippets Groups Projects
workflow.py 18.7 KiB
Newer Older
  • Learn to ignore specific revisions
  • from collections import defaultdict, namedtuple
    
    from typing import Dict, Iterable, Iterator, List, Sequence, Set, Type, Union, TYPE_CHECKING
    
    from django.utils.text import slugify
    
    if TYPE_CHECKING:
        from opentech.apply.users.models import User  # NOQA
    
    class Permission:
        def can_edit(self, user: 'User') -> bool:
            return False
    
        def can_staff_review(self, user: 'User') -> bool:
            return False
    
        def can_reviewer_review(self, user: 'User') -> bool:
            return False
    
        def can_review(self, user: 'User') -> bool:
            return self.can_staff_review(user) or self.can_reviewer_review(user)
    
    
    class StaffReviewPermission(Permission):
        def can_staff_review(self, user: 'User') -> bool:
            return user.is_apply_staff
    
    
    class ReviewerReviewPermission(Permission):
        def can_reviewer_review(self, user: 'User') -> bool:
            return user.is_reviewer
    
    
    class CanEditPermission(Permission):
        def can_edit(self, user: 'User') -> bool:
            return True
    
    
    
    Stage = namedtuple('Stage', ['name', 'has_external_review'])
    
    Request = Stage('Request', False)
    
    
    Concept = Stage('Concept', False)
    
    Proposal = Stage('Proposal', False)
    
    
    
    INITAL_STATE = 'in_discussion'
    
    SingleStage = {
    
            'transitions': {
                'internal_review' : 'Open Review',
                'rejected' : 'Reject',
            },
            'display': 'Under Discussion',
            'stage': Request,
            'permissions': Permission(),
            'step': 0,
        },
        'internal_review' : {
            'transitions': {
    
                'post_review_discussion' : 'Close Review',
    
            },
            'display': 'Internal Review',
            'stage': Request,
            'permissions': StaffReviewPermission(),
            'step': 1,
        },
        'post_review_discussion': {
            'transitions': {
                'accepted': 'Accept',
                'rejected': 'Reject',
            },
            'display': 'Under Discussion',
            'stage': Request,
            'permissions': Permission(),
            'step': 2,
        },
        'accepted': {
            'display': 'Accepted',
            'stage': Request,
            'permissions': Permission(),
            'step': 3,
        },
        'rejected': {
            'display': 'Rejected',
            'stage': Request,
            'permissions': Permission(),
            'step': 3,
        },
    }
    
    DoubleStage = {
    
            'transitions': {
    
                'concept_internal_review' : 'Open Review',
                'concept_rejected' : 'Reject',
    
            },
            'display': 'Under Discussion',
    
            'permissions': Permission(),
            'step': 0,
        },
    
        'concept_internal_review' : {
            'transitions': {
                'concept_review_discussion' : 'Close Review',
            },
            'display': 'Internal Review',
            'stage': Concept,
            'permissions': StaffReviewPermission(),
            'step': 1,
        },
        'concept_review_discussion': {
            'transitions': {
                'invite_to_proposal': 'Invite to Proposal',
                'concept_rejected': 'Reject',
            },
            'display': 'Under Discussion',
            'stage': Concept,
            'permissions': Permission(),
            'step': 2,
        },
        'concept_rejected': {
            'display': 'Rejected',
            'stage': Concept,
            'permissions': Permission(),
            'step': 3,
        },
        'invited_to_proposal': {
            'transitions': {
                'proposal_discussion' : 'Submit',
            },
            'display': 'Invited for Proposal',
            'stage': Proposal,
            'permissions': Permission(),
            'step': 3,
        },
        'proposal_discussion' : {
            'transitions': {
                'proposal_internal_review' : 'Open Review',
            },
            'display': 'Under Discussion',
            'stage': Proposal,
            'permissions': Permission(),
            'step': 4,
        },
        'proposal_internal_review' : {
            'transitions': {
                'post_proposal_review_discussion' : 'Close Review',
            },
            'display': 'Internal Review',
            'stage': Proposal,
            'permissions': StaffReviewPermission(),
            'step': 5,
        },
        'post_proposal_review_discussion': {
            'transitions': {
                'external_review': 'Open AC review',
                'proposal_rejected': 'Reject',
            },
            'display': 'Under Discussion',
            'stage': Proposal,
            'permissions': ReviewerReviewPermission(),
            'step': 6,
        },
        'external_review': {
            'transitions': {
                'post_external_review_discussion': 'Close Review',
            },
            'display': 'Advisory Council Review',
            'stage': Proposal,
            'permissions': Permission(),
            'step': 7,
        },
        'post_external_review_discussion': {
            'transitions': {
                'proposal_accepted': 'Accept',
                'proposal_rejected': 'Reject',
            },
            'display': 'Under Discussion',
            'stage': Proposal,
            'permissions': Permission(),
            'step': 8,
        },
        'proposal_accepted': {
            'display': 'Accepted',
            'stage': Proposal,
            'permissions': Permission(),
            'step': 9,
        },
        'proposal_rejected': {
            'display': 'Rejected',
            'stage': Proposal,
            'permissions': Permission(),
            'step': 9,
        },
    
    
    }
    
    
    status_options = [(key, value['display']) for key, value in SingleStage.items()]
    
    
    This file defines classes which allow you to compose workflows based on the following structure:
    
    
    Workflow -> Stage -> Phase -> Action
    
    
    These classes are designed such that they can be mapped to a wagtail streamfield to allow admins
    to build/adjust workflows as required.
    
    Current limitations:
    * Changing the name of a phase will mean that any object which references it cannot progress. [will
    be fixed when streamfield, may require intermediate fix prior to launch]
    * Outcomes are a special case of phase and perhaps should be handled separately. [will look at when
    progressing stages]
    
    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
    
    
        return '__'.join([stage._internal, phase_name, str(step)])
    
    class Workflow(Iterable):
    
        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) -> None:
            self.stages = [stage(self) for stage in self.stage_classes]
    
        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
    
    
            stage_name, _, _ = current_phase.split('__')
            for stage in self.stages:
                if stage.name == stage_name:
                    # Fall back to the first phase of the stage
                    return stage.first()
    
    
        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:
    
    Todd Dembrey's avatar
    Todd Dembrey committed
                        return self.stages[i + 1]
    
                    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:
    
    class Stage(Iterable):
    
        """
        Holds the Phases that are progressed through as part of the workflow process
        """
    
        name: str = 'Stage'
        phases: list = list()
    
    
        def __init__(self, workflow: 'Workflow', name: str='') -> None:
    
            if name:
                self.name = name
    
            # Make the phases new instances to prevent errors with mutability
    
        def __eq__(self, other: object) -> bool:
    
            if isinstance(other, Stage):
                return self.name == other.name
    
            return super().__eq__(other)
    
    
        def __lt__(self, other: object) -> bool:
            if isinstance(other, Stage):
                return self.workflow.stages.index(self) < self.workflow.stages.index(other)
            return False
    
    Todd Dembrey's avatar
    Todd Dembrey committed
        def copy_phases(self, phases: List['Phase']) -> List['Phase']:
    
            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
    
    
    Todd Dembrey's avatar
    Todd Dembrey committed
        def copy_phase(self, phase: 'Phase', step: int) -> 'Phase':
    
            phase.stage = self
            phase.step = step
            return copy.copy(phase)
    
    Todd Dembrey's avatar
    Todd Dembrey committed
        def __iter__(self) -> 'PhaseIterator':
    
            return PhaseIterator(self.phases, self.steps)
    
        def __str__(self) -> str:
    
        def get_phase(self, phase_name: str) -> 'Phase':
    
                if phase == phase_name:
    
    
            # We don't have the exact name
            for phase in self.phases:
                if phase._internal == phase_name:
                    # Grab the first phase to match the 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:
    
                    else:
                        if next_phase.step != phase.step:
                            return next_phase
    
    Todd Dembrey's avatar
    Todd Dembrey committed
    class PhaseIterator(Iterator):
    
            """Allow handling phases which are equivalent e.g. outcomes (accepted/rejected)
            Delegates to the underlying phases except where naming is concerned
            """
    
    Todd Dembrey's avatar
    Todd Dembrey committed
            def __init__(self, phases: List['Phase']) -> None:
    
                self.phases = phases
    
    
            def __lt__(self, other: object) -> bool:
    
                return all(phase < other for phase in self.phases)
    
    
    Todd Dembrey's avatar
    Todd Dembrey committed
            def step(self) -> int:
    
                return self.phases[0].step
    
            @property
    
    Todd Dembrey's avatar
    Todd Dembrey committed
            def name(self) -> str:
    
                # Hardcode a name for multi-phased step - always outcome at the moment
    
                if len(self.phases) > 1:
                    return 'Outcome'
                return self.phases[0].name
    
    
    Todd Dembrey's avatar
    Todd Dembrey committed
            def __eq__(self, other: object) -> bool:
    
                return any(phase == other for phase in self.phases)
    
    
    Todd Dembrey's avatar
    Todd Dembrey committed
        def __init__(self, phases: List['Phase'], steps: int) -> None:
    
            self.current = 0
    
    Todd Dembrey's avatar
    Todd Dembrey committed
            self.phases: Dict[int, List['Phase']] = defaultdict(list)
    
            for phase in phases:
                self.phases[phase.step].append(phase)
            self.steps = steps
    
    
    Todd Dembrey's avatar
    Todd Dembrey committed
        def __iter__(self) -> 'PhaseIterator':
    
    Todd Dembrey's avatar
    Todd Dembrey committed
        def __next__(self) -> 'Step':
    
            self.current += 1
            if self.current > self.steps:
                raise StopIteration
            return self.Step(self.phases[self.current - 1])
    
    
    
    Todd Dembrey's avatar
    Todd Dembrey committed
    class Phase:
    
        Holds the Actions which a user can perform at each stage. A Phase with no actions is
    
        actions: Sequence['Action'] = list()
    
        name: str = ''
    
        public_name: str = ''
    
        permissions: 'Permission' = Permission()
    
        def __init__(self, name: str='', public_name: str ='', active: bool=True, can_proceed: bool=False, permissions: Permission=None) -> None:
    
            if name:
                self.name = name
    
            if permissions:
                self.permissions = permissions
    
    
            if public_name:
                self.public_name = public_name
            elif not self.public_name:
                self.public_name = self.name
    
    
            self._internal = slugify(self.name)
    
            self.stage: Union['Stage', None] = None
    
            self._actions = {action.name: action for action in self.actions}
    
    Todd Dembrey's avatar
    Todd Dembrey committed
            self.step: int = 0
    
        def __eq__(self, other: Union[object, str]) -> bool:
            if isinstance(other, str):
                return str(self) == other
    
            to_match = ['stage', 'name', 'step']
    
            return all(getattr(self, attr) == getattr(other, attr) for attr in to_match)
    
    
        def __lt__(self, other: object) -> bool:
            if isinstance(other, Phase):
                if self.stage < other.stage:
                    return True
                return self.step < other.step and self.stage == other.stage
            return False
    
        def action_names(self) -> List[str]:
    
            return list(self._actions.keys())
    
    
        def __str__(self) -> str:
    
            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)
    
        def has_perm(self, user: 'User', perm: str) -> bool:
    
    Dan Braghis's avatar
    Dan Braghis committed
            perm_method = getattr(self.permissions, f'can_{perm}', lambda x: False)
    
    Todd Dembrey's avatar
    Todd Dembrey committed
    class Action:
    
        Base Action class.
    
        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:
    
    Todd Dembrey's avatar
    Todd Dembrey committed
            self.name = name
    
    
        def process(self, phase: 'Phase') -> Union['Phase', None]:
    
            # Use this to define the behaviour of the action
            raise NotImplementedError
    
    
    class ChangePhaseAction(Action):
    
        # Change to a specific Phase
    
        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):
    
    Todd Dembrey's avatar
    Todd Dembrey committed
                return phase.stage.get_phase(self.target_phase)
    
            return self.target_phase
    
    class NextPhaseAction(Action):
    
        # Change to the next action in the current Stage
    
        def process(self, phase: 'Phase') -> Union['Phase', None]:
            return phase.stage.next(phase)
    
    
    
    # --- OTF Workflow ---
    
    
    
    reject_action = ChangePhaseAction('rejected', 'Reject')
    
    accept_action = ChangePhaseAction('accepted', 'Accept')
    
    
    progress_stage = ChangePhaseAction('invited-to-proposal', 'Invite to Proposal')
    
    next_phase = NextPhaseAction('Progress')
    
    
    class InDraft(Phase):
        name = 'Invited for Proposal'
        public_name = 'In draft'
        actions = [NextPhaseAction('Submit')]
    
        permissions = CanEditPermission()
    
    class ReviewPhase(Phase):
    
        name = 'Internal Review'
    
        public_name = 'In review'
    
        actions = [NextPhaseAction('Close Review')]
    
        permissions = StaffReviewPermission()
    
    class DiscussionWithProgressionPhase(Phase):
    
        name = 'Under Discussion'
    
        public_name = 'In review'
    
        actions = [progress_stage, reject_action]
    
    
    
        name = 'Under Discussion'
        public_name = 'In review'
        actions = [accept_action, reject_action]
    
    
    
    class DiscussionWithNextPhase(Phase):
    
        name = 'Under Discussion'
    
        public_name = 'In review'
    
        actions = [NextPhaseAction('Open Review'), reject_action]
    
    rejected = Phase(name='Rejected', active=False)
    
    accepted = Phase(name='Accepted', active=False)
    
    progressed = Phase(name='Invited to Proposal', active=False, can_proceed=True)
    
    class RequestStage(Stage):
        name = 'Request'
    
        has_external_review = False
    
        phases = [
            DiscussionWithNextPhase(),
            ReviewPhase(),
            DiscussionPhase(),
    
    class ConceptStage(Stage):
    
        has_external_review = False
    
    Todd Dembrey's avatar
    Todd Dembrey committed
        phases = [
    
    Todd Dembrey's avatar
    Todd Dembrey committed
            ReviewPhase(),
    
            DiscussionWithProgressionPhase(),
            [progressed, rejected],
    
    
    
    class ProposalStage(Stage):
        name = 'Proposal'
    
        has_external_review = True
    
    Todd Dembrey's avatar
    Todd Dembrey committed
        phases = [
    
    Todd Dembrey's avatar
    Todd Dembrey committed
            ReviewPhase(),
    
            ReviewPhase('AC Review', public_name='In AC review', permissions=ReviewerReviewPermission()),
    
            DiscussionPhase(public_name='In AC review'),
    
    Todd Dembrey's avatar
    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]
    
    # def get_active_statuses() -> Set[str]:
    #     active = set()
    
    #     def add_if_active(phase: 'Phase') -> None:
    #         if phase.active:
    #             active.add(str(phase))
    
    #     for phase in itertools.chain(SingleStage(), DoubleStage()):
    #         try:
    #             add_if_active(phase)
    #         except AttributeError:
    #             # it is actually a step
    #             step = phase
    #             for phase in step.phases:
    #                 add_if_active(phase)
    #     return active
    
    active_statuses = [] #get_active_statuses()
    
    def get_review_statuses(user: Union[None, 'User']=None) -> Set[str]:
    
        return []
    #     reviews = set()
    
    #     for step in itertools.chain(SingleStage(), DoubleStage()):
    #         for phase in step.phases:
    #             if isinstance(phase, ReviewPhase):
    #                 if user is None:
    #                     reviews.add(str(phase))
    #                 elif phase.has_perm(user, 'review'):
    #                     reviews.add(str(phase))
    
    #     return reviews
    
    review_statuses = [] #get_review_statuses()