diff --git a/opentech/apply/activity/migrations/0002_activity_type.py b/opentech/apply/activity/migrations/0002_activity_type.py new file mode 100644 index 0000000000000000000000000000000000000000..d0688ecb048114d572c479d534d4632264d7f36d --- /dev/null +++ b/opentech/apply/activity/migrations/0002_activity_type.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-03-01 15:49 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='type', + field=models.CharField(choices=[('comment', 'Comment'), ('activity', 'Activity')], default='comment', max_length=30), + preserve_default=False, + ), + ] diff --git a/opentech/apply/activity/models.py b/opentech/apply/activity/models.py index c01132e720799eeb537f136b087ec7ed7ed764bb..ae0acc1145982ecaa2a44d76c5c9a88153b17a2b 100644 --- a/opentech/apply/activity/models.py +++ b/opentech/apply/activity/models.py @@ -1,12 +1,45 @@ from django.conf import settings from django.db import models +COMMENT = 'comment' +ACTION = 'action' + +ACTIVITY_TYPES = { + COMMENT: 'Comment', + ACTION: 'Action', +} + + +class ActivityBaseManager(models.Manager): + def create(self, **kwargs): + kwargs.update(type=self.type) + return super().create(**kwargs) + + def get_queryset(self): + return super().get_queryset().filter(type=self.type) + + +class CommentManger(ActivityBaseManager): + type = COMMENT + + +class ActionManager(ActivityBaseManager): + type = ACTION + class Activity(models.Model): timestamp = models.DateTimeField(auto_now_add=True) + type = models.CharField(choices=ACTIVITY_TYPES.items(), max_length=30) user = models.ForeignKey(settings.AUTH_USER_MODEL) submission = models.ForeignKey('funds.ApplicationSubmission', related_name='activities') message = models.TextField() + objects = models.Manager() + comments = CommentManger() + actions = ActionManager() + class Meta: ordering = ['-timestamp'] + + def __str__(self): + return '{}: for "{}"'.format(self.get_type_display(), self.submission) diff --git a/opentech/apply/activity/templates/activity/include/action_list.html b/opentech/apply/activity/templates/activity/include/action_list.html new file mode 100644 index 0000000000000000000000000000000000000000..ebd8bbaa11232a7312f180a2ec98681b9ec50958 --- /dev/null +++ b/opentech/apply/activity/templates/activity/include/action_list.html @@ -0,0 +1,3 @@ +{% for action in actions %} + {% include "activity/include/listing_base.html" with activity=action %} +{% endfor %} diff --git a/opentech/apply/activity/templates/activity/include/all_activity_list.html b/opentech/apply/activity/templates/activity/include/all_activity_list.html new file mode 100644 index 0000000000000000000000000000000000000000..312a0b3294fa6438dbb81bb3da4973e7151f152f --- /dev/null +++ b/opentech/apply/activity/templates/activity/include/all_activity_list.html @@ -0,0 +1,3 @@ +{% for activity in all_activity %} + {% include "activity/include/listing_base.html" with activity=activity %} +{% endfor %} diff --git a/opentech/apply/activity/templates/activity/include/comment_form.html b/opentech/apply/activity/templates/activity/include/comment_form.html index 06a11b9a96a12198ba7d7702bc2722cb92941837..c20300aec2bd4315b05c7d672885b34c9c9e602d 100644 --- a/opentech/apply/activity/templates/activity/include/comment_form.html +++ b/opentech/apply/activity/templates/activity/include/comment_form.html @@ -1,5 +1,5 @@ -<form method="post"> +<form method="post" id="comment-form"> {% csrf_token %} {{ comment_form }} - <input type="submit" value="Comment"> + <input id="comment-form-submit" name="form-submitted" type="submit" form="comment-form" value="Comment"> </form> diff --git a/opentech/apply/activity/templates/activity/include/comment_list.html b/opentech/apply/activity/templates/activity/include/comment_list.html index 9217e30137f2b12b4fc33ddc9fccac8d5a409260..cfd58fa44049d4fd9544e96d4aa58ddb506e2de1 100644 --- a/opentech/apply/activity/templates/activity/include/comment_list.html +++ b/opentech/apply/activity/templates/activity/include/comment_list.html @@ -1,7 +1,3 @@ {% for comment in comments %} -<div> - <p>{{ comment.timestamp }}</p> - <p>{{ comment.user }}</p> - <p>{{ comment.message }}</p> -</div> + {% include "activity/include/listing_base.html" with activity=comment %} {% endfor %} diff --git a/opentech/apply/activity/templates/activity/include/listing_base.html b/opentech/apply/activity/templates/activity/include/listing_base.html new file mode 100644 index 0000000000000000000000000000000000000000..8d44760d741a3ec26f32fc3f51b9dc0ba634d1dc --- /dev/null +++ b/opentech/apply/activity/templates/activity/include/listing_base.html @@ -0,0 +1,5 @@ +<div> + <p>{{ activity.timestamp }}</p> + <p>{{ activity.user }}</p> + <p>{{ activity.message }}</p> +</div> diff --git a/opentech/apply/activity/views.py b/opentech/apply/activity/views.py index 5a2c82c874f3569a6dc4359f4669921c16f103de..c680bda9d570f291388d6425936e9c4182aa7fc9 100644 --- a/opentech/apply/activity/views.py +++ b/opentech/apply/activity/views.py @@ -1,20 +1,33 @@ -from django.views.generic import CreateView +from django.views.generic import CreateView, View from .forms import CommentForm -from .models import Activity +from .models import Activity, COMMENT -class CommentContextMixin: +ACTIVITY_LIMIT = 50 + + +class AllActivityContextMixin: + def get_context_data(self, **kwargs): + extra = { + 'actions': Activity.actions.filter(submission__in=self.object_list)[:ACTIVITY_LIMIT], + 'comments': Activity.comments.filter(submission__in=self.object_list[:ACTIVITY_LIMIT]), + 'all_activity': Activity.objects.filter(submission__in=self.object_list)[:ACTIVITY_LIMIT], + } + return super().get_context_data(**extra, **kwargs) + + +class ActivityContextMixin: def get_context_data(self, **kwargs): extra = { - 'comments': Activity.objects.filter(submission=self.object), - CommentFormView.context_name: CommentFormView.form_class(), + 'actions': Activity.actions.filter(submission=self.object), + 'comments': Activity.comments.filter(submission=self.object), } return super().get_context_data(**extra, **kwargs) -class DelegatedCreateView(CreateView): +class DelegatedViewMixin(View): """For use on create views accepting forms from another view""" def get_template_names(self): return self.kwargs['template_names'] @@ -26,15 +39,25 @@ class DelegatedCreateView(CreateView): kwargs.update(**{self.context_name: form}) return super().get_context_data(**kwargs) + @classmethod + def contribute_form(cls, submission): + return cls.context_name, cls.form_class(instance=submission) -class CommentFormView(DelegatedCreateView): + +class CommentFormView(DelegatedViewMixin, CreateView): form_class = CommentForm context_name = 'comment_form' def form_valid(self, form): form.instance.user = self.request.user form.instance.submission = self.kwargs['submission'] + form.instance.type = COMMENT return super().form_valid(form) def get_success_url(self): - return self.object.application.get_absolute_url() + return self.object.submission.get_absolute_url() + + @classmethod + def contribute_form(cls, submission): + # We dont want to pass the submission as the instance + return super().contribute_form(None) diff --git a/opentech/apply/funds/admin_forms.py b/opentech/apply/funds/admin_forms.py new file mode 100644 index 0000000000000000000000000000000000000000..643ab0d9012941d53390c04c154df894918814d5 --- /dev/null +++ b/opentech/apply/funds/admin_forms.py @@ -0,0 +1,34 @@ +from wagtail.wagtailadmin.forms import WagtailAdminPageForm + + +class WorkflowFormAdminForm(WagtailAdminPageForm): + def clean(self): + cleaned_data = super().clean() + model = self._meta.model + + workflow = model.workflow_class_from_name(cleaned_data['workflow_name']) + application_forms = self.formsets['forms'] + + self.validate_stages_equal_forms(workflow, application_forms) + + return cleaned_data + + def validate_stages_equal_forms(self, workflow, application_forms): + if application_forms.is_valid(): + valid_forms = [form for form in application_forms if not form.cleaned_data['DELETE']] + number_of_forms = len(valid_forms) + plural_form = 's' if number_of_forms > 1 else '' + + number_of_stages = len(workflow.stage_classes) + plural_stage = 's' if number_of_stages > 1 else '' + + if number_of_forms != number_of_stages: + self.add_error( + None, + 'Number of forms does not match number of stages: ' + f'{number_of_stages} stage{plural_stage} and {number_of_forms} ' + f'form{plural_form} provided', + ) + + for form in valid_forms[number_of_stages:]: + form.add_error('form', 'Exceeds required number of forms for stage, please remove.') diff --git a/opentech/apply/funds/forms.py b/opentech/apply/funds/forms.py index 643ab0d9012941d53390c04c154df894918814d5..e74a7449e5a9b4aa3fea1ace29af62a6b96b9e8e 100644 --- a/opentech/apply/funds/forms.py +++ b/opentech/apply/funds/forms.py @@ -1,34 +1,34 @@ -from wagtail.wagtailadmin.forms import WagtailAdminPageForm +from django import forms +from .models import ApplicationSubmission -class WorkflowFormAdminForm(WagtailAdminPageForm): - def clean(self): - cleaned_data = super().clean() - model = self._meta.model - workflow = model.workflow_class_from_name(cleaned_data['workflow_name']) - application_forms = self.formsets['forms'] +class ProgressSubmissionForm(forms.ModelForm): + action = forms.ChoiceField() - self.validate_stages_equal_forms(workflow, application_forms) + class Meta: + model = ApplicationSubmission + fields: list = [] - return cleaned_data + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + choices = [(action, action) for action in self.instance.phase.action_names] + self.fields['action'].choices = choices + self.fields['action'].label = self.instance.phase.name + self.should_show = bool(choices) - def validate_stages_equal_forms(self, workflow, application_forms): - if application_forms.is_valid(): - valid_forms = [form for form in application_forms if not form.cleaned_data['DELETE']] - number_of_forms = len(valid_forms) - plural_form = 's' if number_of_forms > 1 else '' + def save(self, *args, **kwargs): + new_phase = self.instance.workflow.process(self.instance.phase, self.cleaned_data['action']) + self.instance.status = str(new_phase) + return super().save(*args, **kwargs) - number_of_stages = len(workflow.stage_classes) - plural_stage = 's' if number_of_stages > 1 else '' - if number_of_forms != number_of_stages: - self.add_error( - None, - 'Number of forms does not match number of stages: ' - f'{number_of_stages} stage{plural_stage} and {number_of_forms} ' - f'form{plural_form} provided', - ) +class UpdateSubmissionLeadForm(forms.ModelForm): + class Meta: + model = ApplicationSubmission + fields = ('lead',) - for form in valid_forms[number_of_stages:]: - form.add_error('form', 'Exceeds required number of forms for stage, please remove.') + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + qs = self.fields['lead'].queryset + self.fields['lead'].queryset = qs.exclude(id=self.instance.lead.id) diff --git a/opentech/apply/funds/migrations/0026_add_leads_to_submission_and_lab.py b/opentech/apply/funds/migrations/0026_add_leads_to_submission_and_lab.py new file mode 100644 index 0000000000000000000000000000000000000000..8d68ac6179b435e8d6a2187c814cb6e72bed0d53 --- /dev/null +++ b/opentech/apply/funds/migrations/0026_add_leads_to_submission_and_lab.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-03-02 17:08 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('funds', '0025_update_with_file_blocks'), + ] + + operations = [ + migrations.AddField( + model_name='applicationsubmission', + name='lead', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='submission_lead', to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='labtype', + name='lead', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='lab_lead', to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/opentech/apply/funds/models.py b/opentech/apply/funds/models.py index f1eb0b1a2937842f3a231d09df58206b8e9de718..c36510831779e8db5128b3da927b7a8b58fbf82e 100644 --- a/opentech/apply/funds/models.py +++ b/opentech/apply/funds/models.py @@ -38,7 +38,7 @@ from opentech.apply.users.groups import STAFF_GROUP_NAME from .blocks import CustomFormFieldsBlock, MustIncludeFieldBlock, REQUIRED_BLOCK_NAMES from .edit_handlers import FilteredFieldPanel, ReadOnlyPanel, ReadOnlyInlinePanel -from .forms import WorkflowFormAdminForm +from .admin_forms import WorkflowFormAdminForm from .workflow import SingleStage, DoubleStage @@ -382,11 +382,17 @@ class LabType(EmailForm, WorkflowStreamForm, SubmittableStreamForm): # type: ig class Meta: verbose_name = _("Lab") + lead = models.ForeignKey(settings.AUTH_USER_MODEL, limit_choices_to={'groups__name': STAFF_GROUP_NAME}, related_name='lab_lead') + parent_page_types = ['apply_home.ApplyHomePage'] subpage_types = [] # type: ignore + content_panels = WorkflowStreamForm.content_panels + [ + FieldPanel('lead') + ] + edit_handler = TabbedInterface([ - ObjectList(WorkflowStreamForm.content_panels, heading='Content'), + ObjectList(content_panels, heading='Content'), EmailForm.email_tab, ObjectList(WorkflowStreamForm.promote_panels, heading='Promote'), ]) @@ -434,6 +440,7 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission): form_fields = StreamField(CustomFormFieldsBlock()) page = models.ForeignKey('wagtailcore.Page', on_delete=models.PROTECT) round = models.ForeignKey('wagtailcore.Page', on_delete=models.PROTECT, related_name='submissions', null=True) + lead = models.ForeignKey(settings.AUTH_USER_MODEL, limit_choices_to={'groups__name': STAFF_GROUP_NAME}, related_name='submission_lead') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) search_data = models.TextField() @@ -515,6 +522,12 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission): self.workflow_name = self.page.workflow_name self.status = str(self.workflow.first()) + try: + self.lead = self.round.specific.lead + except AttributeError: + # Its a lab + self.lead = self.page.specific.lead + # add a denormed version of the answer for searching self.search_data = ' '.join(self.prepare_search_values()) @@ -568,4 +581,7 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission): raise AttributeError('{} has no attribute "{}"'.format(repr(self), item)) def __str__(self): - return str(super().__str__()) + return f'{self.title} from {self.full_name} for {self.page.title}' + + def __repr__(self): + return f'<{self.__class__.__name__}: {str(self.form_data)}>' diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html index fd87b677c8e282c0d8d45c3e6ba37663f777e38f..d6a2e2805a0d886ee8e88541807e7b2cf971073e 100644 --- a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html +++ b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html @@ -9,9 +9,9 @@ <span>{{ object.stage }}</span> <span>{{ object.page }}</span> <span>{{ object.round }}</span> - <span>Lead: {{ object.round.specific.lead }}</span> + <span>Lead: {{ object.lead }}</span> </h5> - {% include "funds/includes/status_bar.html" with workflow=object.workflow status=object.status %} + {% include "funds/includes/status_bar.html" with workflow=object.workflow status=object.phase %} </div> </div> @@ -50,6 +50,8 @@ </div> <aside class="sidebar"> + {% include "funds/includes/progress_form.html" %} + {% include "funds/includes/update_lead_form.html" %} {% if other_submissions %} <div class="sidebar__inner"> <h6 class="heading heading--light-grey heading--small heading--uppercase">Past Submissions</h6> @@ -61,6 +63,7 @@ {% endif %} {% include "activity/include/comment_form.html" %} {% include "activity/include/comment_list.html" %} + {% include "activity/include/action_list.html" %} </aside> </div> </div> diff --git a/opentech/apply/funds/templates/funds/includes/progress_form.html b/opentech/apply/funds/templates/funds/includes/progress_form.html new file mode 100644 index 0000000000000000000000000000000000000000..8d4b54481406eb93bf14c6ba74062824fbea05c7 --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/progress_form.html @@ -0,0 +1,7 @@ +{% if progress_form.should_show %} +<form method="post" id="progress-form"> + {% csrf_token %} + {{ progress_form }} + <input id="progress-form-submit" name="form-submitted" type="submit" form="progress-form" value="Progress"> +</form> +{% endif %} diff --git a/opentech/apply/funds/templates/funds/includes/status_bar.html b/opentech/apply/funds/templates/funds/includes/status_bar.html index d99ed4c6819bdd39b36cc41f6cb3fa29f41c9bde..9dfbdb9409a9d53f9c7c41ddd4d36b50fc6e64f9 100644 --- a/opentech/apply/funds/templates/funds/includes/status_bar.html +++ b/opentech/apply/funds/templates/funds/includes/status_bar.html @@ -1,17 +1,26 @@ <div class="status-bar"> - {# '.status-bar__item--is-complete' needs to be added to each 'complete' step #} - {# '.status-bar__item--is-current' needs to be added to the current step #} - {% for phase in workflow %} - <div class="status-bar__item {% if phase == status %}status-bar__item--is-current{% endif %}"> - <span class="status-bar__tooltip" data-title="{{ phase.name }}" aria-label="{{ phase.name }}"></span> + {% for phase in status.stage %} + <div class="status-bar__item + {% if phase == status %} + status-bar__item--is-current + {% elif phase.step < status.step %} + status-bar__item--is-complete + {% endif %}"> + <span class="status-bar__tooltip" + {% if phase == status %} + data-title="{{ status.name }}" aria-label="{{ status.name }}" + {% else %} + data-title="{{ phase.name }}" aria-label="{{ phase.name }}" + {% endif %} + ></span> <svg class="status-bar__icon"><use xlink:href="#tick-alt"></use></svg> </div> {% endfor %} </div> <div class="status-bar--mobile"> - {% for phase in workflow %} + {% for phase in status.stage %} {% if phase == status %} - <h6 class="status-bar__subheading">{{ phase.name }}</h6> + <h6 class="status-bar__subheading">{{ status.name }}</h6> {% endif %} {% endfor %} </div> diff --git a/opentech/apply/funds/templates/funds/includes/update_lead_form.html b/opentech/apply/funds/templates/funds/includes/update_lead_form.html new file mode 100644 index 0000000000000000000000000000000000000000..b6ad97907a33f678a11a2560a5927cfc96755aaf --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/update_lead_form.html @@ -0,0 +1,5 @@ +<form method="post" id="update-lead-form"> + {% csrf_token %} + {{ lead_form }} + <input id="update-form-submit" name="form-submitted" type="submit" form="update-lead-form" value="Update"> +</form> diff --git a/opentech/apply/funds/templates/funds/submissions.html b/opentech/apply/funds/templates/funds/submissions.html index 3428cc9a7d1cc291ff9c14e714f62d167a7d14c8..f039d269e2cac92256acb8b8ece3c1a753a740aa 100644 --- a/opentech/apply/funds/templates/funds/submissions.html +++ b/opentech/apply/funds/templates/funds/submissions.html @@ -39,6 +39,10 @@ {% render_table table %} </div> +{% include "activity/include/comment_list.html" %} +{% include "activity/include/action_list.html" %} +{% include "activity/include/all_activity_list.html" %} + {% endblock %} {% block extra_js %} diff --git a/opentech/apply/funds/tests/test_workflow.py b/opentech/apply/funds/tests/test_workflow.py index ad1898c37ed551200ad44aa9a6449716b6df4cd0..7fd8a08ed07478d85b1d314010143294d10c1605 100644 --- a/opentech/apply/funds/tests/test_workflow.py +++ b/opentech/apply/funds/tests/test_workflow.py @@ -58,6 +58,28 @@ class TestStageCreation(SimpleTestCase): self.assertEqual(stage.name, name) self.assertEqual(stage.form, form) + def test_can_create_with_multi_phase_step(self): + first_phase, second_phase = Phase(name='first'), Phase(name='second') + change_first = ChangePhaseAction(first_phase, 'first') + change_second = ChangePhaseAction(second_phase, 'second') + + class PhaseSwitch(Phase): + actions = [change_first, change_second] + + class MultiPhaseStep(Stage): + name = 'stage' + phases = [ + PhaseSwitch(), + [first_phase, second_phase], + ] + + stage = MultiPhaseStep(None) + self.assertEqual(stage.steps, 2) + + current_phase = stage.phases[0] + self.assertEqual(current_phase.process(change_first.name), stage.phases[1]) # type: ignore + self.assertEqual(current_phase.process(change_second.name), stage.phases[2]) # type: ignore + def test_can_get_next_phase(self): stage = StageFactory.build(num_phases=2) self.assertEqual(stage.next(stage.phases[0]), stage.phases[1]) diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py index 27781e88163dd5250f14de6db72ed4cdc511dcf8..31b4536c2fada2df244a4cbcf9f25967c15379d5 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -1,18 +1,25 @@ from django import forms from django.template.response import TemplateResponse -from django.views.generic import DetailView +from django.views.generic import DetailView, UpdateView, View from django_filters.views import FilterView from django_tables2.views import SingleTableMixin -from opentech.apply.activity.views import CommentContextMixin, CommentFormView +from opentech.apply.activity.views import ( + AllActivityContextMixin, + ActivityContextMixin, + CommentFormView, + DelegatedViewMixin, +) +from opentech.apply.activity.models import Activity +from .forms import ProgressSubmissionForm, UpdateSubmissionLeadForm from .models import ApplicationSubmission from .tables import SubmissionsTable, SubmissionFilter, SubmissionFilterAndSearch from .workflow import SingleStage, DoubleStage -class SubmissionListView(SingleTableMixin, FilterView): +class SubmissionListView(AllActivityContextMixin, SingleTableMixin, FilterView): template_name = 'funds/submissions.html' table_class = SubmissionsTable @@ -42,13 +49,54 @@ class SubmissionSearchView(SingleTableMixin, FilterView): ) -class SubmissionDetailView(CommentContextMixin, DetailView): +class ProgressSubmissionView(DelegatedViewMixin, UpdateView): model = ApplicationSubmission + form_class = ProgressSubmissionForm + context_name = 'progress_form' + + def form_valid(self, form): + old_phase = form.instance.phase.name + response = super().form_valid(form) + new_phase = form.instance.phase.name + Activity.actions.create( + user=self.request.user, + submission=self.kwargs['submission'], + message=f'Progressed from {old_phase} to {new_phase}' + ) + return response + + +class UpdateLeadView(DelegatedViewMixin, UpdateView): + model = ApplicationSubmission + form_class = UpdateSubmissionLeadForm + context_name = 'lead_form' + + def form_valid(self, form): + old_lead = form.instance.lead + response = super().form_valid(form) + new_lead = form.instance.lead + Activity.actions.create( + user=self.request.user, + submission=self.kwargs['submission'], + message=f'Lead changed from {old_lead} to {new_lead}' + ) + return response + + +class SubmissionDetailView(ActivityContextMixin, DetailView): + model = ApplicationSubmission + form_views = { + 'progress': ProgressSubmissionView, + 'comment': CommentFormView, + 'update': UpdateLeadView, + } def get_context_data(self, **kwargs): + forms = dict(form_view.contribute_form(self.object) for form_view in self.form_views.values()) return super().get_context_data( other_submissions=self.model.objects.filter(user=self.object.user).exclude(id=self.object.id), - **kwargs + **forms, + **kwargs, ) def post(self, request, *args, **kwargs): @@ -60,7 +108,8 @@ class SubmissionDetailView(CommentContextMixin, DetailView): kwargs['template_names'] = self.get_template_names() kwargs['context'] = self.get_context_data() - view = CommentFormView.as_view() + form_submitted = request.POST['form-submitted'].lower() + view = self.form_views[form_submitted].as_view() return view(request, *args, **kwargs) diff --git a/opentech/apply/funds/workflow.py b/opentech/apply/funds/workflow.py index b356dd158876933ec3ab865c374fea8839389a40..36dbc2ec84b58cf47fc7d98675c5811fade3ef54 100644 --- a/opentech/apply/funds/workflow.py +++ b/opentech/apply/funds/workflow.py @@ -1,6 +1,7 @@ +from collections import defaultdict import copy -from typing import Iterable, Iterator, List, Sequence, Type, Union +from typing import Dict, Iterable, Iterator, List, Sequence, Type, Union from django.forms import Form from django.utils.text import slugify @@ -12,14 +13,14 @@ Workflow -> Stage -> Phase -> Action """ -def phase_name(stage: 'Stage', phase: Union['Phase', str], occurrence: int) -> str: +def phase_name(stage: 'Stage', phase: Union['Phase', str], step: int) -> str: # Build the identifiable name for a phase if not isinstance(phase, str): phase_name = phase._internal else: phase_name = phase - return '__'.join([stage.name, phase_name, str(occurrence)]) + return '__'.join([stage.name, phase_name, str(step)]) class Workflow(Iterable): @@ -113,27 +114,43 @@ class Stage(Iterable): # TODO: consider removing form from stage as the stage is generic and # shouldn't care about forms. self.form = form + self.steps = len(self.phases) # Make the phases new instances to prevent errors with mutability - existing_phases: set = set() - new_phases: list = list() - for phase in self.phases: - phase.stage = self - while str(phase) in existing_phases: - phase.occurrence += 1 - existing_phases.add(str(phase)) - new_phases.append(copy.copy(phase)) - self.phases = new_phases - - def __iter__(self) -> Iterator['Phase']: - yield from self.phases + self.phases = self.copy_phases(self.phases) + + def copy_phases(self, phases: List['Phase']) -> List['Phase']: + new_phases = list() + for step, phase in enumerate(self.phases): + try: + new_phases.append(self.copy_phase(phase, step)) + except AttributeError: + # We have a step with multiple equivalent phases + for sub_phase in phase: + new_phases.append(self.copy_phase(sub_phase, step)) + return new_phases + + def copy_phase(self, phase: 'Phase', step: int) -> 'Phase': + phase.stage = self + phase.step = step + return copy.copy(phase) + + def __iter__(self) -> 'PhaseIterator': + return PhaseIterator(self.phases, self.steps) def __str__(self) -> str: return self.name def get_phase(self, phase_name: str) -> 'Phase': for phase in self.phases: - if str(phase) == phase_name: + if phase == phase_name: + return phase + + # We don't have the exact name + for phase in self.phases: + if phase._internal == phase_name: + # Grab the first phase to match the name return phase + return None def first(self) -> 'Phase': @@ -152,6 +169,45 @@ class Stage(Iterable): return None +class PhaseIterator(Iterator): + class Step: + """Allow handling phases which are equivalent e.g. outcomes (accepted/rejected) + Delegates to the underlying phases except where naming is concerned + """ + def __init__(self, phases: List['Phase']) -> None: + self.phases = phases + + @property + def step(self) -> int: + return self.phases[0].step + + @property + def name(self) -> str: + # Hardcode a name for multi-phased step - always outcome at the moment + if len(self.phases) > 1: + return 'Outcome' + return self.phases[0].name + + def __eq__(self, other: object) -> bool: + return any(phase == other for phase in self.phases) + + def __init__(self, phases: List['Phase'], steps: int) -> None: + self.current = 0 + self.phases: Dict[int, List['Phase']] = defaultdict(list) + for phase in phases: + self.phases[phase.step].append(phase) + self.steps = steps + + def __iter__(self) -> 'PhaseIterator': + return self + + def __next__(self) -> 'Step': + self.current += 1 + if self.current > self.steps: + raise StopIteration + return self.Step(self.phases[self.current - 1]) + + class Phase: """ Holds the Actions which a user can perform at each stage. A Phase with no actions is @@ -173,12 +229,12 @@ class Phase: self._internal = slugify(self.name) self.stage: Union['Stage', None] = None self._actions = {action.name: action for action in self.actions} - self.occurrence: int = 0 + self.step: int = 0 def __eq__(self, other: Union[object, str]) -> bool: if isinstance(other, str): return str(self) == other - to_match = ['name', 'occurrence'] + to_match = ['name', 'step'] return all(getattr(self, attr) == getattr(other, attr) for attr in to_match) @property @@ -186,7 +242,7 @@ class Phase: return list(self._actions.keys()) def __str__(self) -> str: - return phase_name(self.stage, self, self.occurrence) + return phase_name(self.stage, self, self.step) def __getitem__(self, value: str) -> 'Action': return self._actions[value] @@ -218,7 +274,7 @@ class ChangePhaseAction(Action): def process(self, phase: 'Phase') -> Union['Phase', None]: if isinstance(self.target_phase, str): - return phase.stage.get_phase(phase_name(phase.stage, self.target_phase, 0)) + return phase.stage.get_phase(self.target_phase) return self.target_phase @@ -275,8 +331,7 @@ class RequestStage(Stage): DiscussionWithNextPhase(), ReviewPhase(), DiscussionPhase(), - accepted, - rejected, + [accepted, rejected] ] @@ -285,8 +340,7 @@ class ConceptStage(Stage): phases = [ DiscussionWithNextPhase(), ReviewPhase(), - DiscussionWithProgressionPhase(), - rejected, + [DiscussionWithProgressionPhase(), rejected] ] @@ -298,8 +352,7 @@ class ProposalStage(Stage): DiscussionWithNextPhase(), ReviewPhase('AC Review', public_name='In AC review'), DiscussionPhase(public_name='In AC review'), - accepted, - rejected, + [accepted, rejected] ]