from collections import defaultdict from enum import Enum import itertools """ 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] """ 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() try: transition['display'] = action.get('display') except AttributeError: transition['display'] = action transition['permissions'] = default_permissions else: transition['method'] = action.get('method') 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 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 Request = Stage('Request', False) Concept = Stage('Concept', False) Proposal = Stage('Proposal', False) 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': Permission(), 'step': 0, }, 'more_info': { 'transitions': { INITIAL_STATE: {'display': 'Submit', 'permissions': {UserPermissions.APPLICANT}}, }, 'display': 'More information required', 'stage': Request, 'permissions': CanEditPermission(), 'step': 0, }, 'internal_review': { 'transitions': { 'post_review_discussion': 'Close Review', }, 'display': 'Internal Review', 'stage': Request, 'permissions': StaffReviewPermission(), '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': Permission(), 'step': 2, }, 'post_review_more_info': { 'transitions': { 'post_review_discussion': {'display': 'Submit', 'permissions': {UserPermissions.APPLICANT}}, }, 'display': 'More information required', 'stage': Request, 'permissions': CanEditPermission(), 'step': 2, }, 'accepted': { 'display': 'Accepted', 'stage': Request, 'permissions': Permission(), 'step': 3, }, 'rejected': { 'display': 'Rejected', 'stage': Request, 'permissions': Permission(), 'step': 3, }, } 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', 'stage': Concept, 'permissions': Permission(), 'step': 0, }, 'concept_more_info': { 'transitions': { INITIAL_STATE: {'display': 'Submit', 'permissions': {UserPermissions.APPLICANT}}, }, 'display': 'More information required', 'stage': Concept, 'permissions': CanEditPermission(), 'step': 0, }, 'concept_internal_review': { 'transitions': { 'concept_review_discussion': 'Close Review', }, 'display': 'Internal Review', 'stage': Concept, 'permissions': StaffReviewPermission(), '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': Permission(), 'step': 2, }, 'concept_review_more_info': { 'transitions': { 'concept_review_discussion': {'display': 'Submit', 'permissions': {UserPermissions.APPLICANT}}, }, 'display': 'More information required', 'stage': Concept, 'permissions': CanEditPermission(), 'step': 2, }, 'invited_to_proposal': { 'display': 'Concept Accepted', 'transitions': { 'draft_proposal': { 'display': 'Progress', 'method': 'progress_application', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}, 'conditions': 'not_progressed', }, }, 'stage': Concept, 'permissions': Permission(), 'step': 3, }, 'concept_rejected': { 'display': 'Rejected', 'stage': Concept, 'permissions': Permission(), 'step': 3, }, 'draft_proposal': { 'transitions': { 'proposal_discussion': {'display': 'Submit', 'permissions': {UserPermissions.APPLICANT}}, }, 'display': 'Invited for Proposal', 'stage': Proposal, 'permissions': CanEditPermission(), 'step': 4, }, 'proposal_discussion': { 'transitions': { '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': Permission(), 'step': 5, }, 'proposal_more_info': { 'transitions': { 'proposal_discussion': {'display': 'Submit', 'permissions': {UserPermissions.APPLICANT}}, }, 'display': 'More information required', 'stage': Proposal, 'permissions': CanEditPermission(), 'step': 5, }, 'proposal_internal_review': { 'transitions': { 'post_proposal_review_discussion': 'Close Review', }, 'display': 'Internal Review', 'stage': Proposal, 'permissions': StaffReviewPermission(), 'step': 6, }, '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': ReviewerReviewPermission(), 'step': 7, }, 'post_proposal_review_more_info': { 'transitions': { 'post_proposal_review_discussion': {'display': 'Submit', 'permissions': {UserPermissions.APPLICANT}}, }, 'display': 'More information required', 'stage': Proposal, 'permissions': CanEditPermission(), 'step': 7, }, 'external_review': { 'transitions': { 'post_external_review_discussion': 'Close Review', }, 'display': 'Advisory Council Review', 'stage': Proposal, 'permissions': Permission(), 'step': 8, }, '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': Permission(), 'step': 9, }, 'post_external_review_more_info': { 'transitions': { 'post_external_review_discussion': {'display': 'Submit', 'permissions': {UserPermissions.APPLICANT}}, }, 'display': 'More information required', 'stage': Proposal, 'permissions': CanEditPermission(), 'step': 9, }, 'proposal_accepted': { 'display': 'Accepted', 'stage': Proposal, 'permissions': Permission(), 'step': 10, }, 'proposal_rejected': { 'display': 'Rejected', 'stage': Proposal, 'permissions': Permission(), 'step': 10, }, } Request = Workflow('Request', 'single', **{ phase_name: Phase(phase_name, **phase_data) for phase_name, phase_data in SingleStageDefinition.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, ConceptProposal.admin_name: ConceptProposal, } PHASES = list(itertools.chain.from_iterable(workflow.items() for workflow in WORKFLOWS.values())) STATUSES = defaultdict(set) for key, value in PHASES: STATUSES[value.display_name].add(key) active_statuses = [ status for status, _ in PHASES if 'accepted' not in status and 'rejected' not in status and 'invited' not in status ] def get_review_statuses(user=None): reviews = set() for phase_name, phase in PHASES: if 'review' 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()