Skip to content
Snippets Groups Projects
Commit 4893b668 authored by Todd Dembrey's avatar Todd Dembrey
Browse files

Start migration to django fsm

parent 6e43164c
No related branches found
No related tags found
No related merge requests found
......@@ -26,7 +26,7 @@
<h5 class="heading heading--no-margin"><a class="link link--underlined" href="{% url 'funds:submission' submission.id %}">{{ submission.title }}</a></h5>
<h6 class="heading heading--no-margin heading--submission-meta"><span>Submitted:</span> {{ submission.submit_time.date }} by {{ submission.user.get_full_name }}</h6>
</div>
{% include "funds/includes/status_bar.html" with phases=submission.workflow status=submission.phase class="status-bar--small" %}
{% include "funds/includes/status_bar.html" with phases=submission.workflow status=submission.status current_phase=submission.phase class="status-bar--small" %}
{% if request.user|has_edit_perm:submission %}
<a class="button button--primary" href="{% url 'funds:edit_submission' submission.id %}">Start your {{ submission.stage }} application</a>
{% endif %}
......
......@@ -16,14 +16,14 @@ class ProgressSubmissionForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
kwargs.pop('user')
super().__init__(*args, **kwargs)
choices = [(action, action) for action in self.instance.phase.action_names]
choices = [(name, action) for name, action in self.instance.phase.get('transitions', {}).items()]
action_field = self.fields['action']
action_field.choices = choices
self.should_show = bool(choices)
def save(self, *args, **kwargs):
new_phase = self.instance.workflow.process(self.instance.phase, self.cleaned_data['action'])
self.instance.status = str(new_phase)
transition = getattr(self.instance, self.cleaned_data['action'])
transition(self.instance)
return super().save(*args, **kwargs)
......
# Generated by Django 2.0.2 on 2018-06-11 16:14
from django.db import migrations, models
import django_fsm
class Migration(migrations.Migration):
dependencies = [
('funds', '0032_make_reviewers_optional_in_all_instances'),
]
operations = [
migrations.AlterField(
model_name='applicationsubmission',
name='status',
field=django_fsm.FSMField(default='in_discussion', max_length=50, protected=True),
),
migrations.AlterField(
model_name='applicationsubmission',
name='workflow_name',
field=models.CharField(choices=[('single', 'Request'), ('double', 'Concept & Proposal')], default='single', max_length=100, verbose_name='Workflow'),
),
migrations.AlterField(
model_name='fundtype',
name='workflow_name',
field=models.CharField(choices=[('single', 'Request'), ('double', 'Concept & Proposal')], default='single', max_length=100, verbose_name='Workflow'),
),
migrations.AlterField(
model_name='labtype',
name='workflow_name',
field=models.CharField(choices=[('single', 'Request'), ('double', 'Concept & Proposal')], default='single', max_length=100, verbose_name='Workflow'),
),
migrations.AlterField(
model_name='round',
name='workflow_name',
field=models.CharField(choices=[('single', 'Request'), ('double', 'Concept & Proposal')], default='single', max_length=100, verbose_name='Workflow'),
),
]
......@@ -16,6 +16,7 @@ from django.urls import reverse
from django.utils.text import mark_safe
from django.utils.translation import ugettext_lazy as _
from django_fsm import FSMField, transition
from modelcluster.fields import ParentalKey, ParentalManyToManyField
from wagtail.admin.edit_handlers import (
FieldPanel,
......@@ -39,12 +40,12 @@ from opentech.apply.users.groups import REVIEWER_GROUP_NAME, STAFF_GROUP_NAME
from .admin_forms import WorkflowFormAdminForm
from .blocks import CustomFormFieldsBlock, MustIncludeFieldBlock, REQUIRED_BLOCK_NAMES
from .edit_handlers import FilteredFieldPanel, ReadOnlyPanel, ReadOnlyInlinePanel
from .workflow import SingleStage, DoubleStage, active_statuses, get_review_statuses, review_statuses
from .workflow import SingleStage, DoubleStage, active_statuses, get_review_statuses, review_statuses, INITAL_STATE
WORKFLOW_CLASS = {
SingleStage.name: SingleStage,
DoubleStage.name: DoubleStage,
'Request': SingleStage,
'Concept & Proposal': DoubleStage,
}
......@@ -88,15 +89,15 @@ class WorkflowHelpers(models.Model):
abstract = True
WORKFLOWS = {
'single': SingleStage.name,
'double': DoubleStage.name,
'single': 'Request',
'double': 'Concept & Proposal',
}
workflow_name = models.CharField(choices=WORKFLOWS.items(), max_length=100, default='single', verbose_name="Workflow")
@property
def workflow(self):
return self.workflow_class()
return self.workflow_class
@property
def workflow_class(self):
......@@ -514,6 +515,18 @@ class ApplicationSubmissionQueryset(JSONOrderable):
class ApplicationSubmission(WorkflowHelpers, BaseStreamForm, AbstractFormSubmission):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
fsm_field = self._meta.get_field('status')
for transition_name in self.phase['transitions'].keys():
def transition_state(self):
# TODO include state change methods
pass
transition_func = transition(fsm_field, source=self.status, target=transition_name)(transition_state)
setattr(self, transition_name, transition_func)
field_template = 'funds/includes/submission_field.html'
form_data = JSONField(encoder=DjangoJSONEncoder)
......@@ -537,7 +550,7 @@ class ApplicationSubmission(WorkflowHelpers, BaseStreamForm, AbstractFormSubmiss
search_data = models.TextField()
# Workflow inherited from WorkflowHelpers
status = models.CharField(max_length=254)
status = FSMField(default=INITAL_STATE, protected=True)
# Meta: used for migration purposes only
drupal_id = models.IntegerField(null=True, blank=True, editable=False)
......@@ -546,19 +559,19 @@ class ApplicationSubmission(WorkflowHelpers, BaseStreamForm, AbstractFormSubmiss
@property
def status_name(self):
return self.phase.name
return self.status
@property
def stage(self):
return self.phase.stage
return self.phase['stage']
@property
def phase(self):
return self.workflow.current(self.status)
return self.workflow.get(self.status) or self.workflow.get(list(self.workflow.keys())[0])
@property
def active(self):
return self.phase.active
return True
def ensure_user_has_account(self):
if self.user and self.user.is_authenticated:
......@@ -648,17 +661,17 @@ class ApplicationSubmission(WorkflowHelpers, BaseStreamForm, AbstractFormSubmiss
self.reviewers.set(self.get_from_parent('reviewers').all())
# Check to see if we should progress to the next stage
if self.phase.can_proceed and not self.next:
submission_in_db = ApplicationSubmission.objects.get(id=self.id)
# if self.phase.can_proceed and not self.next:
# submission_in_db = ApplicationSubmission.objects.get(id=self.id)
self.id = None
self.status = str(self.workflow.next(self.status))
self.form_fields = self.get_from_parent('get_defined_fields')(self.stage)
# self.id = None
# self.status = str(self.workflow.next(self.status))
# self.form_fields = self.get_from_parent('get_defined_fields')(self.stage)
super().save(*args, **kwargs)
# super().save(*args, **kwargs)
submission_in_db.next = self
submission_in_db.save()
# submission_in_db.next = self
# submission_in_db.save()
@property
def missing_reviewers(self):
......
......@@ -27,7 +27,7 @@ class SubmissionsTable(tables.Table):
title = tables.LinkColumn('funds:submission', args=[A('pk')], orderable=True)
submit_time = tables.DateColumn(verbose_name="Submitted")
status_name = tables.Column(verbose_name="Status")
stage = tables.Column(verbose_name="Type", order_by=('status',))
stage = tables.Column(verbose_name="Type", accessor='stage.name', order_by=('status',))
page = tables.Column(verbose_name="Fund")
comments = tables.Column(accessor='activities.comments.all', verbose_name="Comments")
last_update = tables.DateColumn(accessor="activities.last.timestamp", verbose_name="Last updated")
......
......@@ -7,12 +7,12 @@
<div class="wrapper wrapper--medium">
<h2 class="heading heading--no-margin">{{ object.title }}</h2>
<h5 class="heading heading--meta">
<span>{{ object.stage }}</span>
<span>{{ object.stage.name }}</span>
<span>{{ object.page }}</span>
<span>{{ object.round }}</span>
<span>Lead: {{ object.lead }}</span>
</h5>
{% include "funds/includes/status_bar.html" with phases=object.phase.stage status=object.phase %}
{% include "funds/includes/status_bar.html" with phases=object.workflow status=object.status current_phase=object.phase %}
<div class="tabs js-tabs">
<div class="tabs__container">
......
<div class="status-bar {{ class }}">
{% for phase in phases %}
{% for phase_name, phase in phases.items %}
<div class="status-bar__item
{% if phase == status %}
{% if phase_name == status %}
status-bar__item--is-current
{% elif phase < status %}
{% elif current_phase.step > phase.step %}
status-bar__item--is-complete
{% endif %}">
<span class="status-bar__tooltip"
{% if phase == status %}
{# We want to display the status explicitly in case phase is a MultiStep (displays "Outcome" for name) #}
data-title="{{ status.name }}" aria-label="{{ status.name }}"
{% else %}
data-title="{{ phase.name }}" aria-label="{{ phase.name }}"
{% endif %}
data-title="{{ phase.display }}" aria-label="{{ phase.display }}"
></span>
<svg class="status-bar__icon"><use xlink:href="#tick-alt"></use></svg>
</div>
{% endfor %}
</div>
<div class="status-bar--mobile">
{% for phase in status.stage %}
{% if phase == status %}
<h6 class="status-bar__subheading">{{ status.name }}</h6>
{% endif %}
{% endfor %}
<h6 class="status-bar__subheading">{{ current_phase.display }}</h6>
</div>
......@@ -4,7 +4,8 @@ register = template.Library()
def check_permission(user, perm, submission):
return submission.phase.has_perm(user, perm)
perm_method = getattr(submission.phase['permissions'], f'can_{perm}', lambda x: False)
return perm_method(user)
@register.filter
......
......@@ -72,9 +72,9 @@ class ProgressSubmissionView(DelegatedViewMixin, UpdateView):
context_name = 'progress_form'
def form_valid(self, form):
old_phase = form.instance.phase.name
old_phase = form.instance.phase['display']
response = super().form_valid(form)
new_phase = form.instance.phase.name
new_phase = form.instance.phase['display']
Activity.actions.create(
user=self.request.user,
submission=self.kwargs['submission'],
......
from collections import defaultdict
from collections import defaultdict, namedtuple
import copy
import itertools
......@@ -10,6 +10,102 @@ 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)
INITAL_STATE = 'in_discussion'
SingleStage = {
'in_discussion' : {
'transitions': {
'internal_review' : 'Open Review',
'rejected' : 'Reject',
},
'display': 'Under Discussion',
'stage': Request,
'permissions': Permission(),
'step': 0,
},
'internal_review' : {
'transitions': {
'in_discussion_2' : '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 = {
'in_discussion' : {
'transitions': {
'internal_review' : 'Open Review',
'rejected' : 'Reject',
},
'display': 'Under Discussion',
'stage': Request,
'permissions': Permission(),
'step': 0,
},
}
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:
......@@ -240,35 +336,6 @@ class PhaseIterator(Iterator):
return self.Step(self.phases[self.current - 1])
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
class Phase:
"""
Holds the Actions which a user can perform at each stage. A Phase with no actions is
......@@ -450,53 +517,54 @@ class ProposalStage(Stage):
]
class SingleStage(Workflow):
name = 'Single Stage'
stage_classes = [RequestStage]
# class SingleStage(Workflow):
# name = 'Single Stage'
# stage_classes = [RequestStage]
class DoubleStage(Workflow):
name = 'Two Stage'
stage_classes = [ConceptStage, ProposalStage]
# 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]
# 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 get_active_statuses() -> Set[str]:
# active = set()
def add_if_active(phase: 'Phase') -> None:
if phase.active:
active.add(str(phase))
# 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
# 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()
active_statuses = [] #get_active_statuses()
def get_review_statuses(user: Union[None, 'User']=None) -> Set[str]:
reviews = set()
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))
# 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
# return reviews
review_statuses = get_review_statuses()
review_statuses = [] #get_review_statuses()
......@@ -61,6 +61,7 @@ INSTALLED_APPS = [
'django_select2',
'addressfield',
'django_bleach',
'django_fsm',
'django.contrib.admin',
'django.contrib.auth',
......
Django==2.0.2
djangorestframework==3.7.4
django-fsm==2.6.0
wagtail==2.0
psycopg2==2.7.3.1
Pillow==4.3.0
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment