Newer
Older
from enum import Enum
"""
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
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')
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
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:
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:
def can_staff_edit(self, user: 'User') -> bool:
# Prevent staff editing whilst with the user for edits
return False
RequestExt = Stage('RequestExt', True)
Concept = Stage('Concept', False)
'rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
'more_info': 'Request More Information',
},
'display': 'Under Discussion',
'stage': Request,
'permissions': DefaultPermissions(),
'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(),
'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(),
'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(),
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
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': {
Fredrik Jonsson
committed
'ext_external_review': 'Open AC review',
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
'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,
},
}
'concept_internal_review': 'Open Review',
'concept_rejected': {'display': 'Reject', 'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD}},
'concept_more_info': 'Request More Information',
'permissions': DefaultPermissions(),
'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_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': {
'draft_proposal': {
'display': 'Progress',
'method': 'progress_application',
'permissions': {UserPermissions.ADMIN, UserPermissions.LEAD},
'conditions': 'not_progressed',
},
'permissions': NoPermissions(),
'concept_rejected': {
'display': 'Rejected',
'stage': Concept,
'permissions': NoPermissions(),
'step': 3,
},
'proposal_discussion': {'display': 'Submit', 'permissions': {UserPermissions.APPLICANT}, 'method': 'create_revision'},
},
'display': 'Invited for Proposal',
'stage': Proposal,
'permissions': CanEditPermissions(),
'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(),
'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()
for key, value in PHASES:
STATUSES[value.display_name].add(key)
status for status, _ in PHASES
if 'accepted' not in status and 'rejected' not in status and 'invited' not in status
for phase_name, phase in PHASES:
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():
transitions = {}
for phase_name, phase in PHASES:
for transition_name in phase.transitions:
transitions[transition_name] = 'accepted'
transitions[transition_name] = 'rejected'
transitions[transition_name] = 'more_info'
elif 'invited_to_proposal' in transition_name:
transitions[transition_name] = 'accepted'
return transitions
DETERMINATION_OUTCOMES = get_determination_transitions()