diff --git a/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html b/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html index 5093f482c4cfb93f329f3c5c865521eb6f848aef..2a9ad9a7cb3fc84adcf0b340912cae36d681b3c7 100644 --- a/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html +++ b/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html @@ -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 %} diff --git a/opentech/apply/funds/forms.py b/opentech/apply/funds/forms.py index dcbf9371aabc0277c8c079e5520a8e9a2c3e932e..91383a729f4625820c1b2d74f664e7f65043773e 100644 --- a/opentech/apply/funds/forms.py +++ b/opentech/apply/funds/forms.py @@ -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) diff --git a/opentech/apply/funds/migrations/0033_use_django_fsm.py b/opentech/apply/funds/migrations/0033_use_django_fsm.py new file mode 100644 index 0000000000000000000000000000000000000000..b27a4eb6d86742c014f2ba0d6e2c970032154426 --- /dev/null +++ b/opentech/apply/funds/migrations/0033_use_django_fsm.py @@ -0,0 +1,39 @@ +# 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'), + ), + ] diff --git a/opentech/apply/funds/models.py b/opentech/apply/funds/models.py index 0a8076d6e40e2610edb262138aef31568753db76..30db10a27e0cffb3cae58912afd784f3341ce42d 100644 --- a/opentech/apply/funds/models.py +++ b/opentech/apply/funds/models.py @@ -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): diff --git a/opentech/apply/funds/tables.py b/opentech/apply/funds/tables.py index 7269eb3b333b7e8cf5bbeafd171a2a1c74a71dd9..55081847434c5ece54c107ac1b8772a730f7741e 100644 --- a/opentech/apply/funds/tables.py +++ b/opentech/apply/funds/tables.py @@ -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") diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html index 0dbf3bcd39338c07e65f0709bf14d53d4b1b199c..421b7cb2ba83c9fc0149e122f56f01b80aecd42d 100644 --- a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html +++ b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html @@ -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"> diff --git a/opentech/apply/funds/templates/funds/includes/status_bar.html b/opentech/apply/funds/templates/funds/includes/status_bar.html index dfbf334700749dd847447c0e38c3c5cc795eeb6d..e24b9661d21d77e39454a912e1edd8bcce555564 100644 --- a/opentech/apply/funds/templates/funds/includes/status_bar.html +++ b/opentech/apply/funds/templates/funds/includes/status_bar.html @@ -1,27 +1,18 @@ <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> diff --git a/opentech/apply/funds/templatetags/workflow_tags.py b/opentech/apply/funds/templatetags/workflow_tags.py index 87adb9ad8d8edc74fa0e3766e09d03adbd3b7505..224f0ae697c673da4b140925bb9e1d3d14619020 100644 --- a/opentech/apply/funds/templatetags/workflow_tags.py +++ b/opentech/apply/funds/templatetags/workflow_tags.py @@ -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 diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py index 129b3a978845601cdb37a36fd86ac96ceb2c45b1..932b6f8dd8310f8ea8eea9f96190282fc4da6041 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -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'], diff --git a/opentech/apply/funds/workflow.py b/opentech/apply/funds/workflow.py index 89232f813f037c9d28838b12859c73bd40b1647f..81f8d8dc6d6fcea92abe9fa036e6234d40b53c80 100644 --- a/opentech/apply/funds/workflow.py +++ b/opentech/apply/funds/workflow.py @@ -1,4 +1,4 @@ -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() diff --git a/opentech/settings/base.py b/opentech/settings/base.py index f1616a9236d877f782fc67e165304051f98a6d8d..2b69ec1644edc17b5d265d91e0adfd4edf9283d8 100644 --- a/opentech/settings/base.py +++ b/opentech/settings/base.py @@ -61,6 +61,7 @@ INSTALLED_APPS = [ 'django_select2', 'addressfield', 'django_bleach', + 'django_fsm', 'django.contrib.admin', 'django.contrib.auth', diff --git a/requirements.txt b/requirements.txt index 75dc42283b3326f9ef9e65b9c245a6eafd102372..ae8d0744c6275c7be92a07bd39900a4d24f87880 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ 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