Skip to content
Snippets Groups Projects
workflow.py 19.1 KiB
Newer Older
  • Learn to ignore specific revisions
  • from collections import defaultdict
    
    """
    This file defines classes which allow you to compose workflows based on the following structure:
    
    Workflow -> Stage -> Phase -> Action
    
    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]
    """
    
    
    Todd Dembrey's avatar
    Todd Dembrey committed
    
    
    class UserPermissions(Enum):
        STAFF = 1
        ADMIN = 2
        LEAD = 3
        APPLICANT = 4
    
    
    class Workflow(dict):
        def __init__(self, name, admin_name, **data):
            self.name = name
            self.admin_name = admin_name
            super().__init__(**data)
    
        def __str__(self):
            return self.name
    
        @property
        def stages(self):
    
            stages = []
            for phase in self.values():
                if phase.stage not in stages:
                    stages.append(phase.stage)
            return stages
    
    class Phase:
        def __init__(self, name, display, stage, permissions, step, transitions=dict()):
            self.name = name
            self.display_name = display
            self.stage = stage
            self.permissions = permissions
            self.step = step
    
    
            # For building transition methods on the parent
    
            self.transitions = {}
    
    
            default_permissions = {UserPermissions.STAFF, UserPermissions.ADMIN, UserPermissions.LEAD}
    
            for transition_target, action in transitions.items():
                transition = dict()
    
                    transition['display'] = action.get('display')
                except AttributeError:
                    transition['display'] = action
                    transition['permissions'] = default_permissions
                else:
                    transition['method'] = action.get('method')
    
    Todd Dembrey's avatar
    Todd Dembrey committed
                    conditions = action.get('conditions', '')
                    transition['conditions'] = conditions.split(',') if conditions else []
    
                    transition['permissions'] = action.get('permissions', default_permissions)
                self.transitions[transition_target] = transition
    
    
        def __str__(self):
            return self.display_name
    
    
    class Stage:
        def __init__(self, name, has_external_review=False):
            self.name = name
            self.has_external_review = has_external_review
    
        def __str__(self):
            return self.name
    
    
    
    class BasePermissions:
    
        def can_edit(self, user: 'User') -> bool:
    
            if user.is_apply_staff:
                return self.can_staff_edit(user)
    
            if user.is_applicant:
                return self.can_applicant_edit(user)
    
        def can_staff_edit(self, user: 'User') -> bool:
            return False
    
        def can_applicant_edit(self, user: 'User') -> bool:
    
            return False
    
    
        def can_review(self, user: 'User') -> bool:
            if user.is_apply_staff:
                return self.can_staff_review(user)
    
            if user.is_reviewer:
                return self.can_reviewer_review(user)
    
    
        def can_staff_review(self, user: 'User') -> bool:
            return False
    
        def can_reviewer_review(self, user: 'User') -> bool:
            return False
    
    
    
    class NoPermissions(BasePermissions):
        pass
    
    class DefaultPermissions(BasePermissions):
        # Other Permissions should inherit from this class
        # Staff can review at any time
    
        def can_staff_review(self, user: 'User') -> bool:
    
        def can_staff_edit(self, user: 'User') -> bool:
            return True
    
    
    class ReviewerReviewPermissions(DefaultPermissions):
    
        def can_reviewer_review(self, user: 'User') -> bool:
    
    class CanEditPermissions(DefaultPermissions):
        def can_applicant_edit(self, user: 'User') -> bool:
    
            return True
    
    
        def can_staff_edit(self, user: 'User') -> bool:
            # Prevent staff editing whilst with the user for edits
            return False
    
    
    
    Request = Stage('Request', False)
    
    
    RequestExt = Stage('RequestExt', True)
    
    Concept = Stage('Concept', False)
    
    
    Proposal = Stage('Proposal', True)
    
    INITIAL_STATE = 'in_discussion'
    
    SingleStageDefinition = {
    
        INITIAL_STATE: {
    
            'transitions': {
    
                'internal_review': 'Open Review',
    
                'rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
    
                'more_info': 'Request More Information',
    
            },
            'display': 'Under Discussion',
            'stage': Request,
    
            'permissions': DefaultPermissions(),
    
            'step': 0,
        },
    
        'more_info': {
            'transitions': {
    
                INITIAL_STATE: {
                    'display': 'Submit',
                    'permissions': {UserPermissions.APPLICANT, UserPermissions.LEAD, UserPermissions.ADMIN},
                    'method': 'create_revision',
                },
    
            },
            'display': 'More information required',
            'stage': Request,
    
            'permissions': CanEditPermissions(),
    
        'internal_review': {
    
            'transitions': {
    
                'post_review_discussion': 'Close Review',
    
            },
            'display': 'Internal Review',
            'stage': Request,
    
            'permissions': DefaultPermissions(),
    
            'step': 1,
        },
        'post_review_discussion': {
            'transitions': {
    
                'accepted': {'display': 'Accept', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
                'rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
    
                'post_review_more_info': 'Request More Information',
    
            },
            'display': 'Under Discussion',
            'stage': Request,
    
            'permissions': DefaultPermissions(),
    
            'step': 2,
        },
    
        'post_review_more_info': {
            'transitions': {
    
                'post_review_discussion': {
                    'display': 'Submit',
                    'permissions': {UserPermissions.APPLICANT, UserPermissions.LEAD, UserPermissions.ADMIN},
                    'method': 'create_revision',
                },
    
            },
            'display': 'More information required',
            'stage': Request,
    
            'permissions': CanEditPermissions(),
    
        'accepted': {
            'display': 'Accepted',
            'stage': Request,
    
            'permissions': NoPermissions(),
    
            'step': 3,
        },
        'rejected': {
            'display': 'Rejected',
            'stage': Request,
    
            'permissions': NoPermissions(),
    
    SingleStageExternalDefinition = {
        INITIAL_STATE: {
            'transitions': {
                'ext_internal_review': 'Open Review',
                'ext_rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
                'ext_more_info': 'Request More Information',
            },
            'display': 'Under Discussion',
            'stage': RequestExt,
            'permissions': DefaultPermissions(),
            'step': 0,
        },
        'ext_more_info': {
            'transitions': {
                INITIAL_STATE: {
                    'display': 'Submit',
                    'permissions': {UserPermissions.APPLICANT, UserPermissions.LEAD, UserPermissions.ADMIN},
                    'method': 'create_revision',
                },
            },
            'display': 'More information required',
            'stage': RequestExt,
            'permissions': CanEditPermissions(),
            'step': 0,
        },
        'ext_internal_review': {
            'transitions': {
                'ext_post_review_discussion': 'Close Review',
            },
            'display': 'Internal Review',
            'stage': RequestExt,
            'permissions': DefaultPermissions(),
            'step': 1,
        },
        'ext_post_review_discussion': {
            'transitions': {
    
                'ext_rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
                'ext_post_review_more_info': 'Request More Information',
            },
            'display': 'Under Discussion',
            'stage': RequestExt,
            'permissions': DefaultPermissions(),
            'step': 2,
        },
        'ext_post_review_more_info': {
            'transitions': {
                'ext_post_review_discussion': {
                    'display': 'Submit',
                    'permissions': {UserPermissions.APPLICANT, UserPermissions.LEAD, UserPermissions.ADMIN},
                    'method': 'create_revision',
                },
            },
            'display': 'More information required',
            'stage': RequestExt,
            'permissions': CanEditPermissions(),
            'step': 2,
        },
        'ext_external_review': {
            'transitions': {
                'ext_post_external_review_discussion': 'Close Review',
            },
            'display': 'Advisory Council Review',
            'stage': RequestExt,
            'permissions': ReviewerReviewPermissions(),
            'step': 3,
        },
        'ext_post_external_review_discussion': {
            'transitions': {
                'ext_accepted': {'display': 'Accept', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
                'ext_rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
                'ext_post_external_review_more_info': 'Request More Information',
            },
            'display': 'Under Discussion',
            'stage': RequestExt,
            'permissions': DefaultPermissions(),
            'step': 4,
        },
        'ext_post_external_review_more_info': {
            'transitions': {
                'ext_post_external_review_discussion': {
                    'display': 'Submit',
                    'permissions': {UserPermissions.APPLICANT, UserPermissions.LEAD, UserPermissions.ADMIN},
                    'method': 'create_revision',
                },
            },
            'display': 'More information required',
            'stage': RequestExt,
            'permissions': CanEditPermissions(),
            'step': 4,
        },
    
        'ext_accepted': {
            'display': 'Accepted',
            'stage': RequestExt,
            'permissions': NoPermissions(),
            'step': 5,
        },
        'ext_rejected': {
            'display': 'Rejected',
            'stage': RequestExt,
            'permissions': NoPermissions(),
            'step': 5,
        },
    }
    
    
    
    DoubleStageDefinition = {
    
        INITIAL_STATE: {
    
            'transitions': {
    
                'concept_internal_review': 'Open Review',
    
                'concept_rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
    
                'concept_more_info': 'Request More Information',
    
            },
            'display': 'Under Discussion',
    
            'permissions': DefaultPermissions(),
    
            'step': 0,
        },
    
        'concept_more_info': {
            'transitions': {
    
                INITIAL_STATE: {
                    'display': 'Submit',
                    'permissions': {UserPermissions.APPLICANT, UserPermissions.LEAD, UserPermissions.ADMIN},
                    'method': 'create_revision',
                },
    
            },
            'display': 'More information required',
            'stage': Concept,
    
            'permissions': CanEditPermissions(),
    
        'concept_internal_review': {
    
                'concept_review_discussion': 'Close Review',
    
            },
            'display': 'Internal Review',
            'stage': Concept,
    
            'permissions': DefaultPermissions(),
    
            'step': 1,
        },
        'concept_review_discussion': {
            'transitions': {
    
                'invited_to_proposal': {'display': 'Invite to Proposal', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
    
                'concept_rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
    
                'concept_review_more_info': 'Request More Information',
    
            },
            'display': 'Under Discussion',
            'stage': Concept,
    
            'permissions': DefaultPermissions(),
    
        'concept_review_more_info': {
            'transitions': {
    
                'concept_review_discussion': {
                    'display': 'Submit',
                    'permissions': {UserPermissions.APPLICANT, UserPermissions.LEAD, UserPermissions.ADMIN},
                    'method': 'create_revision',
                },
    
            },
            'display': 'More information required',
            'stage': Concept,
    
            'permissions': CanEditPermissions(),
    
        'invited_to_proposal': {
    
            'display': 'Concept Accepted',
    
            'transitions': {
    
    Todd Dembrey's avatar
    Todd Dembrey committed
                'draft_proposal': {
                    'display': 'Progress',
                    'method': 'progress_application',
                    'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD},
                    'conditions': 'not_progressed',
                },
    
            },
            'stage': Concept,
    
            'permissions': NoPermissions(),
    
        'concept_rejected': {
            'display': 'Rejected',
            'stage': Concept,
    
            'permissions': NoPermissions(),
    
        'draft_proposal': {
    
                'proposal_discussion': {'display': 'Submit', 'permissions': {UserPermissions.APPLICANT}, 'method': 'create_revision'},
    
            },
            'display': 'Invited for Proposal',
            'stage': Proposal,
    
            'permissions': CanEditPermissions(),
    
        'proposal_discussion': {
    
                'proposal_internal_review': 'Open Review',
    
                'proposal_rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
    
                'proposal_more_info': 'Request More Information',
    
            },
            'display': 'Under Discussion',
            'stage': Proposal,
    
            'permissions': DefaultPermissions(),
    
        'proposal_more_info': {
            'transitions': {
    
                'proposal_discussion': {
                    'display': 'Submit',
                    'permissions': {UserPermissions.APPLICANT, UserPermissions.LEAD, UserPermissions.ADMIN},
                    'method': 'create_revision',
                },
    
            },
            'display': 'More information required',
            'stage': Proposal,
    
            'permissions': CanEditPermissions(),
    
        'proposal_internal_review': {
    
                'post_proposal_review_discussion': 'Close Review',
    
            },
            'display': 'Internal Review',
            'stage': Proposal,
    
            'permissions': DefaultPermissions(),
    
        },
        'post_proposal_review_discussion': {
            'transitions': {
                'external_review': 'Open AC review',
    
                'proposal_rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
    
                'post_proposal_review_more_info': 'Request More Information',
    
            },
            'display': 'Under Discussion',
            'stage': Proposal,
    
            'permissions': DefaultPermissions(),
    
        'post_proposal_review_more_info': {
            'transitions': {
    
                'post_proposal_review_discussion': {
                    'display': 'Submit',
                    'permissions': {UserPermissions.APPLICANT, UserPermissions.LEAD, UserPermissions.ADMIN},
                    'method': 'create_revision',
                },
    
            },
            'display': 'More information required',
            'stage': Proposal,
    
            'permissions': CanEditPermissions(),
    
        'external_review': {
            'transitions': {
                'post_external_review_discussion': 'Close Review',
            },
            'display': 'Advisory Council Review',
            'stage': Proposal,
    
            'permissions': ReviewerReviewPermissions(),
    
        },
        'post_external_review_discussion': {
            'transitions': {
    
                'proposal_accepted': {'display': 'Accept', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
                'proposal_rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
    
                'post_external_review_more_info': 'Request More Information',
    
            },
            'display': 'Under Discussion',
            'stage': Proposal,
    
            'permissions': DefaultPermissions(),
    
        'post_external_review_more_info': {
            'transitions': {
    
                'post_external_review_discussion': {
                    'display': 'Submit',
                    'permissions': {UserPermissions.APPLICANT, UserPermissions.LEAD, UserPermissions.ADMIN},
                    'method': 'create_revision',
                },
    
            },
            'display': 'More information required',
            'stage': Proposal,
    
            'permissions': CanEditPermissions(),
    
        'proposal_accepted': {
            'display': 'Accepted',
            'stage': Proposal,
    
            'permissions': NoPermissions(),
    
        },
        'proposal_rejected': {
            'display': 'Rejected',
            'stage': Proposal,
    
            'permissions': NoPermissions(),
    
    Request = Workflow('Request', 'single', **{
    
        phase_name: Phase(phase_name, **phase_data)
        for phase_name, phase_data in SingleStageDefinition.items()
    
    RequestExternal = Workflow('Request with external review', 'single_ext', **{
        phase_name: Phase(phase_name, **phase_data)
        for phase_name, phase_data in SingleStageExternalDefinition.items()
    })
    
    ConceptProposal = Workflow('Concept & Proposal', 'double', **{
    
        phase_name: Phase(phase_name, **phase_data)
        for phase_name, phase_data in DoubleStageDefinition.items()
    
    WORKFLOWS = {
        Request.admin_name: Request,
    
        RequestExternal.admin_name: RequestExternal,
    
        ConceptProposal.admin_name: ConceptProposal,
    }
    
    
    # This is not a dictionary as the keys will clash for the first phase of each workflow
    # We cannot find the transitions for the first stage in this instance
    PHASES = list(itertools.chain.from_iterable(workflow.items() for workflow in WORKFLOWS.values()))
    
    def get_stage_change_actions():
        changes = set()
        for workflow in WORKFLOWS.values():
            stage = None
            for phase in workflow.values():
                if phase.stage != stage and stage:
                    changes.add(phase.name)
                stage = phase.stage
    
        return changes
    
    
    STAGE_CHANGE_ACTIONS = get_stage_change_actions()
    
    
    
    STATUSES = defaultdict(set)
    
    for key, value in PHASES:
    
        STATUSES[value.display_name].add(key)
    
    active_statuses = [
    
        status for status, _ in PHASES
    
    Dan Braghis's avatar
    Dan Braghis committed
        if 'accepted' not in status and 'rejected' not in status and 'invited' not in status
    
    def get_review_statuses(user=None):
    
        for phase_name, phase in PHASES:
    
    Todd Dembrey's avatar
    Todd Dembrey committed
            if 'review' in phase_name and 'discussion' not in phase_name:
    
                if user is None:
                    reviews.add(phase_name)
    
                elif phase.permissions.can_review(user):
    
                    reviews.add(phase_name)
        return reviews
    
    review_statuses = get_review_statuses()
    
    DETERMINATION_PHASES = list(phase_name for phase_name, _ in PHASES if '_discussion' in phase_name)
    
    DETERMINATION_RESPONSE_PHASES = [
        'post_review_discussion',
        'concept_review_discussion',
        'post_external_review_discussion',
    
        'ext_post_external_review_discussion',
    
    
    
    def get_determination_transitions():
    
        for phase_name, phase in PHASES:
    
            for transition_name in phase.transitions:
    
                if 'accepted' in transition_name:
    
                    transitions[transition_name] = 'accepted'
    
                elif 'rejected' in transition_name:
    
                    transitions[transition_name] = 'rejected'
    
                elif 'more_info' in transition_name:
    
                    transitions[transition_name] = 'more_info'
    
                elif 'invited_to_proposal' in transition_name:
    
                    transitions[transition_name] = 'accepted'
    
    DETERMINATION_OUTCOMES = get_determination_transitions()