diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index a067900c00be2986f45590e369374f1a173747cd..c8e6c41b1d5c79129c119f0670e628e5d479722b 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -44,6 +44,7 @@ neat_related = { MESSAGES.UPDATE_LEAD: 'old_lead', MESSAGES.NEW_REVIEW: 'review', MESSAGES.TRANSITION: 'old_phase', + MESSAGES.BATCH_TRANSITION: 'transitions', MESSAGES.APPLICANT_EDIT: 'revision', MESSAGES.EDIT: 'revision', MESSAGES.COMMENT: 'comment', @@ -52,6 +53,14 @@ neat_related = { } +def is_transition(message_type): + return message_type in [MESSAGES.TRANSITION, MESSAGES.BATCH_TRANSITION] + + +def is_ready_for_review(message_type): + return message_type in [MESSAGES.READY_FOR_REVIEW, MESSAGES.BATCH_READY_FOR_REVIEW] + + class AdapterBase: messages = {} always_send = False @@ -183,6 +192,7 @@ class ActivityAdapter(AdapterBase): always_send = True messages = { MESSAGES.TRANSITION: 'handle_transition', + MESSAGES.BATCH_TRANSITION: 'handle_batch_transition', MESSAGES.NEW_SUBMISSION: 'Submitted {submission.title} for {submission.page.title}', MESSAGES.EDIT: 'Edited', MESSAGES.APPLICANT_EDIT: 'Edited', @@ -200,11 +210,13 @@ class ActivityAdapter(AdapterBase): def recipients(self, message_type, **kwargs): return [None] - def extra_kwargs(self, message_type, submission, **kwargs): + def extra_kwargs(self, message_type, submission, submissions, **kwargs): from .models import INTERNAL if message_type in [MESSAGES.OPENED_SEALED, MESSAGES.REVIEWERS_UPDATED, MESSAGES.SCREENING]: return {'visibility': INTERNAL} - if message_type == MESSAGES.TRANSITION and not submission.phase.permissions.can_view(submission.user): + + submission = submission or submissions[0] + if is_transition(message_type) and not submission.phase.permissions.can_view(submission.user): # User's shouldn't see status activity changes for stages that aren't visible to the them return {'visibility': INTERNAL} return {} @@ -251,6 +263,12 @@ class ActivityAdapter(AdapterBase): return staff_message + def handle_batch_transition(self, transitions, submissions, **kwargs): + kwargs.pop('submission') + for submission in submissions: + old_phase = transitions[submission.id] + return self.handle_transition(old_phase=old_phase, submission=submission, **kwargs) + def send_message(self, message, user, submission, submissions, **kwargs): from .models import Activity, PUBLIC visibility = kwargs.get('visibility', PUBLIC) @@ -289,6 +307,7 @@ class SlackAdapter(AdapterBase): MESSAGES.REVIEWERS_UPDATED: 'reviewers_updated', MESSAGES.BATCH_REVIEWERS_UPDATED: 'handle_batch_reviewers', MESSAGES.TRANSITION: '{user} has updated the status of <{link}|{submission.title}>: {old_phase.display_name} → {submission.phase}', + MESSAGES.BATCH_TRANSITION: 'handle_batch_transition', MESSAGES.DETERMINATION_OUTCOME: 'A determination for <{link}|{submission.title}> was sent by email. Outcome: {determination.clean_outcome}', MESSAGES.PROPOSAL_SUBMITTED: 'A proposal has been submitted for review: <{link}|{submission.title}>', MESSAGES.INVITED_TO_PROPOSAL: '<{link}|{submission.title}> by {submission.user} has been invited to submit a proposal', @@ -296,6 +315,8 @@ class SlackAdapter(AdapterBase): MESSAGES.READY_FOR_REVIEW: 'notify_reviewers', MESSAGES.OPENED_SEALED: '{user} has opened the sealed submission: <{link}|{submission.title}>', MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {submission}', + MESSAGES.BATCH_READY_FOR_REVIEW: 'batch_notify_reviewers', + } def __init__(self): @@ -303,6 +324,12 @@ class SlackAdapter(AdapterBase): self.destination = settings.SLACK_DESTINATION_URL self.target_room = settings.SLACK_DESTINATION_ROOM + def slack_links(self, links, submissions): + return ', '.join( + f'<{links[submission.id]}|{submission.title}>' + for submission in submissions + ) + def extra_kwargs(self, message_type, **kwargs): submission = kwargs['submission'] submissions = kwargs['submissions'] @@ -344,10 +371,7 @@ class SlackAdapter(AdapterBase): return ' '.join(message) def handle_batch_reviewers(self, submissions, links, user, added, **kwargs): - submissions_text = ', '.join( - f'<{links[submission.id]}|{submission.title}>' - for submission in submissions - ) + submissions_text = self.slack_links(links, submissions) reviewers_text = ', '.join([str(user) for user in added]) return ( '{user} has batch added {reviewers_text} as reviewers on: {submissions_text}'.format( @@ -357,7 +381,23 @@ class SlackAdapter(AdapterBase): ) ) - def notify_reviewers(self, submission, **kwargs): + def handle_batch_transition(self, user, links, submissions, transitions, **kwargs): + submissions_text = [ + ': '.join([ + self.slack_links(links, [submission]), + f'{transitions[submission.id].display_name} → {submission.phase}', + ]) + for submission in submissions + ] + submissions_links = ','.join(submissions_text) + return ( + '{user} has transitioned the following submissions: {submissions_links}'.format( + user=user, + submissions_links=submissions_links, + ) + ) + + def notify_reviewers(self, submission, link, **kwargs): reviewers_to_notify = [] for reviewer in submission.reviewers.all(): if submission.phase.permissions.can_review(reviewer): @@ -369,12 +409,20 @@ class SlackAdapter(AdapterBase): return ( '<{link}|{submission.title}> is ready for review. The following are assigned as reviewers: {reviewers}'.format( + link=link, reviewers=reviewers, submission=submission, - **kwargs ) ) + def batch_notify_reviewers(self, submissions, links, **kwargs): + kwargs.pop('submission') + kwargs.pop('link') + return '. '.join( + self.notify_reviewers(submission, link=links[submission.id], **kwargs) + for submission in submissions + ) + def slack_id(self, user): if user.slack: return f'<{user.slack}>' @@ -432,14 +480,16 @@ class EmailAdapter(AdapterBase): MESSAGES.COMMENT: 'notify_comment', MESSAGES.EDIT: 'messages/email/edit.html', MESSAGES.TRANSITION: 'messages/email/transition.html', + MESSAGES.BATCH_TRANSITION: 'handle_batch_transition', MESSAGES.DETERMINATION_OUTCOME: 'messages/email/determination.html', MESSAGES.INVITED_TO_PROPOSAL: 'messages/email/invited_to_proposal.html', + MESSAGES.BATCH_READY_FOR_REVIEW: 'messages/email/batch_ready_to_review.html', MESSAGES.READY_FOR_REVIEW: 'messages/email/ready_to_review.html', } def get_subject(self, message_type, submission): if submission: - if message_type == MESSAGES.READY_FOR_REVIEW: + if is_ready_for_review(message_type): subject = 'Application ready to review: {submission.title}'.format(submission=submission) else: subject = submission.page.specific.subject or 'Your application to Open Technology Fund: {submission.title}'.format(submission=submission) @@ -450,6 +500,17 @@ class EmailAdapter(AdapterBase): 'subject': self.get_subject(message_type, submission), } + def handle_batch_transition(self, transitions, submissions, **kwargs): + kwargs.pop('submission') + for submission in submissions: + old_phase = transitions[submission.id] + return self.render_message( + 'messages/email/transition.html', + submission=submission, + old_phase=old_phase, + **kwargs + ) + def notify_comment(self, **kwargs): comment = kwargs['comment'] submission = kwargs['submission'] @@ -457,15 +518,32 @@ class EmailAdapter(AdapterBase): return self.render_message('messages/email/comment.html', **kwargs) def recipients(self, message_type, submission, **kwargs): - if message_type == MESSAGES.READY_FOR_REVIEW: + if is_ready_for_review(message_type): return self.reviewers(submission) - if message_type == MESSAGES.TRANSITION: + if is_transition(message_type): # Only notify the applicant if the new phase can be seen within the workflow if not submission.phase.permissions.can_view(submission.user): return [] return [submission.user.email] + def batch_recipients(self, message_type, submissions, **kwargs): + if not is_ready_for_review(message_type): + return super().batch_recipients(message_type, submissions, **kwargs) + + reviewers_to_message = defaultdict(list) + for submission in submissions: + reviewers = self.reviewers(submission) + for reviewer in reviewers: + reviewers_to_message[reviewer].append(submission) + + return [ + { + 'recipients': [reviewer], + 'submissions': submissions, + } for reviewer, submissions in reviewers_to_message.items() + ] + def reviewers(self, submission): return [ reviewer.email @@ -495,6 +573,7 @@ class DjangoMessagesAdapter(AdapterBase): messages = { MESSAGES.BATCH_REVIEWERS_UPDATED: 'batch_reviewers_updated', + MESSAGES.BATCH_TRANSITION: 'batch_transition', } def batch_reviewers_updated(self, added, submissions, **kwargs): @@ -505,6 +584,19 @@ class DjangoMessagesAdapter(AdapterBase): ', '.join(['"{}"'.format(submission.title) for submission in submissions]) ) + def batch_transition(self, submissions, transitions, **kwargs): + base_message = 'Successfully updated:' + transition = '{submission} [{old_display} → {new_display}].' + transition_messages = [ + transition.format( + submission=submission.title, + old_display=transitions[submission.id], + new_display=submission.phase, + ) for submission in submissions + ] + messages = [base_message, *transition_messages] + return ' '.join(messages) + def recipients(self, *args, **kwargs): return [None] diff --git a/opentech/apply/activity/migrations/0015_add_batch_transition.py b/opentech/apply/activity/migrations/0015_add_batch_transition.py new file mode 100644 index 0000000000000000000000000000000000000000..539c08b218900e4610fe896aa84dcab174319b5c --- /dev/null +++ b/opentech/apply/activity/migrations/0015_add_batch_transition.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.9 on 2019-02-16 22:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0014_add_batch_reviewer_message'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('READY_FOR_REVIEW', 'Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0016_add_batch_ready.py b/opentech/apply/activity/migrations/0016_add_batch_ready.py new file mode 100644 index 0000000000000000000000000000000000000000..558e135df3df7c0eec8526e767c9ebf64f8854d6 --- /dev/null +++ b/opentech/apply/activity/migrations/0016_add_batch_ready.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.9 on 2019-02-17 09:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0015_add_batch_transition'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index f5f70f4b2bae6e0a75895d73407aebfe389a4f27..7041ce9e343aa4bbf7782417d2c4786e5cbc9e7e 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -8,11 +8,13 @@ class MESSAGES(Enum): NEW_SUBMISSION = 'New Submission' SCREENING = 'Screening' TRANSITION = 'Transition' + BATCH_TRANSITION = 'Batch Transition' DETERMINATION_OUTCOME = 'Determination Outcome' INVITED_TO_PROPOSAL = 'Invited To Proposal' REVIEWERS_UPDATED = 'Reviewers Updated' BATCH_REVIEWERS_UPDATED = 'Batch Reviewers Updated' READY_FOR_REVIEW = 'Ready For Review' + BATCH_READY_FOR_REVIEW = 'Batch Ready For Review' NEW_REVIEW = 'New Review' COMMENT = 'Comment' PROPOSAL_SUBMITTED = 'Proposal Submitted' diff --git a/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html b/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html new file mode 100644 index 0000000000000000000000000000000000000000..25d2aa7a6ab25a1c1c7e99841e5af8449ffde663 --- /dev/null +++ b/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html @@ -0,0 +1,12 @@ +{% extends "messages/email/base.html" %} +{% block salutation %}Dear Reviewer,{% endblock %} + +{% block content %} +New proposals have been added to your review list. +{% for submission in submissions %} + +Title: {{ submission.title }} +Link: {{ request.scheme }}://{{ request.get_host }}{{ submission.get_absolute_url }} +{% endfor %} + +{% endblock %} diff --git a/opentech/apply/activity/templates/messages/email/transition.html b/opentech/apply/activity/templates/messages/email/transition.html index a10355e55628dcee05f6bdf21982405e3105f742..522c08cf89bd1af54aab399d6313418cf887ea0c 100644 --- a/opentech/apply/activity/templates/messages/email/transition.html +++ b/opentech/apply/activity/templates/messages/email/transition.html @@ -1,3 +1,3 @@ {% extends "messages/email/applicant_base.html" %} -{% block content %}Your application has been progressed from {{ old_phase.display_name }} to {{ submission.phase }}.{% endblock %} +{% block content %}Your application has been progressed from {{ old_phase.public_name }} to {{ submission.phase.public_name }}.{% endblock %} diff --git a/opentech/apply/activity/tests/test_messaging.py b/opentech/apply/activity/tests/test_messaging.py index e2c8b6318234ba997d35a0d04d96a0b259e23e23..c7e1d854021a069fa7ae5264592e0979571c5f5e 100644 --- a/opentech/apply/activity/tests/test_messaging.py +++ b/opentech/apply/activity/tests/test_messaging.py @@ -247,13 +247,13 @@ class TestActivityAdapter(TestCase): def test_internal_transition_kwarg_for_invisible_transition(self): submission = ApplicationSubmissionFactory(status='post_review_discussion') - kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission) + kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission, submissions=None) self.assertEqual(kwargs['visibility'], INTERNAL) def test_public_transition_kwargs(self): submission = ApplicationSubmissionFactory() - kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission) + kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission, submissions=None) self.assertNotIn('visibility', kwargs) diff --git a/opentech/apply/activity/views.py b/opentech/apply/activity/views.py index 12600a8bd8033929a4513bcc34f4511fd1a92289..07ccbd32c0358a21384d9ac4eab48519af0808e9 100644 --- a/opentech/apply/activity/views.py +++ b/opentech/apply/activity/views.py @@ -71,6 +71,6 @@ class CommentFormView(DelegatedViewMixin, CreateView): return self.object.submission.get_absolute_url() + '#communications' @classmethod - def contribute_form(cls, submission, user): + def contribute_form(cls, instance, user): # We dont want to pass the submission as the instance - return super().contribute_form(None, user=user) + return super().contribute_form(instance=None, user=user) diff --git a/opentech/apply/funds/forms.py b/opentech/apply/funds/forms.py index 0a71b92e8892a8f0e27251e79c5abb814e7595d5..8c51547999823658ff3b4ec665c765c19c5976a9 100644 --- a/opentech/apply/funds/forms.py +++ b/opentech/apply/funds/forms.py @@ -6,6 +6,7 @@ from opentech.apply.users.models import User from .models import ApplicationSubmission, AssignedReviewers, ReviewerRole from .widgets import Select2MultiCheckboxesWidget, Select2IconWidget +from .workflow import get_action_mapping class ProgressSubmissionForm(forms.ModelForm): @@ -24,6 +25,29 @@ class ProgressSubmissionForm(forms.ModelForm): self.should_show = bool(choices) +class BatchProgressSubmissionForm(forms.Form): + action = forms.ChoiceField(label='Take action') + submissions = forms.CharField(widget=forms.HiddenInput(attrs={'class': 'js-submissions-id'})) + + def __init__(self, *args, round=None, **kwargs): + self.user = kwargs.pop('user') + super().__init__(*args, **kwargs) + workflow = round and round.workflow + self.action_mapping = get_action_mapping(workflow) + choices = [(action, detail['display']) for action, detail in self.action_mapping.items()] + self.fields['action'].choices = choices + + def clean_submissions(self): + value = self.cleaned_data['submissions'] + submission_ids = [int(submission) for submission in value.split(',')] + return ApplicationSubmission.objects.filter(id__in=submission_ids) + + def clean_action(self): + value = self.cleaned_data['action'] + action = self.action_mapping[value]['transitions'] + return action + + class ScreeningSubmissionForm(forms.ModelForm): class Meta: @@ -197,8 +221,12 @@ class BatchUpdateReviewersForm(forms.Form): queryset=User.objects.staff(), widget=Select2MultiCheckboxesWidget(attrs={'data-placeholder': 'Staff'}), ) - submission_ids = forms.CharField(widget=forms.HiddenInput()) + submissions = forms.CharField(widget=forms.HiddenInput(attrs={'class': 'js-submissions-id'})) + + def __init__(self, *args, user=None, round=None, **kwargs): + super().__init__(*args, **kwargs) - def clean_submission_ids(self): - value = self.cleaned_data['submission_ids'] - return [int(submission) for submission in value.split(',')] + def clean_submissions(self): + value = self.cleaned_data['submissions'] + submission_ids = [int(submission) for submission in value.split(',')] + return ApplicationSubmission.objects.filter(id__in=submission_ids) diff --git a/opentech/apply/funds/tables.py b/opentech/apply/funds/tables.py index a515aa6b5cff5c9fa3341424eff3433f725e74d1..198373c993c2a89ee1e841ea721bfce45670887c 100644 --- a/opentech/apply/funds/tables.py +++ b/opentech/apply/funds/tables.py @@ -1,3 +1,4 @@ +import json import textwrap from django import forms @@ -25,6 +26,12 @@ def make_row_class(record): return css_class +def render_actions(table, record): + user = table.context['user'] + actions = record.get_actions_for_user(user) + return json.dumps([slugify(action) for _, action in actions]) + + def render_title(record): return textwrap.shorten(record.title, width=30, placeholder="...") @@ -33,7 +40,7 @@ class SubmissionsTable(tables.Table): """Base table for listing submissions, do not include admin data to this table""" title = tables.LinkColumn('funds:submissions:detail', text=render_title, args=[A('pk')], orderable=True, attrs={'td': {'data-tooltip': lambda record: record.title, 'class': 'js-title'}}) submit_time = tables.DateColumn(verbose_name="Submitted") - phase = tables.Column(verbose_name="Status", order_by=('status',)) + phase = tables.Column(verbose_name="Status", order_by=('status',), attrs={'td': {'data-actions': render_actions, 'class': 'js-actions'}}) stage = tables.Column(verbose_name="Type", order_by=('status',)) fund = tables.Column(verbose_name="Fund", accessor='page') comments = tables.Column(accessor='comment_count', verbose_name="Comments") diff --git a/opentech/apply/funds/templates/funds/includes/batch_progress_form.html b/opentech/apply/funds/templates/funds/includes/batch_progress_form.html new file mode 100644 index 0000000000000000000000000000000000000000..20943a97776d577586d9301ee0d8d8d591a0516f --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/batch_progress_form.html @@ -0,0 +1,9 @@ +<div class="modal modal--secondary" id="batch-progress"> + <h4 class="modal__header-bar">Update Status</h4> + <div class="modal__list-item modal__list-item--meta" aria-live="polite"> + <span class="js-batch-title-count"></span> + <a href="#" class="modal__hide-link js-toggle-batch-list">Show</a> + </div> + <div class="modal__list js-batch-titles is-closed" aria-live="polite"></div> + {% include 'funds/includes/delegated_form_base.html' with form=batch_progress_form value='Update'%} +</div> diff --git a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html index ba2f54d0b562e7ae0d35414fdbbda299eb7e45d8..2b4727e2b5128931ddd9b5b5621368b39901e32d 100644 --- a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html +++ b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html @@ -6,14 +6,14 @@ <h4 class="heading heading--normal heading--no-margin">{{ heading }}</h4> {% endif %} + {% if use_batch_actions %} <div class="actions-bar__inner actions-bar__inner--batch-actions"> <p class="actions-bar__total"><span class="js-total-actions">0</span> Selected</p> - <form action="" class="js-batch-update-status"> - <button class="button button--action button--change-status" type="submit">Change status</button> - </form> - <button data-fancybox data-src="#batch-update-reviewers" class="button button--action button--reviewers js-batch-update-reviewers" type="button">Reviewers</button> + <button data-fancybox data-src="#batch-progress" class="button button--action button--batch-status js-batch-button js-batch-progress" type="button">Status</button> + + <button data-fancybox data-src="#batch-update-reviewers" class="button button--action button--reviewers js-batch-button" type="button">Reviewers</button> </div> {% endif %} </div> @@ -53,3 +53,4 @@ </div> {% include "funds/includes/batch_update_reviewer_form.html" %} +{% include "funds/includes/batch_progress_form.html" %} diff --git a/opentech/apply/funds/tests/views/test_batch_progress.py b/opentech/apply/funds/tests/views/test_batch_progress.py new file mode 100644 index 0000000000000000000000000000000000000000..4d18a380f1a617e5e9adc12389df810464e79789 --- /dev/null +++ b/opentech/apply/funds/tests/views/test_batch_progress.py @@ -0,0 +1,110 @@ +from unittest import mock + +from opentech.apply.funds.models import ApplicationSubmission + +from opentech.apply.funds.tests.factories import ( + ApplicationSubmissionFactory, + InvitedToProposalFactory, +) +from opentech.apply.users.tests.factories import ( + ReviewerFactory, + StaffFactory, + UserFactory, +) +from opentech.apply.utils.testing.tests import BaseViewTestCase + + +class BaseBatchProgressViewTestCase(BaseViewTestCase): + url_name = 'funds:submissions:{}' + base_view_name = 'list' + + def data(self, action, submissions): + return { + 'form-submitted-batch_progress_form': 'Update', + 'action': action, + 'submissions': ','.join([str(submission.id) for submission in submissions]), + } + + +class StaffTestCase(BaseBatchProgressViewTestCase): + user_factory = StaffFactory + + def test_can_progress_application(self): + submission = ApplicationSubmissionFactory() + action = 'open-review' + self.post_page(data=self.data(action, [submission])) + submission = self.refresh(submission) + self.assertEqual(submission.status, 'internal_review') + + def test_can_progress_multiple_applications(self): + submissions = ApplicationSubmissionFactory.create_batch(3) + action = 'open-review' + self.post_page(data=self.data(action, submissions)) + + self.assertCountEqual( + [self.refresh(submission).status for submission in submissions], + ['internal_review'] * 3, + ) + + def test_cant_progress_in_incorrect_state(self): + submission = ApplicationSubmissionFactory() + action = 'close-review' + self.post_page(data=self.data(action, [submission])) + submission = self.refresh(submission) + self.assertEqual(submission.status, 'in_discussion') + + def test_can_progress_one_in_mixed_state(self): + bad_submission = ApplicationSubmissionFactory() + good_submission = ApplicationSubmissionFactory(status='internal_review') + action = 'close-review' + self.post_page(data=self.data(action, [good_submission, bad_submission])) + good_submission = self.refresh(good_submission) + bad_submission = self.refresh(bad_submission) + self.assertEqual(bad_submission.status, 'in_discussion') + self.assertEqual(good_submission.status, 'post_review_discussion') + + def test_can_progress_different_states(self): + submission = ApplicationSubmissionFactory() + other_submission = InvitedToProposalFactory() + action = 'open-review' + self.post_page(data=self.data(action, [submission, other_submission])) + submission = self.refresh(submission) + other_submission = self.refresh(other_submission) + self.assertEqual(submission.status, 'internal_review') + self.assertEqual(other_submission.status, 'proposal_internal_review') + + @mock.patch('opentech.apply.funds.views.messenger') + def test_messenger_not_called_with_failed(self, patched): + submission = ApplicationSubmissionFactory() + action = 'close-review' + self.post_page(data=self.data(action, [submission])) + patched.assert_called_once() + _, _, kwargs = patched.mock_calls[0] + self.assertQuerysetEqual(kwargs['submissions'], ApplicationSubmission.objects.none()) + + @mock.patch('opentech.apply.funds.views.messenger') + def test_messenger_with_submission_in_review(self, patched): + submission = ApplicationSubmissionFactory() + action = 'open-review' + self.post_page(data=self.data(action, [submission])) + self.assertEqual(patched.call_count, 2) + _, _, kwargs = patched.mock_calls[0] + self.assertCountEqual(kwargs['submissions'], [submission]) + _, _, kwargs = patched.mock_calls[1] + self.assertCountEqual(kwargs['submissions'], [submission]) + + +class ReivewersTestCase(BaseBatchProgressViewTestCase): + user_factory = ReviewerFactory + + def test_cant_post_to_page(self): + response = self.post_page() + self.assertEqual(response.status_code, 405) + + +class ApplicantTestCase(BaseBatchProgressViewTestCase): + user_factory = UserFactory + + def test_cant_access_page_to_page(self): + response = self.post_page() + self.assertEqual(response.status_code, 403) diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py index bff504909e79e9fb2c3900294a85e579af155782..01659e1e832604ade8551660dc9610befa41bb46 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -27,12 +27,12 @@ from opentech.apply.activity.messaging import messenger, MESSAGES from opentech.apply.determinations.views import DeterminationCreateOrUpdateView from opentech.apply.review.views import ReviewContextMixin from opentech.apply.users.decorators import staff_required -from opentech.apply.users.models import User from opentech.apply.utils.views import DelegateableListView, DelegateableView, ViewDispatcher from .differ import compare from .forms import ( BatchUpdateReviewersForm, + BatchProgressSubmissionForm, ProgressSubmissionForm, ScreeningSubmissionForm, UpdateReviewersForm, @@ -54,7 +54,7 @@ from .tables import ( SubmissionReviewerFilterAndSearch, SummarySubmissionsTable, ) -from .workflow import STAGE_CHANGE_ACTIONS, PHASES_MAPPING +from .workflow import STAGE_CHANGE_ACTIONS, PHASES_MAPPING, review_statuses class BaseAdminSubmissionsTable(SingleTableMixin, FilterView): @@ -108,10 +108,9 @@ class BatchUpdateReviewersView(DelegatedViewMixin, FormView): currently saved to that submission. Send out a message of updates. """ - reviewers = User.objects.filter(id__in=form.cleaned_data['staff_reviewers']) + reviewers = form.cleaned_data['staff_reviewers'] - submission_ids = form.cleaned_data['submission_ids'] - submissions = ApplicationSubmission.objects.filter(id__in=submission_ids) + submissions = form.cleaned_data['submissions'] for submission in submissions: submission.reviewers.add(*reviewers) @@ -127,6 +126,64 @@ class BatchUpdateReviewersView(DelegatedViewMixin, FormView): return super().form_valid(form) +@method_decorator(staff_required, name='dispatch') +class BatchProgressSubmissionView(DelegatedViewMixin, FormView): + form_class = BatchProgressSubmissionForm + context_name = 'batch_progress_form' + + def form_valid(self, form): + submissions = form.cleaned_data['submissions'] + transitions = form.cleaned_data.get('action') + + failed = [] + phase_changes = {} + for submission in submissions: + valid_actions = {action for action, _ in submission.get_actions_for_user(self.request.user)} + old_phase = submission.phase + try: + transition = (valid_actions & set(transitions)).pop() + submission.perform_transition( + transition, + self.request.user, + request=self.request, + notify=False, + ) + except (PermissionDenied, KeyError): + failed.append(submission) + else: + phase_changes[submission.id] = old_phase + + if failed: + messages.warning( + self.request, + _('Failed to update: ') + + ', '.join(str(submission) for submission in failed) + ) + + succeeded_submissions = submissions.exclude(id__in=[submission.id for submission in failed]) + messenger( + MESSAGES.BATCH_TRANSITION, + user=self.request.user, + request=self.request, + submissions=succeeded_submissions, + related=phase_changes, + ) + + ready_for_review = [ + phase for phase in transitions + if phase in review_statuses + ] + if ready_for_review: + messenger( + MESSAGES.BATCH_READY_FOR_REVIEW, + user=self.request.user, + request=self.request, + submissions=succeeded_submissions.filter(status__in=ready_for_review), + ) + + return super().form_valid(form) + + class BaseReviewerSubmissionsTable(BaseAdminSubmissionsTable): table_class = ReviewerSubmissionsTable filterset_class = SubmissionReviewerFilterAndSearch @@ -182,7 +239,8 @@ class SubmissionOverviewView(AllActivityContextMixin, BaseAdminSubmissionsTable) class SubmissionAdminListView(AllActivityContextMixin, BaseAdminSubmissionsTable, DelegateableListView): template_name = 'funds/submissions.html' form_views = [ - BatchUpdateReviewersView + BatchUpdateReviewersView, + BatchProgressSubmissionView, ] @@ -199,11 +257,17 @@ class SubmissionListView(ViewDispatcher): class SubmissionsByRound(AllActivityContextMixin, BaseAdminSubmissionsTable, DelegateableListView): template_name = 'funds/submissions_by_round.html' form_views = [ - BatchUpdateReviewersView + BatchUpdateReviewersView, + BatchProgressSubmissionView, ] excluded_fields = ('round', 'lead', 'fund') + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['round'] = self.obj + return kwargs + def get_queryset(self): # We want to only show lab or Rounds in this view, their base class is Page try: @@ -220,19 +284,22 @@ class SubmissionsByRound(AllActivityContextMixin, BaseAdminSubmissionsTable, Del @method_decorator(staff_required, name='dispatch') -class SubmissionsByStatus(BaseAdminSubmissionsTable): +class SubmissionsByStatus(BaseAdminSubmissionsTable, DelegateableListView): template_name = 'funds/submissions_by_status.html' status_mapping = PHASES_MAPPING + form_views = [ + BatchUpdateReviewersView, + BatchProgressSubmissionView, + ] - def get(self, request, *args, **kwargs): + def dispatch(self, request, *args, **kwargs): self.status = kwargs.get('status') status_data = self.status_mapping[self.status] self.status_name = status_data['name'] self.statuses = status_data['statuses'] if self.status not in self.status_mapping: raise Http404(_("No statuses match the requested value")) - - return super().get(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def get_filterset_kwargs(self, filterset_class, **kwargs): return super().get_filterset_kwargs(filterset_class, limit_statuses=self.statuses, **kwargs) diff --git a/opentech/apply/funds/workflow.py b/opentech/apply/funds/workflow.py index cb5744d5a2480962f84ab8a82375c63375a8cfc3..58b017eae90a70b4c7ae2535b16fa3dbdab62a8a 100644 --- a/opentech/apply/funds/workflow.py +++ b/opentech/apply/funds/workflow.py @@ -2,6 +2,7 @@ from collections import defaultdict from enum import Enum import itertools +from django.utils.text import slugify """ This file defines classes which allow you to compose workflows based on the following structure: @@ -732,6 +733,23 @@ def get_determination_transitions(): return transitions +def get_action_mapping(workflow): + # Maps action names to the phase they originate from + transitions = defaultdict(lambda: {'display': '', 'transitions': []}) + if workflow: + phases = workflow.items() + else: + phases = PHASES + for phase_name, phase in phases: + for transition_name, transition in phase.transitions.items(): + transition_display = transition['display'] + transition_key = slugify(transition_display) + transitions[transition_key]['transitions'].append(transition_name) + transitions[transition_key]['display'] = transition_display + + return transitions + + DETERMINATION_OUTCOMES = get_determination_transitions() diff --git a/opentech/apply/utils/views.py b/opentech/apply/utils/views.py index 3e83f17d44a38942df0dc9f1133d0c3141c7cff2..1cd79277d148c4b15a67c4ab7ffa1563ab909d08 100644 --- a/opentech/apply/utils/views.py +++ b/opentech/apply/utils/views.py @@ -1,5 +1,6 @@ from django.contrib.auth.decorators import login_required from django.forms.models import ModelForm +from django.http import HttpResponseForbidden from django.utils.decorators import method_decorator from django.views import defaults from django.views.generic import View @@ -34,7 +35,9 @@ class ViewDispatcher(View): elif self.reviewer_check(request): view = self.reviewer_view - return view.as_view()(request, *args, **kwargs) + if view: + return view.as_view()(request, *args, **kwargs) + return HttpResponseForbidden() class DelegatableBase(ContextMixin): @@ -45,11 +48,12 @@ class DelegatableBase(ContextMixin): """ form_prefix = 'form-submitted-' - def get_form_args(self): - return (None, None) + def get_form_kwargs(self): + return {} def get_context_data(self, **kwargs): - forms = dict(form_view.contribute_form(*self.get_form_args()) for form_view in self.form_views) + form_kwargs = self.get_form_kwargs() + forms = dict(form_view.contribute_form(**form_kwargs) for form_view in self.form_views) return super().get_context_data( form_prefix=self.form_prefix, @@ -71,8 +75,11 @@ class DelegatableBase(ContextMixin): class DelegateableView(DelegatableBase): - def get_form_args(self): - return self.object, self.request.user + def get_form_kwargs(self): + return { + 'user': self.request.user, + 'instance': self.object, + } def post(self, request, *args, **kwargs): self.object = self.get_object() @@ -83,6 +90,11 @@ class DelegateableView(DelegatableBase): class DelegateableListView(DelegatableBase): + def get_form_kwargs(self): + return { + 'user': self.request.user, + } + def post(self, request, *args, **kwargs): self.object_list = self.get_queryset() return super().post(request, *args, **kwargs) @@ -96,8 +108,7 @@ class DelegatedViewMixin(View): def get_form_kwargs(self): kwargs = super().get_form_kwargs() - if self.is_model_form(): - kwargs['user'] = self.request.user + kwargs['user'] = self.request.user return kwargs def get_form(self, *args, **kwargs): @@ -117,16 +128,16 @@ class DelegatedViewMixin(View): return issubclass(cls.form_class, ModelForm) @classmethod - def contribute_form(cls, submission, user): - if cls.is_model_form(): - form = cls.form_class(instance=submission, user=user) - else: - form = cls.form_class() # This is for the batch update, we don't pass in the user or a single submission + def contribute_form(cls, **kwargs): + form = cls.form_class(**kwargs) form.name = cls.context_name return cls.context_name, form def get_success_url(self): - return self.request.path + query = self.request.GET.urlencode() + if query: + query = '?' + query + return self.request.path + query class CreateOrUpdateView(SingleObjectTemplateResponseMixin, ModelFormMixin, ProcessFormView): diff --git a/opentech/static_src/src/javascript/apply/batch-actions.js b/opentech/static_src/src/javascript/apply/batch-actions.js index 6d2af16f8d6269e0b28086a1440e76f8f18a2fab..37d457028b7fc73eb175b0a74f95d1b2462a31ec 100644 --- a/opentech/static_src/src/javascript/apply/batch-actions.js +++ b/opentech/static_src/src/javascript/apply/batch-actions.js @@ -5,10 +5,12 @@ const $body = $('body'); const $checkbox = $('.js-batch-select'); const $allCheckboxInput = $('.js-batch-select-all'); - const $batchReviewersButton = $('.js-batch-update-reviewers'); + const $batchButtons = $('.js-batch-button'); + const $batchProgress = $('.js-batch-progress'); + const $actionOptions = $('#id_action option'); const $batchTitlesList = $('.js-batch-titles'); const $batchTitleCount = $('.js-batch-title-count'); - const $hiddenIDlist = $('#id_submission_ids'); + const $hiddenIDlist = $('.js-submissions-id'); const $toggleBatchList = $('.js-toggle-batch-list'); const activeClass = 'batch-actions-enabled'; const closedClass = 'is-closed'; @@ -32,6 +34,7 @@ toggleBatchActions(); updateCount(); + updateProgressButton(); }); $checkbox.change(function () { @@ -45,34 +48,19 @@ if (!$(this).is(':checked') && $allCheckboxInput.is(':checked')) { resetCheckAllInput(); } + + updateProgressButton(); }); // append selected project titles to batch update reviewer modal - $batchReviewersButton.click(function () { - $batchTitlesList.html(''); - $batchTitleCount.html(''); - $batchTitlesList.addClass(closedClass); - $toggleBatchList.html('Show'); - - let selectedIDs = []; - - $checkbox.each(function () { - if ($(this).is(':checked')) { - const href = $(this).parents('tr').find('.js-title').find('a').attr('href'); - const title = $(this).parents('tr').find('.js-title').data('tooltip'); - - $batchTitlesList.append(` - <a href="${href}" class="modal__list-item" target="_blank" rel="noopener noreferrer" title="${title}"> - ${title} - <svg class="modal__open-link-icon"><use xlink:href="#open-in-new-tab"></use></svg> - </a> - `); - selectedIDs.push($(this).parents('tr').data('record-id')); - } + $batchButtons.each(function () { + $(this).click(function () { + prepareBatchListing(); }); + }); - $batchTitleCount.append(`${selectedIDs.length} submissions selected`); - $hiddenIDlist.val(selectedIDs.join(',')); + $batchProgress.click(function () { + updateProgressButton(); }); // show/hide the list of actions @@ -89,6 +77,55 @@ $batchTitlesList.toggleClass(closedClass); }); + function prepareBatchListing() { + $batchTitlesList.html(''); + $batchTitleCount.html(''); + $batchTitlesList.addClass(closedClass); + $toggleBatchList.html('Show'); + + let selectedIDs = []; + + $checkbox.filter(':checked').each(function () { + const href = $(this).parents('tr').find('.js-title').find('a').attr('href'); + const title = $(this).parents('tr').find('.js-title').data('tooltip'); + + $batchTitlesList.append(` + <a href="${href}" class="modal__list-item" target="_blank" rel="noopener noreferrer" title="${title}"> + ${title} + <svg class="modal__open-link-icon"><use xlink:href="#open-in-new-tab"></use></svg> + </a> + `); + selectedIDs.push($(this).parents('tr').data('record-id')); + }); + + $batchTitleCount.append(`${selectedIDs.length} submissions selected`); + $hiddenIDlist.val(selectedIDs.join(',')); + } + + function updateProgressButton() { + var actions = $actionOptions.map(function () { return this.value; }).get(); + $checkbox.filter(':checked').each(function () { + let newActions = $(this).parents('tr').find('.js-actions').data('actions'); + actions = actions.filter(action => newActions.includes(action)); + }); + $actionOptions.each(function () { + if (!actions.includes(this.value)) { + $(this).attr('disabled', 'disabled'); + } + else { + $(this).removeAttr('disabled'); + } + }); + $actionOptions.filter(':enabled:first').prop('selected', true); + if (actions.length === 0) { + $batchProgress.attr('disabled', 'disabled'); + } + else { + $batchProgress.removeAttr('disabled'); + } + } + + function toggleBatchActions() { if ($('.js-batch-select:checked').length) { $body.addClass(activeClass); diff --git a/opentech/static_src/src/sass/apply/components/_button.scss b/opentech/static_src/src/sass/apply/components/_button.scss index 9c2afcc23920c948901e7a58eafd243d1c9a4841..1934b4ed2904254e1a9309941ed5dc86b6ed4f2c 100644 --- a/opentech/static_src/src/sass/apply/components/_button.scss +++ b/opentech/static_src/src/sass/apply/components/_button.scss @@ -242,9 +242,7 @@ } } - &--change-status { - display: none; - + &--batch-status { &::before { background-image: url('./../../images/arrow-split.svg'); transform: rotate(90deg);