diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 417895e341..ce110c9e04 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,20 +1,26 @@ name-template: "$RESOLVED_VERSION" tag-template: "v$RESOLVED_VERSION" version-resolver: - major: + default: patch +categories: + - title: "Major" + type: version-resolver + semver-increment: major labels: - "Type: Major" - "major" - minor: + - title: "Minor" + type: version-resolver + semver-increment: minor labels: - "Type: Minor" - "minor" - patch: + - title: "Patch" + type: version-resolver + semver-increment: patch labels: - "Type: Patch" - "patch" - default: patch -categories: - title: "🔒 Security" labels: - "Type: Security" diff --git a/hypha/apply/activity/adapters/emails.py b/hypha/apply/activity/adapters/emails.py index 7f2aff0371..9fcda3b493 100644 --- a/hypha/apply/activity/adapters/emails.py +++ b/hypha/apply/activity/adapters/emails.py @@ -284,16 +284,19 @@ def handle_determination(self, determination, source, **kwargs): ) def handle_batch_determination(self, determinations, sources, **kwargs): - submissions = sources + # Batch messages are sent one submission at a time, see + # `AdapterBase.batch_recipients`. + submission = sources[0] + determination = determinations[submission.id] + if not determination.send_notice: + return kwargs.pop("source") - for submission in submissions: - determination = determinations[submission.id] - return self.render_message( - "messages/email/determination.html", - source=submission, - determination=determination, - **kwargs, - ) + return self.render_message( + "messages/email/determination.html", + source=submission, + determination=determination, + **kwargs, + ) def handle_ready_for_review(self, request, source, **kwargs): if settings.SEND_READY_FOR_REVIEW: diff --git a/hypha/apply/determinations/blocks.py b/hypha/apply/determinations/blocks.py index d75bac29da..cf0af03a1a 100644 --- a/hypha/apply/determinations/blocks.py +++ b/hypha/apply/determinations/blocks.py @@ -65,6 +65,14 @@ class Meta: label = _("Send Notice") icon = "tick-inverse" + def get_field_kwargs(self, struct_value): + kwargs = super().get_field_kwargs(struct_value) + # A required BooleanField has to be checked to validate, which would + # make it impossible to submit a determination without notifying the + # applicant - the only thing this field is for. + kwargs["required"] = False + return kwargs + def get_searchable_content(self, value, data): return None diff --git a/hypha/apply/determinations/tests/test_views.py b/hypha/apply/determinations/tests/test_views.py index 64b72452fd..22d3830019 100644 --- a/hypha/apply/determinations/tests/test_views.py +++ b/hypha/apply/determinations/tests/test_views.py @@ -2,10 +2,15 @@ from django.contrib.messages.storage.fallback import FallbackStorage from django.contrib.sessions.middleware import SessionMiddleware +from django.core import mail from django.test import RequestFactory, override_settings from django.urls import reverse_lazy from hypha.apply.activity.models import Activity +from hypha.apply.determinations.blocks import ( + DeterminationMessageBlock, + SendNoticeBlock, +) from hypha.apply.determinations.options import ACCEPTED, NEEDS_MORE_INFO, REJECTED from hypha.apply.determinations.views import BatchDeterminationCreateView from hypha.apply.funds.models.co_applicants import ( @@ -13,7 +18,8 @@ CoApplicantInvite, CoApplicantInviteStatus, ) -from hypha.apply.funds.tests.factories import ApplicationSubmissionFactory +from hypha.apply.funds.models.forms import RoundBaseDeterminationForm +from hypha.apply.funds.tests.factories import ApplicationSubmissionFactory, RoundFactory from hypha.apply.projects.models.project import CONTRACTING, DRAFT from hypha.apply.users.roles import APPLICANT_GROUP_NAME from hypha.apply.users.tests.factories import ( @@ -25,7 +31,11 @@ ) from hypha.apply.utils.testing import BaseViewTestCase -from .factories import DeterminationFactory +from .factories import ( + DeterminationFactory, + DeterminationFormFactory, + DeterminationFormFieldsFactory, +) def make_co_applicant(submission, user): @@ -675,6 +685,74 @@ def test_cant_see_detailed_data_as_co_applicant_when_disabled(self): self.assertFalse(response.context["show_detailed_data"]) +@override_settings(SEND_MESSAGES=True) +class BatchDeterminationStreamFormTestCase(BaseViewTestCase): + """Batch determinations for submissions using streamfield determination forms.""" + + user_factory = StaffFactory + url_name = "funds:submissions:determinations:{}" + base_view_name = "batch" + + def setUp(self): + super().setUp() + self.determination_form = DeterminationFormFactory() + round_page = RoundFactory() + RoundBaseDeterminationForm.objects.create( + round=round_page, form=self.determination_form + ) + self.submissions = ApplicationSubmissionFactory.create_batch( + 2, round=round_page + ) + mail.outbox.clear() + + def field_id(self, block_type): + return next( + field.id + for field in self.determination_form.form_fields + if isinstance(field.block, block_type) + ) + + def batch_determine(self, message, send_notice=True): + url = ( + self.url(None) + + "?submissions=" + + ",".join([str(submission.id) for submission in self.submissions]) + + "&action=rejected" + ) + data = DeterminationFormFieldsFactory.form_response( + self.determination_form.form_fields, + {self.field_id(DeterminationMessageBlock): message}, + ) + if not send_notice: + # An unchecked checkbox is simply not submitted. + del data[self.field_id(SendNoticeBlock)] + return self.client.post(url, data, secure=True, follow=True) + + def applicant_emails(self, submission): + return [email for email in mail.outbox if submission.user.email in email.to] + + def test_determination_message_is_included_in_applicant_email(self): + message = "Sorry, not this time." + self.batch_determine(message) + + # Every submission in the batch gets its own email, about its own + # submission and carrying the determination message. + for submission in self.submissions: + emails = self.applicant_emails(submission) + self.assertEqual(len(emails), 1) + self.assertIn(message, emails[0].body) + self.assertIn(submission.get_absolute_url(), emails[0].body) + + def test_no_applicant_email_if_send_notice_is_unchecked(self): + self.batch_determine("Sorry, not this time.", send_notice=False) + + for submission in self.submissions: + determination = submission.determinations.first() + self.assertIsNotNone(determination) + self.assertFalse(determination.send_notice) + self.assertEqual(self.applicant_emails(submission), []) + + class UserDeterminationFormTestCase(BaseViewTestCase): user_factory = UserFactory url_name = "funds:submissions:determinations:{}" diff --git a/hypha/apply/determinations/views.py b/hypha/apply/determinations/views.py index d8ef556dd5..95daab8a44 100644 --- a/hypha/apply/determinations/views.py +++ b/hypha/apply/determinations/views.py @@ -228,6 +228,24 @@ def form_valid(self, form): } sources = submissions.filter(id__in=list(determinations)) + # The streamfield determination form data is not stored by the form itself, + # it has to be copied onto the newly created determinations. This must + # happen before any notifications are sent as the determination message is + # part of the email to the applicant. + if sources and sources[0].is_determination_form_attached: + defined_fields = self.get_defined_fields() + for determination in determinations.values(): + determination.form_fields = defined_fields + determination.message = form.cleaned_data[ + determination.message_field.id + ] + determination.send_notice = ( + form.cleaned_data[determination.send_notice_field.id] + if determination.send_notice_field + else True + ) + determination.save() + base_message = _("Successfully determined as {outcome}: ").format( outcome=determinations[sources[0].id].clean_outcome ) @@ -253,12 +271,6 @@ def form_valid(self, form): ).format(title=submission.title_text_display), ) else: - if submission.is_determination_form_attached: - determination.form_fields = self.get_defined_fields() - determination.message = form.cleaned_data[ - determination.message_field.id - ] - determination.save() transition = transition_from_outcome( form.cleaned_data.get("outcome"), submission ) @@ -449,13 +461,6 @@ def form_valid(self, form): return HttpResponseRedirect(self.submission.get_absolute_url()) with transaction.atomic(): - messenger( - MESSAGES.DETERMINATION_OUTCOME, - request=self.request, - user=self.object.author, - submission=self.object.submission, - related=self.object, - ) proposal_form = form.cleaned_data.get("proposal_form") transition = transition_from_outcome( int(self.object.outcome), self.submission