diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index 137c2fb02fde86e36d287606a68d0ef0f249ce63..a380859104349d9153131bd0ef0d107d4a4ec1ed 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -242,7 +242,13 @@ class ActivityAdapter(AdapterBase): return ' '.join(message) def batch_reviewers_updated(self, added, **kwargs): - return 'Batch ' + self.reviewers_updated(added, **kwargs) + base = ['Batch Reviewers Updated.'] + base.extend([ + f'{str(user)} as {role.name}.' + for role, user in added + if user + ]) + return ' '.join(base) def batch_determination(self, submissions, determinations, **kwargs): submission = submissions[0] @@ -395,7 +401,11 @@ class SlackAdapter(AdapterBase): def handle_batch_reviewers(self, submissions, links, user, added, **kwargs): submissions_text = self.slack_links(links, submissions) - reviewers_text = ', '.join([str(user) for user in added]) + reviewers_text = ' '.join([ + f'{str(user)} as {role.name},' + for role, user in added + if user + ]) return ( '{user} has batch added {reviewers_text} as reviewers on: {submissions_text}'.format( user=user, @@ -624,9 +634,15 @@ class DjangoMessagesAdapter(AdapterBase): } def batch_reviewers_updated(self, added, submissions, **kwargs): + reviewers_text = ' '.join([ + f'{str(user)} as {role.name},' + for role, user in added + if user + ]) + return ( 'Batch reviewers added: ' + - ', '.join([str(user) for user in added]) + + reviewers_text + ' to ' + ', '.join(['"{}"'.format(submission.title) for submission in submissions]) ) diff --git a/opentech/apply/funds/forms.py b/opentech/apply/funds/forms.py index 99f11d75b1c651e2342999a241347a381785a908..426a0d0a7246b07bd409b9d19b95a07cfc3d4f22 100644 --- a/opentech/apply/funds/forms.py +++ b/opentech/apply/funds/forms.py @@ -4,9 +4,9 @@ from django.utils.translation import ugettext_lazy as _ from django_select2.forms import Select2Widget from opentech.apply.users.models import User -from opentech.apply.utils.image import generate_image_tag -from .models import ApplicationSubmission, AssignedReviewers, ReviewerRole +from .models import AssignedReviewers, ApplicationSubmission, ReviewerRole +from .utils import render_icon from .widgets import Select2MultiCheckboxesWidget from .workflow import get_action_mapping @@ -100,23 +100,14 @@ class UpdateReviewersForm(forms.ModelForm): ) } - staff_reviewers = User.objects.staff().only('full_name', 'pk') - self.role_fields = {} - for role in ReviewerRole.objects.all().order_by('order'): - field_name = 'role_reviewer_' + slugify(str(role)) - self.role_fields[field_name] = role - - self.fields[field_name] = forms.ModelChoiceField( - queryset=staff_reviewers, - widget=Select2Widget(attrs={ - 'data-placeholder': 'Select a reviewer', - }), - required=False, - label=mark_safe(self.render_icon(role.icon) + f'{role.name} Reviewer'), - ) - # Pre-populate form field - self.fields[field_name].initial = assigned_roles.get(role) + field_data = make_role_reviewer_fields() + + for data in field_data: + field_name = data['field_name'] + self.fields[field_name] = data['field'] + self.role_fields[field_name] = data['role'] + self.fields[field_name].initial = assigned_roles.get(data['role']) self.submitted_reviewers = User.objects.filter( id__in=self.instance.reviews.values('author'), @@ -136,12 +127,6 @@ class UpdateReviewersForm(forms.ModelForm): else: self.fields.pop('reviewer_reviewers') - def render_icon(self, image): - if not image: - return '' - filter_spec = 'fill-20x20' - return generate_image_tag(image, filter_spec) - def prepare_field(self, field_name, initial, excluded): field = self.fields[field_name] field.queryset = field.queryset.exclude(id__in=excluded) @@ -155,7 +140,7 @@ class UpdateReviewersForm(forms.ModelForm): role_reviewers = [ user for field, user in self.cleaned_data.items() - if field != 'reviewer_reviewers' + if field in self.role_fields ] # If any of the users match and are set to multiple roles, throw an error @@ -222,16 +207,105 @@ class UpdateReviewersForm(forms.ModelForm): class BatchUpdateReviewersForm(forms.Form): - staff_reviewers = forms.ModelMultipleChoiceField( - queryset=User.objects.staff(), - widget=Select2MultiCheckboxesWidget(attrs={'data-placeholder': 'Staff'}), - ) submissions = forms.CharField(widget=forms.HiddenInput(attrs={'class': 'js-submissions-id'})) def __init__(self, *args, user=None, round=None, **kwargs): super().__init__(*args, **kwargs) + self.role_fields = {} + field_data = make_role_reviewer_fields() + + for data in field_data: + field_name = data['field_name'] + self.fields[field_name] = data['field'] + self.role_fields[field_name] = data['role'] + 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(self): + cleaned_data = super().clean() + role_reviewers = [ + user + for field, user in self.cleaned_data.items() + if field in self.role_fields + ] + + # If any of the users match and are set to multiple roles, throw an error + if len(role_reviewers) != len(set(role_reviewers)) and any(role_reviewers): + self.add_error(None, _('Users cannot be assigned to multiple roles.')) + + return cleaned_data + + def save(self): + submissions = self.cleaned_data['submissions'] + assigned_roles = { + role: self.cleaned_data[field] + for field, role in self.role_fields.items() + } + for role, reviewer in assigned_roles.items(): + if reviewer: + existing_assignments = AssignedReviewers.objects.filter( + submission__in=submissions, + role=role, + ) + + about_to_be_orphaned = [ + [assigned.reviewer, assigned.submission] + for assigned in existing_assignments + if submissions.get( + pk=assigned.submission.pk + ).reviews.filter(author=assigned.reviewer).exists() + ] + + # Being reassigned + AssignedReviewers.objects.filter( + submission__in=submissions, + role__isnull=False, + reviewer=reviewer, + ).delete() + + existing_assignments.update(reviewer=reviewer) + + AssignedReviewers.objects.bulk_create( + AssignedReviewers( + role=role, + reviewer=reviewer, + submission=submission, + ) for submission in submissions.exclude(pk__in=existing_assignments.values('submission')) + ) + + AssignedReviewers.objects.bulk_create( + AssignedReviewers( + role=None, + reviewer=reviewer, + submission=submission, + ) for reviewer, submission in about_to_be_orphaned + ) + + return None + + +def make_role_reviewer_fields(): + role_fields = [] + staff_reviewers = User.objects.staff().only('full_name', 'pk') + + for role in ReviewerRole.objects.all().order_by('order'): + field_name = 'role_reviewer_' + slugify(str(role)) + field = forms.ModelChoiceField( + queryset=staff_reviewers, + widget=Select2Widget(attrs={ + 'data-placeholder': 'Select a reviewer', + }), + required=False, + label=mark_safe(render_icon(role.icon) + f'{role.name} Reviewer'), + ) + role_fields.append({ + 'role': role, + 'field': field, + 'field_name': field_name, + }) + + return role_fields diff --git a/opentech/apply/funds/tests/views/test_batch_reviewers.py b/opentech/apply/funds/tests/views/test_batch_reviewers.py new file mode 100644 index 0000000000000000000000000000000000000000..5df52e496b2a7893e445e0765c2eb2f996590269 --- /dev/null +++ b/opentech/apply/funds/tests/views/test_batch_reviewers.py @@ -0,0 +1,88 @@ +from django.utils.text import slugify + +from opentech.apply.funds.tests.factories import ( + ApplicationSubmissionFactory, + AssignedWithRoleReviewersFactory, + ReviewerRoleFactory, +) +from opentech.apply.users.tests.factories import ( + ReviewerFactory, + StaffFactory, +) +from opentech.apply.review.tests.factories import ReviewFactory +from opentech.apply.utils.testing.tests import BaseViewTestCase + + +class BaseBatchReviewerTestCase(BaseViewTestCase): + url_name = 'funds:submissions:{}' + base_view_name = 'list' + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.submissions = ApplicationSubmissionFactory.create_batch(4) + cls.staff = StaffFactory.create_batch(4) + cls.reviewers = ReviewerFactory.create_batch(4) + cls.roles = ReviewerRoleFactory.create_batch(2) + + def data(self, reviewer_roles, submissions): + data = { + 'form-submitted-batch_reviewer_form': 'Update', + 'submissions': ','.join([str(submission.id) for submission in submissions]), + } + + data.update( + **{ + f'role_reviewer_{slugify(str(role))}': reviewer.id + for role, reviewer in zip(self.roles, reviewer_roles) + } + ) + return data + + +class StaffTestCase(BaseBatchReviewerTestCase): + user_factory = StaffFactory + + def test_can_assign_role_reviewers(self): + reviewer_roles = [self.staff[0]] + submissions = self.submissions[0:2] + self.post_page(data=self.data(reviewer_roles, submissions)) + for submission in submissions: + self.assertEqual(submission.assigned.count(), 1) + self.assertEqual(submission.assigned.first().reviewer, self.staff[0]) + self.assertEqual(submission.assigned.first().role, self.roles[0]) + + def test_can_reassign_role_reviewers(self): + AssignedWithRoleReviewersFactory(reviewer=self.staff[1], submission=self.submissions[0], role=self.roles[0]) + AssignedWithRoleReviewersFactory(reviewer=self.staff[1], submission=self.submissions[1], role=self.roles[0]) + submissions = self.submissions[0:2] + reviewer_roles = [self.staff[0]] + self.post_page(data=self.data(reviewer_roles, submissions)) + for submission in submissions: + self.assertEqual(submission.assigned.count(), 1) + self.assertEqual(submission.assigned.first().reviewer, self.staff[0]) + self.assertEqual(submission.assigned.first().role, self.roles[0]) + + def test_can_reassign_from_other_role_reviewers(self): + AssignedWithRoleReviewersFactory(reviewer=self.staff[0], submission=self.submissions[0], role=self.roles[1]) + AssignedWithRoleReviewersFactory(reviewer=self.staff[0], submission=self.submissions[1], role=self.roles[1]) + submissions = self.submissions[0:2] + reviewer_roles = [self.staff[0]] + self.post_page(data=self.data(reviewer_roles, submissions)) + for submission in submissions: + self.assertEqual(submission.assigned.count(), 1) + self.assertEqual(submission.assigned.first().reviewer, self.staff[0]) + self.assertEqual(submission.assigned.first().role, self.roles[0]) + + def test_doesnt_remove_if_already_reviewed(self): + AssignedWithRoleReviewersFactory(reviewer=self.staff[1], submission=self.submissions[0], role=self.roles[0]) + ReviewFactory(author=self.staff[1], submission=self.submissions[0], draft=False) + ReviewFactory(author=self.staff[1], submission=self.submissions[1], draft=False) + submissions = self.submissions[0:2] + reviewer_roles = [self.staff[0]] + self.post_page(data=self.data(reviewer_roles, submissions)) + for submission in submissions: + self.assertEqual(submission.assigned.count(), 2) + reviewers = submission.assigned.values_list('reviewer', flat=True) + self.assertIn(self.staff[0].pk, reviewers) + self.assertIn(self.staff[1].pk, reviewers) diff --git a/opentech/apply/funds/utils.py b/opentech/apply/funds/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..3a3578dbd3826efae067c78d8c17ca9e79dccc84 --- /dev/null +++ b/opentech/apply/funds/utils.py @@ -0,0 +1,8 @@ +from opentech.apply.utils.image import generate_image_tag + + +def render_icon(image): + if not image: + return '' + filter_spec = 'fill-20x20' + return generate_image_tag(image, filter_spec) diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py index a2e4499a799630dc41ed2063b8af0db8e560c110..f97c6092fd1ccfb149fd3b0b1f21ecda58f6aa10 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -97,23 +97,13 @@ class BatchUpdateReviewersView(DelegatedViewMixin, FormView): form_class = BatchUpdateReviewersForm context_name = 'batch_reviewer_form' - def form_invalid(self, form): - messages.error(self.request, mark_safe(_('Sorry something went wrong') + form.errors.as_ul())) - return super().form_invalid(form) - def form_valid(self, form): - """ - Loop through all submissions selected on the page, - Add any reviewers that were selected, only if they are not - currently saved to that submission. - Send out a message of updates. - """ - reviewers = form.cleaned_data['staff_reviewers'] - submissions = form.cleaned_data['submissions'] - - for submission in submissions: - submission.reviewers.add(*reviewers) + form.save() + reviewers = [ + [role, form.cleaned_data[field_name]] + for field_name, role in form.role_fields.items() + ] messenger( MESSAGES.BATCH_REVIEWERS_UPDATED, @@ -122,9 +112,12 @@ class BatchUpdateReviewersView(DelegatedViewMixin, FormView): submissions=submissions, added=reviewers, ) - return super().form_valid(form) + def form_invalid(self, form): + messages.error(self.request, mark_safe(_('Sorry something went wrong') + form.errors.as_ul())) + return super().form_invalid(form) + @method_decorator(staff_required, name='dispatch') class BatchProgressSubmissionView(DelegatedViewMixin, FormView):