diff --git a/opentech/apply/activity/__init__.py b/opentech/apply/activity/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/activity/apps.py b/opentech/apply/activity/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..34cfcc035c5ba7b644b8d40ef0f61bd953171b31 --- /dev/null +++ b/opentech/apply/activity/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ActivityConfig(AppConfig): + name = 'activity' diff --git a/opentech/apply/activity/forms.py b/opentech/apply/activity/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..1931ffde3a71848d774ef2b3251679a640e5eea1 --- /dev/null +++ b/opentech/apply/activity/forms.py @@ -0,0 +1,9 @@ +from django import forms + +from .models import Activity + + +class CommentForm(forms.ModelForm): + class Meta: + model = Activity + fields = ('message',) diff --git a/opentech/apply/activity/migrations/0001_initial.py b/opentech/apply/activity/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..b5e7d8c3be47f7ad5afd532ae439e69b16fb976d --- /dev/null +++ b/opentech/apply/activity/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2018-02-28 11:03 +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): + + initial = True + + dependencies = [ + ('funds', '0025_update_with_file_blocks'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Activity', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('message', models.TextField()), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='funds.ApplicationSubmission', related_name='activities')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-timestamp'], + }, + ), + ] 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..bc7ca42a580b691ee57c12663478579882542ebf --- /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'), ('action', 'Action')], default='comment', max_length=30), + preserve_default=False, + ), + ] diff --git a/opentech/apply/activity/migrations/__init__.py b/opentech/apply/activity/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/activity/models.py b/opentech/apply/activity/models.py new file mode 100644 index 0000000000000000000000000000000000000000..ae0acc1145982ecaa2a44d76c5c9a88153b17a2b --- /dev/null +++ b/opentech/apply/activity/models.py @@ -0,0 +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 new file mode 100644 index 0000000000000000000000000000000000000000..c20300aec2bd4315b05c7d672885b34c9c9e602d --- /dev/null +++ b/opentech/apply/activity/templates/activity/include/comment_form.html @@ -0,0 +1,5 @@ +<form method="post" id="comment-form"> + {% csrf_token %} + {{ comment_form }} + <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 new file mode 100644 index 0000000000000000000000000000000000000000..cfd58fa44049d4fd9544e96d4aa58ddb506e2de1 --- /dev/null +++ b/opentech/apply/activity/templates/activity/include/comment_list.html @@ -0,0 +1,3 @@ +{% for comment in comments %} + {% 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 new file mode 100644 index 0000000000000000000000000000000000000000..c680bda9d570f291388d6425936e9c4182aa7fc9 --- /dev/null +++ b/opentech/apply/activity/views.py @@ -0,0 +1,63 @@ +from django.views.generic import CreateView, View + +from .forms import CommentForm +from .models import Activity, COMMENT + + +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 = { + 'actions': Activity.actions.filter(submission=self.object), + 'comments': Activity.comments.filter(submission=self.object), + } + + return super().get_context_data(**extra, **kwargs) + + +class DelegatedViewMixin(View): + """For use on create views accepting forms from another view""" + def get_template_names(self): + return self.kwargs['template_names'] + + def get_context_data(self, **kwargs): + # Use the previous context but override the validated form + form = kwargs.pop('form') + kwargs.update(self.kwargs['context']) + 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(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.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..0de2d45500fde063fcfaf1c43aafa2be0ee3bf2b 100644 --- a/opentech/apply/funds/forms.py +++ b/opentech/apply/funds/forms.py @@ -1,34 +1,36 @@ -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] + action_field = self.fields['action'] + action_field.choices = choices + action_field.label = f'Current status: {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) + lead_field = self.fields['lead'] + lead_field.label = f'Update lead from { self.instance.lead } to' + lead_field.queryset = lead_field.queryset.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 5e67acccff3944518b1a0f3685e38413e800f771..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()) @@ -558,6 +571,9 @@ class ApplicationSubmission(WorkflowHelpers, AbstractFormSubmission): return form_data + def get_absolute_url(self): + return reverse('funds:submission', args=(self.id,)) + def __getattr__(self, item): # fall back to values defined on the data if item in REQUIRED_BLOCK_NAMES: @@ -565,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 fd5c00db4c56bff923bbd76dbfc07f4ae3e9032f..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> @@ -59,6 +61,9 @@ {% endfor %} </div> {% 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..e537515025ce4cea59a39f9c96c8e192d1455c3d 100644 --- a/opentech/apply/funds/templates/funds/includes/status_bar.html +++ b/opentech/apply/funds/templates/funds/includes/status_bar.html @@ -1,17 +1,27 @@ <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 %} + {# 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 %} + ></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/factories/models.py b/opentech/apply/funds/tests/factories/models.py index 11253d317fef1b31999f806812a880d135b9652b..87d4ffcf551dea830299f5e1412c56edf4e3fd73 100644 --- a/opentech/apply/funds/tests/factories/models.py +++ b/opentech/apply/funds/tests/factories/models.py @@ -121,6 +121,7 @@ class LabFactory(wagtail_factories.PageFactory): # Will need to update how the stages are identified as Fund Page changes workflow_name = factory.LazyAttribute(lambda o: list(FundType.WORKFLOWS.keys())[o.workflow_stages - 1]) + lead = factory.SubFactory(UserFactory, groups__name=STAFF_GROUP_NAME) class LabFormFactory(AbstractRelatedFormFactory): diff --git a/opentech/apply/funds/tests/test_models.py b/opentech/apply/funds/tests/test_models.py index 4f2e4517aa95ea00c6c56ef75b9149bc6ccae8d3..348486c45843031a19bce56e317cda2bfd73dd75 100644 --- a/opentech/apply/funds/tests/test_models.py +++ b/opentech/apply/funds/tests/test_models.py @@ -202,7 +202,7 @@ class TestFormSubmission(TestCase): self.round_page = RoundFactory(parent=fund) RoundFormFactory(round=self.round_page, form=form) - self.lab_page = LabFactory() + self.lab_page = LabFactory(lead=self.round_page.lead) LabFormFactory(lab=self.lab_page, form=form) def submit_form(self, page=None, email=None, name=None, user=AnonymousUser()): 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 6068b7cca4cdf15a0530f7c3cca1a9791ad2134e..cc28bdbff046fb84a8c23c3e8e1a9cc7a9e83d17 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -1,16 +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 from django_filters.views import FilterView from django_tables2.views import SingleTableMixin +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 @@ -40,15 +49,71 @@ class SubmissionSearchView(SingleTableMixin, FilterView): ) -class SubmissionDetailView(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): + # Fetch the old lead from the database + old_lead = self.get_object().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): + self.object = self.get_object() + + kwargs['submission'] = self.object + + # Information to pretend we originate from this view + kwargs['template_names'] = self.get_template_names() + kwargs['context'] = self.get_context_data() + + form_submitted = request.POST['form-submitted'].lower() + view = self.form_views[form_submitted].as_view() + + return view(request, *args, **kwargs) + workflows = [SingleStage, DoubleStage] diff --git a/opentech/apply/funds/workflow.py b/opentech/apply/funds/workflow.py index b356dd158876933ec3ab865c374fea8839389a40..1f621ec609ed66a582b416f3ec84f94fcc3b7e8d 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 @@ -9,17 +10,26 @@ from django.utils.text import slugify This file defines classes which allow you to compose workflows based on the following structure: Workflow -> Stage -> Phase -> Action + +These classes are designed such that they can be mapped to a wagtail streamfield to allow admins +to build/adjust workflows as required. + +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] +* Outcomes are a special case of phase and perhaps should be handled separately. [will look at when +progressing stages] """ -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 +123,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 +178,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 +238,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 +251,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 +283,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 +340,7 @@ class RequestStage(Stage): DiscussionWithNextPhase(), ReviewPhase(), DiscussionPhase(), - accepted, - rejected, + [accepted, rejected] ] @@ -285,8 +349,7 @@ class ConceptStage(Stage): phases = [ DiscussionWithNextPhase(), ReviewPhase(), - DiscussionWithProgressionPhase(), - rejected, + [DiscussionWithProgressionPhase(), rejected] ] @@ -298,8 +361,7 @@ class ProposalStage(Stage): DiscussionWithNextPhase(), ReviewPhase('AC Review', public_name='In AC review'), DiscussionPhase(public_name='In AC review'), - accepted, - rejected, + [accepted, rejected] ] diff --git a/opentech/settings/base.py b/opentech/settings/base.py index 3e9b3885934f75879296b689ce3bfd3dd62a1b74..ba29eb1f5f9f281f3d817f25605b5205e051eba6 100644 --- a/opentech/settings/base.py +++ b/opentech/settings/base.py @@ -14,6 +14,7 @@ BASE_DIR = os.path.dirname(PROJECT_DIR) INSTALLED_APPS = [ 'opentech.images', + 'opentech.apply.activity', 'opentech.apply.categories', 'opentech.apply.funds', 'opentech.apply.dashboard', diff --git a/requirements.txt b/requirements.txt index 9a8e1221bbcdd67d36852ee7c27ba38e1461334c..110437cac5a56fb9110ca0a2dfdbd7f639d69cbd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ uwsgidecorators==1.1.0 mypy==0.550 factory_boy==2.9.2 # wagtail_factories - waiting on merge and release form master branch -git+git://github.com/todd-dembrey/wagtail-factories.git#egg=wagtail_factories +git+git://github.com/todd-dembrey/wagtail-factories.git@a3131766ac21f0d1df18e5f19bdf2dfadd073479#egg=wagtail_factories flake8