From a16ce187d255466ffb5c61d88e490f09aff96528 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Mon, 5 Aug 2024 23:40:23 +0200 Subject: [PATCH] [#2681] Add tests for InstructorConfirmedForWorkshop (complex email action) --- .../instructor_confirmed_for_workshop.py | 2 +- ..._confirmed_for_workshop_cancel_receiver.py | 283 ++++++++++++++ ...ructor_confirmed_for_workshop_receiver.py} | 4 +- ...tructor_confirmed_for_workshop_strategy.py | 270 +++++++++++++ ..._confirmed_for_workshop_update_receiver.py | 368 ++++++++++++++++++ amy/workshops/views.py | 26 +- 6 files changed, 948 insertions(+), 5 deletions(-) create mode 100644 amy/emails/tests/actions/test_instructor_confirmed_for_workshop_cancel_receiver.py rename amy/emails/tests/actions/{test_instructor_confirmed_for_workshop.py => test_instructor_confirmed_for_workshop_receiver.py} (98%) create mode 100644 amy/emails/tests/actions/test_instructor_confirmed_for_workshop_strategy.py create mode 100644 amy/emails/tests/actions/test_instructor_confirmed_for_workshop_update_receiver.py diff --git a/amy/emails/actions/instructor_confirmed_for_workshop.py b/amy/emails/actions/instructor_confirmed_for_workshop.py index 54bc621f9..919e9621a 100644 --- a/amy/emails/actions/instructor_confirmed_for_workshop.py +++ b/amy/emails/actions/instructor_confirmed_for_workshop.py @@ -44,7 +44,7 @@ def instructor_confirmed_for_workshop_strategy(task: Task) -> StrategyEnum: person_email_exists=person_email_exists, ) - email_should_exist = instructor_role and person_email_exists + email_should_exist = task.pk and instructor_role and person_email_exists logger.debug(f"{email_should_exist=}") ct = ContentType.objects.get_for_model(task.person) # type: ignore diff --git a/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_cancel_receiver.py b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_cancel_receiver.py new file mode 100644 index 000000000..bbbab6308 --- /dev/null +++ b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_cancel_receiver.py @@ -0,0 +1,283 @@ +from datetime import UTC, date, datetime, timedelta +from unittest.mock import MagicMock, call, patch + +from django.test import RequestFactory, TestCase, override_settings +from django.urls import reverse + +from emails.actions.instructor_confirmed_for_workshop import ( + instructor_confirmed_for_workshop_cancel_receiver, + instructor_confirmed_for_workshop_strategy, + run_instructor_confirmed_for_workshop_strategy, +) +from emails.models import EmailTemplate, ScheduledEmail, ScheduledEmailStatus +from emails.signals import ( + INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + instructor_confirmed_for_workshop_cancel_signal, +) +from workshops.models import Event, Organization, Person, Role, Task +from workshops.tests.base import TestBase + + +class TestInstructorBadgeAwardedCancelReceiver(TestCase): + def setUp(self) -> None: + host = Organization.objects.create(domain="test.com", fullname="Test") + self.event = Event.objects.create( + slug="test-event", host=host, start=date(2024, 8, 5), end=date(2024, 8, 5) + ) + self.person = Person.objects.create(email="test@example.org") + instructor = Role.objects.create(name="instructor") + self.task = Task.objects.create( + role=instructor, person=self.person, event=self.event + ) + + def setUpEmailTemplate(self) -> EmailTemplate: + return EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + from_header="workshops@carpentries.org", + cc_header=["team@carpentries.org"], + bcc_header=[], + subject="Greetings {{ name }}", + body="Hello, {{ name }}! Nice to meet **you**.", + ) + + @patch("emails.actions.base_action.logger") + def test_disabled_when_no_feature_flag(self, mock_logger) -> None: + # Arrange + request = RequestFactory().get("/") + with self.settings(FLAGS={"EMAIL_MODULE": [("boolean", False)]}): + # Act + instructor_confirmed_for_workshop_cancel_receiver(None, request=request) + # Assert + mock_logger.debug.assert_called_once_with( + "EMAIL_MODULE feature flag not set, skipping " + "instructor_confirmed_for_workshop_cancel" + ) + + def test_receiver_connected_to_signal(self) -> None: + # Arrange + original_receivers = instructor_confirmed_for_workshop_cancel_signal.receivers[ + : + ] + + # Act + # attempt to connect the receiver + instructor_confirmed_for_workshop_cancel_signal.connect( + instructor_confirmed_for_workshop_cancel_receiver + ) + new_receivers = instructor_confirmed_for_workshop_cancel_signal.receivers[:] + + # Assert + # the same receiver list means this receiver has already been connected + self.assertEqual(original_receivers, new_receivers) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + def test_action_triggered(self) -> None: + # Arrange + request = RequestFactory().get("/") + + template = self.setUpEmailTemplate() + scheduled_email = ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.person, + ) + + # Act + with patch( + "emails.actions.base_action.messages_action_cancelled" + ) as mock_messages_action_cancelled: + instructor_confirmed_for_workshop_cancel_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + scheduled_email = ScheduledEmail.objects.get(template=template) + mock_messages_action_cancelled.assert_called_once_with( + request, + INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + scheduled_email, + ) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @patch("emails.actions.base_action.messages_action_cancelled") + def test_email_cancelled( + self, + mock_messages_action_cancelled: MagicMock, + ) -> None: + # Arrange + request = RequestFactory().get("/") + template = self.setUpEmailTemplate() + scheduled_email = ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.person, + ) + + # Act + with patch( + "emails.actions.base_action.EmailController.cancel_email" + ) as mock_cancel_email: + instructor_confirmed_for_workshop_cancel_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + mock_cancel_email.assert_called_once_with( + scheduled_email=scheduled_email, + author=None, + ) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @patch("emails.actions.base_action.messages_action_cancelled") + def test_multiple_emails_cancelled( + self, + mock_messages_action_cancelled: MagicMock, + ) -> None: + # Arrange + request = RequestFactory().get("/") + template = self.setUpEmailTemplate() + scheduled_email1 = ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.person, + ) + scheduled_email2 = ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.person, + ) + + # Act + with patch( + "emails.actions.base_action.EmailController.cancel_email" + ) as mock_cancel_email: + instructor_confirmed_for_workshop_cancel_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + mock_cancel_email.assert_has_calls( + [ + call( + scheduled_email=scheduled_email1, + author=None, + ), + call( + scheduled_email=scheduled_email2, + author=None, + ), + ] + ) + + +class TestInstructorBadgeAwardedCancelIntegration(TestBase): + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + def test_integration(self) -> None: + # Arrange + self._setUpRoles() + self._setUpTags() + self._setUpUsersAndLogin() + + template = EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + from_header="workshops@carpentries.org", + cc_header=["team@carpentries.org"], + bcc_header=[], + subject="Greetings", + body="Hello! Nice to meet **you**.", + ) + + ttt_organization = Organization.objects.create( + domain="carpentries.org", fullname="Instructor Training" + ) + host_organization = Organization.objects.create( + domain="example.com", fullname="Example" + ) + event = Event.objects.create( + slug="2024-08-05-test-event", + host=host_organization, + administrator=ttt_organization, + start=date.today() + timedelta(days=30), + ) + + instructor = Person.objects.create( + personal="Kelsi", + middle="", + family="Purdy", + username="purdy_kelsi", + email="purdy.kelsi@example.com", + secondary_email="notused@amy.org", + gender="F", + airport=self.airport_0_0, + github="", + twitter="", + url="http://kelsipurdy.com/", + affiliation="University of Arizona", + occupation="TA at Biology Department", + orcid="0000-0000-0000", + is_active=True, + ) + instructor_role = Role.objects.get(name="instructor") + task = Task.objects.create(event=event, person=instructor, role=instructor_role) + + request = RequestFactory().get("/") + with patch( + "emails.actions.base_action.messages_action_scheduled" + ) as mock_action_scheduled: + run_instructor_confirmed_for_workshop_strategy( + instructor_confirmed_for_workshop_strategy(task), + request, + task=task, + person_id=task.person.pk, + event_id=task.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + scheduled_email = ScheduledEmail.objects.get(template=template) + + url = reverse("task_delete", args=[task.pk]) + + # Act + rv = self.client.post(url) + + # Arrange + mock_action_scheduled.assert_called_once() + self.assertEqual(rv.status_code, 302) + scheduled_email.refresh_from_db() + self.assertEqual(scheduled_email.state, ScheduledEmailStatus.CANCELLED) diff --git a/amy/emails/tests/actions/test_instructor_confirmed_for_workshop.py b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_receiver.py similarity index 98% rename from amy/emails/tests/actions/test_instructor_confirmed_for_workshop.py rename to amy/emails/tests/actions/test_instructor_confirmed_for_workshop_receiver.py index e89890ff4..60414cf98 100644 --- a/amy/emails/tests/actions/test_instructor_confirmed_for_workshop.py +++ b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_receiver.py @@ -5,7 +5,9 @@ from django.urls import reverse from communityroles.models import CommunityRole, CommunityRoleConfig -from emails.actions import instructor_confirmed_for_workshop_receiver +from emails.actions.instructor_confirmed_for_workshop import ( + instructor_confirmed_for_workshop_receiver, +) from emails.models import EmailTemplate, ScheduledEmail from emails.schemas import ContextModel, ToHeaderModel from emails.signals import instructor_confirmed_for_workshop_signal diff --git a/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_strategy.py b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_strategy.py new file mode 100644 index 000000000..3b52a8845 --- /dev/null +++ b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_strategy.py @@ -0,0 +1,270 @@ +from datetime import UTC, date, datetime +from unittest.mock import MagicMock, patch + +from django.test import RequestFactory, TestCase + +from emails.actions.exceptions import EmailStrategyException +from emails.actions.instructor_confirmed_for_workshop import ( + instructor_confirmed_for_workshop_strategy, + run_instructor_confirmed_for_workshop_strategy, +) +from emails.models import EmailTemplate, ScheduledEmail, ScheduledEmailStatus +from emails.signals import INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME +from emails.types import StrategyEnum +from workshops.models import Event, Organization, Person, Role, Task + + +class TestInstructorConfirmedForWorkshopStrategy(TestCase): + def setUp(self) -> None: + host = Organization.objects.create(domain="test.com", fullname="Test") + self.event = Event.objects.create( + slug="test-event", host=host, start=date(2024, 8, 5), end=date(2024, 8, 5) + ) + self.person = Person.objects.create(email="test@example.org") + instructor = Role.objects.create(name="instructor") + self.task = Task.objects.create( + role=instructor, person=self.person, event=self.event + ) + + def test_strategy_create(self) -> None: + # Arrange + + # Act + result = instructor_confirmed_for_workshop_strategy(self.task) + + # Assert + self.assertEqual(result, StrategyEnum.CREATE) + + def test_strategy_update(self) -> None: + # Arrange + template = EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + from_header="workshops@carpentries.org", + cc_header=["team@carpentries.org"], + bcc_header=[], + subject="Greetings {{ name }}", + body="Hello, {{ name }}! Nice to meet **you**.", + ) + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.person, + ) + + # Act + result = instructor_confirmed_for_workshop_strategy(self.task) + + # Assert + self.assertEqual(result, StrategyEnum.UPDATE) + + def test_strategy_cancel(self) -> None: + # Arrange + self.task.role = Role.objects.create(name="learner") + self.task.save() + + template = EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + from_header="workshops@carpentries.org", + cc_header=["team@carpentries.org"], + bcc_header=[], + subject="Greetings {{ name }}", + body="Hello, {{ name }}! Nice to meet **you**.", + ) + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.person, + ) + + # Act + result = instructor_confirmed_for_workshop_strategy(self.task) + + # Assert + self.assertEqual(result, StrategyEnum.CANCEL) + + +class TestRunInstructorConfirmedForWorkshopStrategy(TestCase): + def setUp(self) -> None: + host = Organization.objects.create(domain="test.com", fullname="Test") + self.event = Event.objects.create( + slug="test-event", host=host, start=date(2024, 8, 5), end=date(2024, 8, 5) + ) + self.person = Person.objects.create(email="test@example.org") + instructor = Role.objects.create(name="instructor") + self.task = Task.objects.create( + role=instructor, person=self.person, event=self.event + ) + + @patch( + "emails.actions.instructor_confirmed_for_workshop." + "instructor_confirmed_for_workshop_signal" + ) + def test_strategy_calls_create_signal( + self, + mock_instructor_confirmed_for_workshop_signal, + ) -> None: + # Arrange + strategy = StrategyEnum.CREATE + request = RequestFactory().get("/") + + # Act + run_instructor_confirmed_for_workshop_strategy( + strategy, + request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + mock_instructor_confirmed_for_workshop_signal.send.assert_called_once_with( + sender=self.task, + request=request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + @patch( + "emails.actions.instructor_confirmed_for_workshop." + "instructor_confirmed_for_workshop_update_signal" + ) + def test_strategy_calls_update_signal( + self, + mock_instructor_confirmed_for_workshop_update_signal, + ) -> None: + # Arrange + strategy = StrategyEnum.UPDATE + request = RequestFactory().get("/") + + # Act + run_instructor_confirmed_for_workshop_strategy( + strategy, + request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + mock_instructor_confirmed_for_workshop_update_signal.send.assert_called_once_with( # noqa: E501 + sender=self.task, + request=request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + @patch( + "emails.actions.instructor_confirmed_for_workshop." + "instructor_confirmed_for_workshop_cancel_signal" + ) + def test_strategy_calls_cancel_signal( + self, + mock_instructor_confirmed_for_workshop_cancel_signal, + ) -> None: + # Arrange + strategy = StrategyEnum.CANCEL + request = RequestFactory().get("/") + + # Act + run_instructor_confirmed_for_workshop_strategy( + strategy, + request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + mock_instructor_confirmed_for_workshop_cancel_signal.send.assert_called_once_with( # noqa: E501 + sender=self.task, + request=request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + @patch("emails.actions.base_strategy.logger") + @patch( + "emails.actions.instructor_confirmed_for_workshop." + "instructor_confirmed_for_workshop_signal" + ) + @patch( + "emails.actions.instructor_confirmed_for_workshop." + "instructor_confirmed_for_workshop_update_signal" + ) + @patch( + "emails.actions.instructor_confirmed_for_workshop." + "instructor_confirmed_for_workshop_cancel_signal" + ) + def test_invalid_strategy_no_signal_called( + self, + mock_instructor_confirmed_for_workshop_cancel_signal, + mock_instructor_confirmed_for_workshop_update_signal, + mock_instructor_confirmed_for_workshop_signal, + mock_logger, + ) -> None: + # Arrange + strategy = StrategyEnum.NOOP + request = RequestFactory().get("/") + + # Act + run_instructor_confirmed_for_workshop_strategy( + strategy, + request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + mock_instructor_confirmed_for_workshop_signal.send.assert_not_called() + mock_instructor_confirmed_for_workshop_update_signal.send.assert_not_called() + mock_instructor_confirmed_for_workshop_cancel_signal.send.assert_not_called() + mock_logger.debug.assert_called_once_with( + f"Strategy {strategy} for {self.task} is a no-op" + ) + + def test_invalid_strategy(self) -> None: + # Arrange + strategy = MagicMock() + request = RequestFactory().get("/") + + # Act & Assert + with self.assertRaises( + EmailStrategyException, msg=f"Unknown strategy {strategy}" + ): + run_instructor_confirmed_for_workshop_strategy( + strategy, + request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) diff --git a/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_update_receiver.py b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_update_receiver.py new file mode 100644 index 000000000..efb738183 --- /dev/null +++ b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_update_receiver.py @@ -0,0 +1,368 @@ +from datetime import UTC, date, datetime, timedelta +from unittest.mock import MagicMock, patch + +from django.test import RequestFactory, TestCase, override_settings +from django.urls import reverse + +from emails.actions.instructor_confirmed_for_workshop import ( + instructor_confirmed_for_workshop_strategy, + instructor_confirmed_for_workshop_update_receiver, + run_instructor_confirmed_for_workshop_strategy, +) +from emails.models import EmailTemplate, ScheduledEmail, ScheduledEmailStatus +from emails.schemas import ContextModel, ToHeaderModel +from emails.signals import ( + INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + instructor_confirmed_for_workshop_update_signal, +) +from emails.utils import api_model_url, scalar_value_none +from workshops.forms import PersonForm +from workshops.models import Event, Organization, Person, Role, Task +from workshops.tests.base import TestBase + + +class TestInstructorConfirmedForWorkshopUpdateReceiver(TestCase): + def setUp(self) -> None: + host = Organization.objects.create(domain="test.com", fullname="Test") + self.event = Event.objects.create( + slug="test-event", host=host, start=date(2024, 8, 5), end=date(2024, 8, 5) + ) + self.person = Person.objects.create(email="test@example.org") + instructor = Role.objects.create(name="instructor") + self.task = Task.objects.create( + role=instructor, person=self.person, event=self.event + ) + + def setUpEmailTemplate(self) -> EmailTemplate: + return EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + from_header="workshops@carpentries.org", + cc_header=["team@carpentries.org"], + bcc_header=[], + subject="Greetings {{ name }}", + body="Hello, {{ name }}! Nice to meet **you**.", + ) + + @patch("emails.actions.base_action.logger") + def test_disabled_when_no_feature_flag(self, mock_logger) -> None: + # Arrange + request = RequestFactory().get("/") + with self.settings(FLAGS={"EMAIL_MODULE": [("boolean", False)]}): + # Act + instructor_confirmed_for_workshop_update_receiver(None, request=request) + # Assert + mock_logger.debug.assert_called_once_with( + "EMAIL_MODULE feature flag not set, skipping " + "instructor_confirmed_for_workshop_update" + ) + + def test_receiver_connected_to_signal(self) -> None: + # Arrange + original_receivers = instructor_confirmed_for_workshop_update_signal.receivers[ + : + ] + + # Act + # attempt to connect the receiver + instructor_confirmed_for_workshop_update_signal.connect( + instructor_confirmed_for_workshop_update_receiver + ) + new_receivers = instructor_confirmed_for_workshop_update_signal.receivers[:] + + # Assert + # the same receiver list means this receiver has already been connected + self.assertEqual(original_receivers, new_receivers) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + def test_action_triggered(self) -> None: + # Arrange + request = RequestFactory().get("/") + + template = self.setUpEmailTemplate() + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.person, + ) + + # Act + with patch( + "emails.actions.base_action.messages_action_updated" + ) as mock_messages_action_updated: + instructor_confirmed_for_workshop_update_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + scheduled_email = ScheduledEmail.objects.get(template=template) + mock_messages_action_updated.assert_called_once_with( + request, + INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + scheduled_email, + ) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @patch("emails.actions.base_action.messages_action_updated") + @patch("emails.actions.instructor_confirmed_for_workshop.immediate_action") + def test_email_updated( + self, + mock_immediate_action: MagicMock, + mock_messages_action_updated: MagicMock, + ) -> None: + # Arrange + request = RequestFactory().get("/") + template = self.setUpEmailTemplate() + scheduled_email = ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.person, + ) + scheduled_at = datetime(2024, 8, 5, 12, 0, tzinfo=UTC) + mock_immediate_action.return_value = scheduled_at + + # Act + with patch( + "emails.actions.base_action.EmailController.update_scheduled_email" + ) as mock_update_scheduled_email: + instructor_confirmed_for_workshop_update_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + mock_update_scheduled_email.assert_called_once_with( + scheduled_email=scheduled_email, + context_json=ContextModel( + { + "person": api_model_url("person", self.person.pk), + "event": api_model_url("event", self.event.pk), + "instructor_recruitment_signup": scalar_value_none(), + } + ), + scheduled_at=scheduled_at, + to_header=[self.person.email], + to_header_context_json=ToHeaderModel( + [ + { + "api_uri": api_model_url("person", self.person.pk), + "property": "email", + }, # type: ignore + ] + ), + generic_relation_obj=self.person, + author=None, + ) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @patch("emails.actions.base_action.logger") + @patch("emails.actions.base_action.EmailController") + def test_previously_scheduled_email_not_existing( + self, mock_email_controller: MagicMock, mock_logger: MagicMock + ) -> None: + # Arrange + request = RequestFactory().get("/") + signal = INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME + person = self.person + + # Act + instructor_confirmed_for_workshop_update_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=person.pk, + event_id=self.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + mock_email_controller.update_scheduled_email.assert_not_called() + mock_logger.warning.assert_called_once_with( + f"Scheduled email for signal {signal} and generic_relation_obj={person!r} " + "does not exist." + ) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @patch("emails.actions.base_action.logger") + @patch("emails.actions.base_action.EmailController") + def test_multiple_previously_scheduled_emails( + self, mock_email_controller: MagicMock, mock_logger: MagicMock + ) -> None: + # Arrange + request = RequestFactory().get("/") + signal = INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME + template = self.setUpEmailTemplate() + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.person, + ) + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.person, + ) + person = self.person + + # Act + instructor_confirmed_for_workshop_update_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + mock_email_controller.update_scheduled_email.assert_not_called() + mock_logger.warning.assert_called_once_with( + f"Too many scheduled emails for signal {signal} and " + f"generic_relation_obj={person!r}. Can't update them." + ) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @patch("emails.actions.base_action.messages_missing_recipients") + def test_missing_recipients( + self, mock_messages_missing_recipients: MagicMock + ) -> None: + # Arrange + request = RequestFactory().get("/") + template = self.setUpEmailTemplate() + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.person, + ) + signal = INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME + self.person.email = "" + self.person.save() + + # Act + instructor_confirmed_for_workshop_update_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + + # Assert + mock_messages_missing_recipients.assert_called_once_with(request, signal) + + +class TestInstructorConfirmedForWorkshopUpdateIntegration(TestBase): + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + def test_integration(self) -> None: + # Arrange + self._setUpRoles() + self._setUpTags() + self._setUpUsersAndLogin() + + template = EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + from_header="workshops@carpentries.org", + cc_header=["team@carpentries.org"], + bcc_header=[], + subject="Greetings", + body="Hello! Nice to meet **you**.", + ) + + ttt_organization = Organization.objects.create( + domain="carpentries.org", fullname="Instructor Training" + ) + host_organization = Organization.objects.create( + domain="example.com", fullname="Example" + ) + event = Event.objects.create( + slug="2024-08-05-test-event", + host=host_organization, + administrator=ttt_organization, + start=date.today() + timedelta(days=30), + ) + + instructor = Person.objects.create( + personal="Kelsi", + middle="", + family="Purdy", + username="purdy_kelsi", + email="purdy.kelsi@example.com", + secondary_email="notused@amy.org", + gender="F", + airport=self.airport_0_0, + github="", + twitter="", + url="http://kelsipurdy.com/", + affiliation="University of Arizona", + occupation="TA at Biology Department", + orcid="0000-0000-0000", + is_active=True, + ) + instructor_role = Role.objects.get(name="instructor") + task = Task.objects.create(event=event, person=instructor, role=instructor_role) + + request = RequestFactory().get("/") + with patch( + "emails.actions.base_action.messages_action_scheduled" + ) as mock_action_scheduled: + run_instructor_confirmed_for_workshop_strategy( + instructor_confirmed_for_workshop_strategy(task), + request, + task=task, + person_id=task.person.pk, + event_id=task.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + scheduled_email = ScheduledEmail.objects.get(template=template) + + url = reverse("person_edit", args=[instructor.pk]) + new_email = "fake_email@example.org" + data = PersonForm(instance=instructor).initial + data.update({"email": new_email, "github": "", "twitter": ""}) + + # Act + rv = self.client.post(url, data) + + # Arrange + mock_action_scheduled.assert_called_once() + self.assertEqual(rv.status_code, 302) + scheduled_email.refresh_from_db() + self.assertEqual(scheduled_email.state, ScheduledEmailStatus.SCHEDULED) + self.assertEqual(scheduled_email.to_header, [new_email]) diff --git a/amy/workshops/views.py b/amy/workshops/views.py index e8770ce8c..94ee19f02 100644 --- a/amy/workshops/views.py +++ b/amy/workshops/views.py @@ -3,7 +3,7 @@ from functools import partial import io import logging -from typing import Optional +from typing import Optional, cast from django.conf import settings from django.contrib import messages @@ -1817,6 +1817,16 @@ def form_valid(self, form): self.object.event, ) + run_instructor_confirmed_for_workshop_strategy( + instructor_confirmed_for_workshop_strategy(self.object), + self.request, + task=self.object, + person_id=self.object.person.pk, + event_id=self.object.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + return res @@ -1833,8 +1843,8 @@ class TaskDelete( object: Task def before_delete(self, *args, **kwargs): - old = self.get_object() - self.event = old.event + self.old: Task = cast(Task, self.get_object()) + self.event = self.old.event def after_delete(self, *args, **kwargs): run_instructor_training_approaching_strategy( @@ -1867,6 +1877,16 @@ def after_delete(self, *args, **kwargs): self.object.event, ) + run_instructor_confirmed_for_workshop_strategy( + instructor_confirmed_for_workshop_strategy(self.object), + self.request, + task=self.old, + person_id=self.object.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=None, + instructor_recruitment_signup_id=None, + ) + # ------------------------------------------------------------