diff --git a/amy/emails/actions/instructor_confirmed_for_workshop.py b/amy/emails/actions/instructor_confirmed_for_workshop.py index dab4aa044..ad3c7f393 100644 --- a/amy/emails/actions/instructor_confirmed_for_workshop.py +++ b/amy/emails/actions/instructor_confirmed_for_workshop.py @@ -1,6 +1,5 @@ from datetime import datetime import logging -from typing import Any from django.contrib.contenttypes.models import ContentType from django.http import HttpRequest @@ -9,7 +8,7 @@ from emails.actions.base_action import BaseAction, BaseActionCancel, BaseActionUpdate from emails.actions.base_strategy import run_strategy -from emails.models import ScheduledEmail +from emails.models import ScheduledEmail, ScheduledEmailStatus from emails.schemas import ContextModel, ToHeaderModel from emails.signals import ( INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, @@ -23,41 +22,34 @@ InstructorConfirmedKwargs, StrategyEnum, ) -from emails.utils import ( - api_model_url, - immediate_action, - log_condition_elements, - scalar_value_none, - scalar_value_url, -) +from emails.utils import api_model_url, immediate_action, log_condition_elements from recruitment.models import InstructorRecruitmentSignup -from workshops.models import Event, Person, TagQuerySet, Task +from workshops.models import Event, Person, TagQuerySet logger = logging.getLogger("amy") def instructor_confirmed_for_workshop_strategy( - task: Task, optional_task_pk: int | None = None + signup: InstructorRecruitmentSignup, ) -> StrategyEnum: - logger.info(f"Running InstructorConfirmedForWorkshop strategy for {task=}") + logger.info(f"Running InstructorConfirmedForWorkshop strategy for {signup=}") - instructor_role = task.role.name == "instructor" - person_email_exists = bool(task.person.email) - carpentries_tags = task.event.tags.filter( + signup_is_accepted = signup.state == "a" + person_email_exists = bool(signup.person.email) + event = signup.recruitment.event + carpentries_tags = event.tags.filter( name__in=TagQuerySet.CARPENTRIES_TAG_NAMES ).exclude(name__in=TagQuerySet.NON_CARPENTRIES_TAG_NAMES) centrally_organised = ( - task.event.administrator and task.event.administrator.domain != "self-organized" - ) - start_date_in_future = ( - task.event.start and task.event.start >= timezone.now().date() + event.administrator and event.administrator.domain != "self-organized" ) + start_date_in_future = event.start and event.start >= timezone.now().date() log_condition_elements( - task=task, - task_pk=task.pk, - optional_task_pk=optional_task_pk, - instructor_role=instructor_role, + signup=signup, + signup_pk=signup.pk, + signup_is_accepted=signup_is_accepted, + event=event, person_email_exists=person_email_exists, carpentries_tags=carpentries_tags, centrally_organised=centrally_organised, @@ -65,8 +57,7 @@ def instructor_confirmed_for_workshop_strategy( ) email_should_exist = ( - task.pk - and instructor_role + signup_is_accepted and person_email_exists and carpentries_tags and centrally_organised @@ -74,11 +65,12 @@ def instructor_confirmed_for_workshop_strategy( ) logger.debug(f"{email_should_exist=}") - ct = ContentType.objects.get_for_model(Task) + ct = ContentType.objects.get_for_model(InstructorRecruitmentSignup) email_exists = ScheduledEmail.objects.filter( generic_relation_content_type=ct, - generic_relation_pk=optional_task_pk or task.pk, + generic_relation_pk=signup.pk, template__signal=INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + state=ScheduledEmailStatus.SCHEDULED, ).exists() logger.debug(f"{email_exists=}") @@ -96,7 +88,10 @@ def instructor_confirmed_for_workshop_strategy( def run_instructor_confirmed_for_workshop_strategy( - strategy: StrategyEnum, request: HttpRequest, task: Task, **kwargs + strategy: StrategyEnum, + request: HttpRequest, + signup: InstructorRecruitmentSignup, + **kwargs, ) -> None: signal_mapping: dict[StrategyEnum, Signal | None] = { StrategyEnum.CREATE: instructor_confirmed_for_workshop_signal, @@ -108,8 +103,8 @@ def run_instructor_confirmed_for_workshop_strategy( strategy, signal_mapping, request, - sender=task, - task=task, + sender=signup, + signup=signup, **kwargs, ) @@ -123,35 +118,24 @@ def get_context( ) -> InstructorConfirmedContext: person = Person.objects.get(pk=kwargs["person_id"]) event = Event.objects.get(pk=kwargs["event_id"]) - task = Task.objects.filter(pk=kwargs["task_id"]).first() - instructor_recruitment_signup = InstructorRecruitmentSignup.objects.filter( + instructor_recruitment_signup = InstructorRecruitmentSignup.objects.get( pk=kwargs["instructor_recruitment_signup_id"] - ).first() + ) return { "person": person, "event": event, - "task": task, - "task_id": kwargs["task_id"], "instructor_recruitment_signup": instructor_recruitment_signup, } def get_context_json(context: InstructorConfirmedContext) -> ContextModel: - signup = context["instructor_recruitment_signup"] - task = context["task"] return ContextModel( { "person": api_model_url("person", context["person"].pk), "event": api_model_url("event", context["event"].pk), - "task": api_model_url("task", task.pk) if task else scalar_value_none(), - "task_id": scalar_value_url("int", f"{context['task_id']}"), - "instructor_recruitment_signup": ( - api_model_url( - "instructorrecruitmentsignup", - signup.pk, - ) - if signup - else scalar_value_none() + "instructor_recruitment_signup": api_model_url( + "instructorrecruitmentsignup", + context["instructor_recruitment_signup"].pk, ), }, ) @@ -159,9 +143,8 @@ def get_context_json(context: InstructorConfirmedContext) -> ContextModel: def get_generic_relation_object( context: InstructorConfirmedContext, **kwargs: Unpack[InstructorConfirmedKwargs] -) -> Task: - # When removing task, this will be None. - return context["task"] # type: ignore +) -> InstructorRecruitmentSignup: + return context["instructor_recruitment_signup"] def get_recipients( @@ -203,7 +186,7 @@ def get_generic_relation_object( self, context: InstructorConfirmedContext, **kwargs: Unpack[InstructorConfirmedKwargs], - ) -> Task: + ) -> InstructorRecruitmentSignup: return get_generic_relation_object(context, **kwargs) def get_recipients( @@ -239,7 +222,7 @@ def get_generic_relation_object( self, context: InstructorConfirmedContext, **kwargs: Unpack[InstructorConfirmedKwargs], - ) -> Task: + ) -> InstructorRecruitmentSignup: return get_generic_relation_object(context, **kwargs) def get_recipients( @@ -268,23 +251,20 @@ def get_context( def get_context_json(self, context: InstructorConfirmedContext) -> ContextModel: return get_context_json(context) - def get_generic_relation_content_type( - self, context: InstructorConfirmedContext, generic_relation_obj: Any - ) -> ContentType: - return ContentType.objects.get_for_model(Task) - - def get_generic_relation_pk( - self, context: InstructorConfirmedContext, generic_relation_obj: Any - ) -> int | Any: - return context["task_id"] - def get_generic_relation_object( self, context: InstructorConfirmedContext, **kwargs: Unpack[InstructorConfirmedKwargs], - ) -> Task: + ) -> InstructorRecruitmentSignup: return get_generic_relation_object(context, **kwargs) + def get_recipients( + self, + context: InstructorConfirmedContext, + **kwargs: Unpack[InstructorConfirmedKwargs], + ) -> list[str]: + return get_recipients(context, **kwargs) + def get_recipients_context_json( self, context: InstructorConfirmedContext, diff --git a/amy/emails/actions/instructor_task_created_for_workshop.py b/amy/emails/actions/instructor_task_created_for_workshop.py new file mode 100644 index 000000000..3e4c49f0c --- /dev/null +++ b/amy/emails/actions/instructor_task_created_for_workshop.py @@ -0,0 +1,319 @@ +from datetime import datetime +import logging +from typing import Any + +from django.contrib.contenttypes.models import ContentType +from django.http import HttpRequest +from django.utils import timezone +from typing_extensions import Unpack + +from emails.actions.base_action import BaseAction, BaseActionCancel, BaseActionUpdate +from emails.actions.base_strategy import run_strategy +from emails.models import ScheduledEmail +from emails.schemas import ContextModel, ToHeaderModel +from emails.signals import ( + INSTRUCTOR_TASK_CREATED_FOR_WORKSHOP_SIGNAL_NAME, + Signal, + instructor_task_created_for_workshop_cancel_signal, + instructor_task_created_for_workshop_signal, + instructor_task_created_for_workshop_update_signal, +) +from emails.types import ( + InstructorTaskCreatedForWorkshopContext, + InstructorTaskCreatedForWorkshopKwargs, + StrategyEnum, +) +from emails.utils import ( + api_model_url, + immediate_action, + log_condition_elements, + scalar_value_none, + scalar_value_url, +) +from workshops.models import Event, Person, TagQuerySet, Task + +logger = logging.getLogger("amy") + + +def instructor_task_created_for_workshop_strategy( + task: Task, optional_task_pk: int | None = None +) -> StrategyEnum: + logger.info( + f"Running InstructorTaskCreatedForWorkshopForWorkshop strategy for {task=}" + ) + + instructor_role = task.role.name == "instructor" + person_email_exists = bool(task.person.email) + carpentries_tags = task.event.tags.filter( + name__in=TagQuerySet.CARPENTRIES_TAG_NAMES + ).exclude(name__in=TagQuerySet.NON_CARPENTRIES_TAG_NAMES) + centrally_organised = ( + task.event.administrator and task.event.administrator.domain != "self-organized" + ) + start_date_in_future = ( + task.event.start and task.event.start >= timezone.now().date() + ) + + log_condition_elements( + task=task, + task_pk=task.pk, + optional_task_pk=optional_task_pk, + instructor_role=instructor_role, + person_email_exists=person_email_exists, + carpentries_tags=carpentries_tags, + centrally_organised=centrally_organised, + start_date_in_future=start_date_in_future, + ) + + email_should_exist = ( + task.pk + and instructor_role + and person_email_exists + and carpentries_tags + and centrally_organised + and start_date_in_future + ) + logger.debug(f"{email_should_exist=}") + + ct = ContentType.objects.get_for_model(Task) + email_exists = ScheduledEmail.objects.filter( + generic_relation_content_type=ct, + generic_relation_pk=optional_task_pk or task.pk, + template__signal=INSTRUCTOR_TASK_CREATED_FOR_WORKSHOP_SIGNAL_NAME, + ).exists() + logger.debug(f"{email_exists=}") + + if not email_exists and email_should_exist: + result = StrategyEnum.CREATE + elif email_exists and not email_should_exist: + result = StrategyEnum.CANCEL + elif email_exists and email_should_exist: + result = StrategyEnum.UPDATE + else: + result = StrategyEnum.NOOP + + logger.debug(f"InstructorTaskCreatedForWorkshop strategy {result = }") + return result + + +def run_instructor_task_created_for_workshop_strategy( + strategy: StrategyEnum, request: HttpRequest, task: Task, **kwargs +) -> None: + signal_mapping: dict[StrategyEnum, Signal | None] = { + StrategyEnum.CREATE: instructor_task_created_for_workshop_signal, + StrategyEnum.UPDATE: instructor_task_created_for_workshop_update_signal, + StrategyEnum.CANCEL: instructor_task_created_for_workshop_cancel_signal, + StrategyEnum.NOOP: None, + } + return run_strategy( + strategy, + signal_mapping, + request, + sender=task, + task=task, + **kwargs, + ) + + +def get_scheduled_at( + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], +) -> datetime: + return immediate_action() + + +def get_context( + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], +) -> InstructorTaskCreatedForWorkshopContext: + person = Person.objects.get(pk=kwargs["person_id"]) + event = Event.objects.get(pk=kwargs["event_id"]) + task = Task.objects.filter(pk=kwargs["task_id"]).first() + return { + "person": person, + "event": event, + "task": task, + "task_id": kwargs["task_id"], + } + + +def get_context_json(context: InstructorTaskCreatedForWorkshopContext) -> ContextModel: + task = context["task"] + return ContextModel( + { + "person": api_model_url("person", context["person"].pk), + "event": api_model_url("event", context["event"].pk), + "task": api_model_url("task", task.pk) if task else scalar_value_none(), + "task_id": scalar_value_url("int", f"{context['task_id']}"), + }, + ) + + +def get_generic_relation_object( + context: InstructorTaskCreatedForWorkshopContext, + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], +) -> Task: + # When removing task, this will be None. + return context["task"] # type: ignore + + +def get_recipients( + context: InstructorTaskCreatedForWorkshopContext, + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], +) -> list[str]: + person = context["person"] + return [person.email] if person.email else [] + + +def get_recipients_context_json( + context: InstructorTaskCreatedForWorkshopContext, + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], +) -> ToHeaderModel: + return ToHeaderModel( + [ + { + "api_uri": api_model_url("person", context["person"].pk), + "property": "email", + }, # type: ignore + ], + ) + + +class InstructorTaskCreatedForWorkshopReceiver(BaseAction): + signal = instructor_task_created_for_workshop_signal.signal_name + + def get_scheduled_at( + self, **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs] + ) -> datetime: + return get_scheduled_at(**kwargs) + + def get_context( + self, **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs] + ) -> InstructorTaskCreatedForWorkshopContext: + return get_context(**kwargs) + + def get_context_json( + self, context: InstructorTaskCreatedForWorkshopContext + ) -> ContextModel: + return get_context_json(context) + + def get_generic_relation_object( + self, + context: InstructorTaskCreatedForWorkshopContext, + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], + ) -> Task: + return get_generic_relation_object(context, **kwargs) + + def get_recipients( + self, + context: InstructorTaskCreatedForWorkshopContext, + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], + ) -> list[str]: + return get_recipients(context, **kwargs) + + def get_recipients_context_json( + self, + context: InstructorTaskCreatedForWorkshopContext, + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], + ) -> ToHeaderModel: + return get_recipients_context_json(context, **kwargs) + + +class InstructorTaskCreatedForWorkshopUpdateReceiver(BaseActionUpdate): + signal = instructor_task_created_for_workshop_signal.signal_name + + def get_scheduled_at( + self, **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs] + ) -> datetime: + return get_scheduled_at(**kwargs) + + def get_context( + self, **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs] + ) -> InstructorTaskCreatedForWorkshopContext: + return get_context(**kwargs) + + def get_context_json( + self, context: InstructorTaskCreatedForWorkshopContext + ) -> ContextModel: + return get_context_json(context) + + def get_generic_relation_object( + self, + context: InstructorTaskCreatedForWorkshopContext, + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], + ) -> Task: + return get_generic_relation_object(context, **kwargs) + + def get_recipients( + self, + context: InstructorTaskCreatedForWorkshopContext, + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], + ) -> list[str]: + return get_recipients(context, **kwargs) + + def get_recipients_context_json( + self, + context: InstructorTaskCreatedForWorkshopContext, + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], + ) -> ToHeaderModel: + return get_recipients_context_json(context, **kwargs) + + +class InstructorTaskCreatedForWorkshopCancelReceiver(BaseActionCancel): + signal = instructor_task_created_for_workshop_signal.signal_name + + def get_context( + self, **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs] + ) -> InstructorTaskCreatedForWorkshopContext: + return get_context(**kwargs) + + def get_context_json( + self, context: InstructorTaskCreatedForWorkshopContext + ) -> ContextModel: + return get_context_json(context) + + def get_generic_relation_content_type( + self, + context: InstructorTaskCreatedForWorkshopContext, + generic_relation_obj: Any, + ) -> ContentType: + return ContentType.objects.get_for_model(Task) + + def get_generic_relation_pk( + self, + context: InstructorTaskCreatedForWorkshopContext, + generic_relation_obj: Any, + ) -> int | Any: + return context["task_id"] + + def get_generic_relation_object( + self, + context: InstructorTaskCreatedForWorkshopContext, + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], + ) -> Task: + return get_generic_relation_object(context, **kwargs) + + def get_recipients_context_json( + self, + context: InstructorTaskCreatedForWorkshopContext, + **kwargs: Unpack[InstructorTaskCreatedForWorkshopKwargs], + ) -> ToHeaderModel: + return get_recipients_context_json(context, **kwargs) + + +instructor_task_created_for_workshop_receiver = ( + InstructorTaskCreatedForWorkshopReceiver() +) +instructor_task_created_for_workshop_signal.connect( + instructor_task_created_for_workshop_receiver +) +instructor_task_created_for_workshop_update_receiver = ( + InstructorTaskCreatedForWorkshopUpdateReceiver() +) +instructor_task_created_for_workshop_update_signal.connect( + instructor_task_created_for_workshop_update_receiver +) +instructor_task_created_for_workshop_cancel_receiver = ( + InstructorTaskCreatedForWorkshopCancelReceiver() +) +instructor_task_created_for_workshop_cancel_signal.connect( + instructor_task_created_for_workshop_cancel_receiver +) diff --git a/amy/emails/signals.py b/amy/emails/signals.py index b561ec99b..63976f800 100644 --- a/amy/emails/signals.py +++ b/amy/emails/signals.py @@ -11,6 +11,7 @@ InstructorConfirmedContext, InstructorDeclinedContext, InstructorSignupContext, + InstructorTaskCreatedForWorkshopContext, InstructorTrainingApproachingContext, InstructorTrainingCompletedNotBadgedContext, NewMembershipOnboardingContext, @@ -28,6 +29,7 @@ class SignalNameEnum(StrEnum): instructor_signs_up_for_workshop = "instructor_signs_up_for_workshop" admin_signs_instructor_up_for_workshop = "admin_signs_instructor_up_for_workshop" persons_merged = "persons_merged" + instructor_task_created_for_workshop = "instructor_task_created_for_workshop" instructor_training_approaching = "instructor_training_approaching" instructor_training_completed_not_badged = ( "instructor_training_completed_not_badged" @@ -103,6 +105,17 @@ def triple_signals(name: str, context_type: Any) -> tuple[Signal, Signal, Signal signal_name=SignalNameEnum.persons_merged, context_type=PersonsMergedContext, ) +INSTRUCTOR_TASK_CREATED_FOR_WORKSHOP_SIGNAL_NAME = ( + "instructor_task_created_for_workshop" +) +( + instructor_task_created_for_workshop_signal, + instructor_task_created_for_workshop_update_signal, + instructor_task_created_for_workshop_cancel_signal, +) = triple_signals( + SignalNameEnum.instructor_task_created_for_workshop, + InstructorTaskCreatedForWorkshopContext, +) # Runs 1 month before the event. INSTRUCTOR_TRAINING_APPROACHING_SIGNAL_NAME = "instructor_training_approaching" 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 index 6423f7f71..2f2b41513 100644 --- 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 @@ -4,6 +4,7 @@ from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse +from communityroles.models import CommunityRole, CommunityRoleConfig from emails.actions.instructor_confirmed_for_workshop import ( instructor_confirmed_for_workshop_cancel_receiver, instructor_confirmed_for_workshop_strategy, @@ -14,7 +15,8 @@ INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, instructor_confirmed_for_workshop_cancel_signal, ) -from workshops.models import Event, Organization, Person, Role, Tag, Task +from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup +from workshops.models import Event, Organization, Person, Tag from workshops.tests.base import TestBase @@ -25,9 +27,11 @@ def setUp(self) -> None: 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 + self.recruitment = InstructorRecruitment.objects.create( + event=self.event, notes="Test notes" + ) + self.signup = InstructorRecruitmentSignup.objects.create( + recruitment=self.recruitment, person=self.person ) def setUpEmailTemplate(self) -> EmailTemplate: @@ -84,7 +88,7 @@ def test_action_triggered(self) -> None: cc_header=[], bcc_header=[], state=ScheduledEmailStatus.SCHEDULED, - generic_relation=self.task, + generic_relation=self.signup, ) # Act @@ -92,14 +96,12 @@ def test_action_triggered(self) -> None: "emails.actions.base_action.messages_action_cancelled" ) as mock_messages_action_cancelled: instructor_confirmed_for_workshop_cancel_signal.send( - sender=self.task, + sender=self.signup, request=request, - task=self.task, person_id=self.person.pk, event_id=self.event.pk, - task_id=self.task.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # Assert @@ -126,7 +128,7 @@ def test_email_cancelled( cc_header=[], bcc_header=[], state=ScheduledEmailStatus.SCHEDULED, - generic_relation=self.task, + generic_relation=self.signup, ) # Act @@ -134,14 +136,12 @@ def test_email_cancelled( "emails.actions.base_action.EmailController.cancel_email" ) as mock_cancel_email: instructor_confirmed_for_workshop_cancel_signal.send( - sender=self.task, + sender=self.signup, request=request, - task=self.task, person_id=self.person.pk, event_id=self.event.pk, - task_id=self.task.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # Assert @@ -166,7 +166,7 @@ def test_multiple_emails_cancelled( cc_header=[], bcc_header=[], state=ScheduledEmailStatus.SCHEDULED, - generic_relation=self.task, + generic_relation=self.signup, ) scheduled_email2 = ScheduledEmail.objects.create( template=template, @@ -175,7 +175,7 @@ def test_multiple_emails_cancelled( cc_header=[], bcc_header=[], state=ScheduledEmailStatus.SCHEDULED, - generic_relation=self.task, + generic_relation=self.signup, ) # Act @@ -183,14 +183,12 @@ def test_multiple_emails_cancelled( "emails.actions.base_action.EmailController.cancel_email" ) as mock_cancel_email: instructor_confirmed_for_workshop_cancel_signal.send( - sender=self.task, + sender=self.signup, request=request, - task=self.task, person_id=self.person.pk, event_id=self.event.pk, - task_id=self.task.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # Assert @@ -209,12 +207,49 @@ def test_multiple_emails_cancelled( class TestInstructorConfirmedForWorkshopCancelIntegration(TestBase): - @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @override_settings( + FLAGS={ + "INSTRUCTOR_RECRUITMENT": [("boolean", True)], + "EMAIL_MODULE": [("boolean", True)], + } + ) def test_integration(self) -> None: # Arrange self._setUpRoles() self._setUpTags() + self._setUpAdministrators() self._setUpUsersAndLogin() + host = Organization.objects.create(domain="test.com", fullname="Test") + person = Person.objects.create_user( # type: ignore + username="test_test", + personal="Test", + family="User", + email="test@user.com", + password="test", + ) + config = CommunityRoleConfig.objects.create( + name="instructor", + display_name="Instructor", + link_to_award=False, + link_to_membership=False, + additional_url=False, + ) + CommunityRole.objects.create( + config=config, + person=person, + ) + event = Event.objects.create( + slug="test-event", + host=host, + start=date.today() + timedelta(days=7), + end=date.today() + timedelta(days=8), + administrator=Organization.objects.get(domain="software-carpentry.org"), + ) + event.tags.add(Tag.objects.get(name="SWC")) + recruitment = InstructorRecruitment.objects.create(status="o", event=event) + signup = InstructorRecruitmentSignup.objects.create( + recruitment=recruitment, person=person, state="a" + ) template = EmailTemplate.objects.create( name="Test Email Template", @@ -226,61 +261,26 @@ def test_integration(self) -> None: 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), - ) - event.tags.set([Tag.objects.get(name="SWC")]) - - 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="", - bluesky="@purdy_kelsi.bsky.social", - 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), + instructor_confirmed_for_workshop_strategy(signup), request, - task=task, - person_id=task.person.pk, - event_id=task.event.pk, - task_id=task.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, + signup=signup, + person_id=person.pk, + event_id=event.pk, + instructor_recruitment_id=recruitment.pk, + instructor_recruitment_signup_id=signup.pk, ) scheduled_email = ScheduledEmail.objects.get(template=template) - url = reverse("task_delete", args=[task.pk]) + url = reverse("instructorrecruitmentsignup_changestate", args=[signup.pk]) + payload = {"action": "decline"} # Act - rv = self.client.post(url) + rv = self.client.post(url, payload) # Arrange mock_action_scheduled.assert_called_once() diff --git a/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_receiver.py b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_receiver.py index 5151dc7d1..5aabf8dac 100644 --- a/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_receiver.py +++ b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_receiver.py @@ -10,10 +10,13 @@ ) from emails.models import EmailTemplate, ScheduledEmail from emails.schemas import ContextModel, ToHeaderModel -from emails.signals import instructor_confirmed_for_workshop_signal -from emails.utils import api_model_url, scalar_value_url +from emails.signals import ( + INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, + instructor_confirmed_for_workshop_signal, +) +from emails.utils import api_model_url from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup -from workshops.models import Event, Organization, Person, Role, Tag, Task +from workshops.models import Event, Organization, Person, Tag from workshops.tests.base import TestBase @@ -57,8 +60,6 @@ def test_action_triggered(self) -> None: event=event, notes="Test notes" ) person = Person.objects.create(email="test@example.org") - instructor_role = Role.objects.create(name="instructor") - task = Task.objects.create(person=person, event=event, role=instructor_role) signup = InstructorRecruitmentSignup.objects.create( recruitment=recruitment, person=person ) @@ -82,7 +83,6 @@ def test_action_triggered(self) -> None: request=request, person_id=signup.person.pk, event_id=signup.recruitment.event.pk, - task_id=task.pk, instructor_recruitment_id=signup.recruitment.pk, instructor_recruitment_signup_id=signup.pk, ) @@ -112,8 +112,6 @@ def test_email_scheduled( event=event, notes="Test notes" ) person = Person.objects.create(email="test@example.org") - instructor_role = Role.objects.create(name="instructor") - task = Task.objects.create(person=person, event=event, role=instructor_role) signup = InstructorRecruitmentSignup.objects.create( recruitment=recruitment, person=person ) @@ -133,7 +131,6 @@ def test_email_scheduled( request=request, person_id=signup.person.pk, event_id=signup.recruitment.event.pk, - task_id=task.pk, instructor_recruitment_id=signup.recruitment.pk, instructor_recruitment_signup_id=signup.pk, ) @@ -145,8 +142,6 @@ def test_email_scheduled( { "person": api_model_url("person", person.pk), "event": api_model_url("event", event.pk), - "task": api_model_url("task", task.pk), - "task_id": scalar_value_url("int", task.pk), "instructor_recruitment_signup": api_model_url( "instructorrecruitmentsignup", signup.pk ), @@ -162,7 +157,7 @@ def test_email_scheduled( } # type: ignore ] ), - generic_relation_obj=task, + generic_relation_obj=signup, author=None, ) @@ -183,8 +178,6 @@ def test_missing_recipients( signup = InstructorRecruitmentSignup.objects.create( recruitment=recruitment, person=person ) - instructor_role = Role.objects.create(name="instructor") - task = Task.objects.create(person=person, event=event, role=instructor_role) request = RequestFactory().get("/") signal = instructor_confirmed_for_workshop_signal.signal_name @@ -194,7 +187,6 @@ def test_missing_recipients( request=request, person_id=signup.person.pk, event_id=signup.recruitment.event.pk, - task_id=task.pk, instructor_recruitment_id=signup.recruitment.pk, instructor_recruitment_signup_id=signup.pk, ) @@ -217,8 +209,6 @@ def test_missing_template(self, mock_messages_missing_template: MagicMock) -> No signup = InstructorRecruitmentSignup.objects.create( recruitment=recruitment, person=person ) - instructor_role = Role.objects.create(name="instructor") - task = Task.objects.create(person=person, event=event, role=instructor_role) request = RequestFactory().get("/") signal = instructor_confirmed_for_workshop_signal.signal_name @@ -228,7 +218,6 @@ def test_missing_template(self, mock_messages_missing_template: MagicMock) -> No request=request, person_id=signup.person.pk, event_id=signup.recruitment.event.pk, - task_id=task.pk, instructor_recruitment_id=signup.recruitment.pk, instructor_recruitment_signup_id=signup.pk, ) @@ -244,9 +233,7 @@ class TestInstructorConfirmedForWorkshopReceiverIntegration(TestBase): "EMAIL_MODULE": [("boolean", True)], } ) - def test_integration( - self, - ) -> None: + def test_integration(self) -> None: # Arrange self._setUpRoles() self._setUpTags() @@ -285,7 +272,7 @@ def test_integration( template = EmailTemplate.objects.create( name="Test Email Template", - signal=instructor_confirmed_for_workshop_signal.signal_name, + signal=INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME, from_header="workshops@carpentries.org", cc_header=["team@carpentries.org"], bcc_header=[], 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 index acc112af5..fbed5c8ac 100644 --- a/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_strategy.py +++ b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop_strategy.py @@ -12,7 +12,8 @@ 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, Tag, Task +from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup +from workshops.models import Event, Organization, Person, Tag class TestInstructorConfirmedForWorkshopStrategy(TestCase): @@ -28,16 +29,18 @@ def setUp(self) -> None: swc_tag = Tag.objects.create(name="SWC") self.event.tags.set([swc_tag]) 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 + self.recruitment = InstructorRecruitment.objects.create( + event=self.event, notes="Test notes" + ) + self.signup = InstructorRecruitmentSignup.objects.create( + recruitment=self.recruitment, person=self.person, state="a" ) def test_strategy_create(self) -> None: # Arrange # Act - result = instructor_confirmed_for_workshop_strategy(self.task) + result = instructor_confirmed_for_workshop_strategy(self.signup) # Assert self.assertEqual(result, StrategyEnum.CREATE) @@ -60,19 +63,19 @@ def test_strategy_update(self) -> None: cc_header=[], bcc_header=[], state=ScheduledEmailStatus.SCHEDULED, - generic_relation=self.task, + generic_relation=self.signup, ) # Act - result = instructor_confirmed_for_workshop_strategy(self.task) + result = instructor_confirmed_for_workshop_strategy(self.signup) # Assert self.assertEqual(result, StrategyEnum.UPDATE) def test_strategy_cancel(self) -> None: # Arrange - self.task.role = Role.objects.create(name="learner") - self.task.save() + self.signup.state = "d" + self.signup.save() template = EmailTemplate.objects.create( name="Test Email Template", @@ -90,11 +93,11 @@ def test_strategy_cancel(self) -> None: cc_header=[], bcc_header=[], state=ScheduledEmailStatus.SCHEDULED, - generic_relation=self.task, + generic_relation=self.signup, ) # Act - result = instructor_confirmed_for_workshop_strategy(self.task) + result = instructor_confirmed_for_workshop_strategy(self.signup) # Assert self.assertEqual(result, StrategyEnum.CANCEL) @@ -107,9 +110,11 @@ def setUp(self) -> None: 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 + self.recruitment = InstructorRecruitment.objects.create( + event=self.event, notes="Test notes" + ) + self.signup = InstructorRecruitmentSignup.objects.create( + recruitment=self.recruitment, person=self.person ) @patch( @@ -128,22 +133,22 @@ def test_strategy_calls_create_signal( 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, + signup=self.signup, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # Assert mock_instructor_confirmed_for_workshop_signal.send.assert_called_once_with( - sender=self.task, + sender=self.signup, 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, + signup=self.signup, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) @patch( @@ -152,7 +157,7 @@ def test_strategy_calls_create_signal( ) def test_strategy_calls_update_signal( self, - mock_instructor_confirmed_for_workshop_update_signal, + mock_update_signal, ) -> None: # Arrange strategy = StrategyEnum.UPDATE @@ -162,22 +167,22 @@ def test_strategy_calls_update_signal( 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, + signup=self.signup, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # Assert - mock_instructor_confirmed_for_workshop_update_signal.send.assert_called_once_with( # noqa: E501 - sender=self.task, + mock_update_signal.send.assert_called_once_with( + sender=self.signup, 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, + signup=self.signup, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) @patch( @@ -186,7 +191,7 @@ def test_strategy_calls_update_signal( ) def test_strategy_calls_cancel_signal( self, - mock_instructor_confirmed_for_workshop_cancel_signal, + mock_cancel_signal, ) -> None: # Arrange strategy = StrategyEnum.CANCEL @@ -196,22 +201,22 @@ def test_strategy_calls_cancel_signal( 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, + signup=self.signup, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # Assert - mock_instructor_confirmed_for_workshop_cancel_signal.send.assert_called_once_with( # noqa: E501 - sender=self.task, + mock_cancel_signal.send.assert_called_once_with( + sender=self.signup, 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, + signup=self.signup, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) @patch("emails.actions.base_strategy.logger") @@ -242,11 +247,11 @@ def test_invalid_strategy_no_signal_called( 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, + signup=self.signup, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # Assert @@ -254,7 +259,7 @@ def test_invalid_strategy_no_signal_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" + f"Strategy {strategy} for {self.signup} is a no-op" ) def test_invalid_strategy(self) -> None: @@ -269,9 +274,9 @@ def test_invalid_strategy(self) -> None: 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, + signup=self.signup, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) 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 index 5f7775940..571822b19 100644 --- 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 @@ -4,20 +4,26 @@ from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse +from communityroles.models import CommunityRole, CommunityRoleConfig 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.models import ( + EmailTemplate, + ScheduledEmail, + ScheduledEmailLog, + 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, scalar_value_url -from workshops.forms import PersonForm -from workshops.models import Event, Organization, Person, Role, Tag, Task +from emails.utils import api_model_url +from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup +from workshops.models import Event, Organization, Person, Tag from workshops.tests.base import TestBase @@ -28,9 +34,11 @@ def setUp(self) -> None: 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 + self.recruitment = InstructorRecruitment.objects.create( + event=self.event, notes="Test notes" + ) + self.signup = InstructorRecruitmentSignup.objects.create( + recruitment=self.recruitment, person=self.person ) def setUpEmailTemplate(self) -> EmailTemplate: @@ -80,7 +88,6 @@ def test_action_triggered(self) -> None: request = RequestFactory().get("/") template = self.setUpEmailTemplate() - task = self.task ScheduledEmail.objects.create( template=template, scheduled_at=datetime.now(UTC), @@ -88,7 +95,7 @@ def test_action_triggered(self) -> None: cc_header=[], bcc_header=[], state=ScheduledEmailStatus.SCHEDULED, - generic_relation=task, + generic_relation=self.signup, ) # Act @@ -96,14 +103,12 @@ def test_action_triggered(self) -> None: "emails.actions.base_action.messages_action_updated" ) as mock_messages_action_updated: instructor_confirmed_for_workshop_update_signal.send( - sender=task, + sender=self.signup, request=request, - task=task, person_id=self.person.pk, event_id=self.event.pk, - task_id=task.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # Assert @@ -125,7 +130,6 @@ def test_email_updated( # Arrange request = RequestFactory().get("/") template = self.setUpEmailTemplate() - task = self.task scheduled_email = ScheduledEmail.objects.create( template=template, scheduled_at=datetime.now(UTC), @@ -133,7 +137,7 @@ def test_email_updated( cc_header=[], bcc_header=[], state=ScheduledEmailStatus.SCHEDULED, - generic_relation=task, + generic_relation=self.signup, ) scheduled_at = datetime(2024, 8, 5, 12, 0, tzinfo=UTC) mock_immediate_action.return_value = scheduled_at @@ -143,14 +147,12 @@ def test_email_updated( "emails.actions.base_action.EmailController.update_scheduled_email" ) as mock_update_scheduled_email: instructor_confirmed_for_workshop_update_signal.send( - sender=self.task, + sender=self.signup, request=request, - task=self.task, person_id=self.person.pk, event_id=self.event.pk, - task_id=task.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # Assert @@ -160,9 +162,9 @@ def test_email_updated( { "person": api_model_url("person", self.person.pk), "event": api_model_url("event", self.event.pk), - "task": api_model_url("task", task.pk), - "task_id": scalar_value_url("int", task.pk), - "instructor_recruitment_signup": scalar_value_none(), + "instructor_recruitment_signup": api_model_url( + "instructorrecruitmentsignup", self.signup.pk + ), } ), scheduled_at=scheduled_at, @@ -175,7 +177,7 @@ def test_email_updated( }, # type: ignore ] ), - generic_relation_obj=task, + generic_relation_obj=self.signup, author=None, ) @@ -188,24 +190,22 @@ def test_previously_scheduled_email_not_existing( # Arrange request = RequestFactory().get("/") signal = INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME - task = self.task # Act instructor_confirmed_for_workshop_update_signal.send( - sender=task, + sender=self.signup, request=request, - task=task, person_id=self.person.pk, event_id=self.event.pk, - task_id=task.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # Assert mock_email_controller.update_scheduled_email.assert_not_called() + signup = self.signup mock_logger.warning.assert_called_once_with( - f"Scheduled email for signal {signal} and generic_relation_obj={task!r} " + f"Scheduled email for signal {signal} and generic_relation_obj={signup!r} " "does not exist." ) @@ -219,7 +219,6 @@ def test_multiple_previously_scheduled_emails( request = RequestFactory().get("/") signal = INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME template = self.setUpEmailTemplate() - task = self.task ScheduledEmail.objects.create( template=template, scheduled_at=datetime.now(UTC), @@ -227,7 +226,7 @@ def test_multiple_previously_scheduled_emails( cc_header=[], bcc_header=[], state=ScheduledEmailStatus.SCHEDULED, - generic_relation=task, + generic_relation=self.signup, ) ScheduledEmail.objects.create( template=template, @@ -236,26 +235,24 @@ def test_multiple_previously_scheduled_emails( cc_header=[], bcc_header=[], state=ScheduledEmailStatus.SCHEDULED, - generic_relation=task, + generic_relation=self.signup, ) # Act instructor_confirmed_for_workshop_update_signal.send( - sender=task, + sender=self.signup, request=request, - task=task, person_id=self.person.pk, event_id=self.event.pk, - task_id=task.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # 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={task!r}. Can't update them." + f"generic_relation_obj={self.signup!r}. Can't update them." ) @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) @@ -266,7 +263,6 @@ def test_missing_recipients( # Arrange request = RequestFactory().get("/") template = self.setUpEmailTemplate() - task = self.task ScheduledEmail.objects.create( template=template, scheduled_at=datetime.now(UTC), @@ -274,7 +270,7 @@ def test_missing_recipients( cc_header=[], bcc_header=[], state=ScheduledEmailStatus.SCHEDULED, - generic_relation=task, + generic_relation=self.signup, ) signal = INSTRUCTOR_CONFIRMED_FOR_WORKSHOP_SIGNAL_NAME self.person.email = "" @@ -282,14 +278,12 @@ def test_missing_recipients( # Act instructor_confirmed_for_workshop_update_signal.send( - sender=task, + sender=self.signup, request=request, - task=task, person_id=self.person.pk, event_id=self.event.pk, - task_id=task.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, ) # Assert @@ -297,12 +291,49 @@ def test_missing_recipients( class TestInstructorConfirmedForWorkshopUpdateIntegration(TestBase): - @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @override_settings( + FLAGS={ + "INSTRUCTOR_RECRUITMENT": [("boolean", True)], + "EMAIL_MODULE": [("boolean", True)], + } + ) def test_integration(self) -> None: # Arrange self._setUpRoles() self._setUpTags() + self._setUpAdministrators() self._setUpUsersAndLogin() + host = Organization.objects.create(domain="test.com", fullname="Test") + person = Person.objects.create_user( # type: ignore + username="test_test", + personal="Test", + family="User", + email="test@user.com", + password="test", + ) + config = CommunityRoleConfig.objects.create( + name="instructor", + display_name="Instructor", + link_to_award=False, + link_to_membership=False, + additional_url=False, + ) + CommunityRole.objects.create( + config=config, + person=person, + ) + event = Event.objects.create( + slug="test-event", + host=host, + start=date.today() + timedelta(days=7), + end=date.today() + timedelta(days=8), + administrator=Organization.objects.get(domain="software-carpentry.org"), + ) + event.tags.add(Tag.objects.get(name="SWC")) + recruitment = InstructorRecruitment.objects.create(status="o", event=event) + signup = InstructorRecruitmentSignup.objects.create( + recruitment=recruitment, person=person, state="a" + ) template = EmailTemplate.objects.create( name="Test Email Template", @@ -310,72 +341,41 @@ def test_integration(self) -> None: 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" + subject="Greetings {{ person.personal }}", + body="Hello, {{ person.personal }}! Nice to meet **you**.", ) - event = Event.objects.create( - slug="2024-08-05-test-event", - host=host_organization, - administrator=ttt_organization, - start=date.today() + timedelta(days=30), - ) - event.tags.set([Tag.objects.get(name="SWC")]) - - 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="", - bluesky="@purdy_kelsi.bsky.social", - 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), + instructor_confirmed_for_workshop_strategy(signup), request, - task=task, - person_id=task.person.pk, - event_id=task.event.pk, - task_id=task.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, + signup=signup, + person_id=person.pk, + event_id=event.pk, + instructor_recruitment_id=recruitment.pk, + instructor_recruitment_signup_id=signup.pk, ) 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": ""}) + url = reverse("instructorrecruitmentsignup_changestate", args=[signup.pk]) + payload = {"action": "confirm"} # Act - rv = self.client.post(url, data) + rv = self.client.post(url, payload) # 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]) + # Ensure that last log is update (from 'scheduled' to 'scheduled') + last_log = ( + ScheduledEmailLog.objects.filter(scheduled_email=scheduled_email) + .order_by("created_at") + .last() + ) + assert last_log + self.assertEqual(last_log.state_before, last_log.state_after) diff --git a/amy/emails/tests/actions/test_instructor_declined_from_workshop_cancel_receiver.py b/amy/emails/tests/actions/test_instructor_declined_from_workshop_cancel_receiver.py index 4c50f66fd..7fd563c3d 100644 --- a/amy/emails/tests/actions/test_instructor_declined_from_workshop_cancel_receiver.py +++ b/amy/emails/tests/actions/test_instructor_declined_from_workshop_cancel_receiver.py @@ -16,7 +16,7 @@ instructor_declined_from_workshop_cancel_signal, ) from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup -from workshops.models import Event, Organization, Person, Role, Tag, Task +from workshops.models import Event, Organization, Person, Tag from workshops.tests.base import TestBase @@ -27,10 +27,6 @@ def setUp(self) -> None: 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 - ) self.recruitment = InstructorRecruitment.objects.create( event=self.event, notes="Test notes" ) diff --git a/amy/emails/tests/actions/test_instructor_declined_from_workshop_strategy.py b/amy/emails/tests/actions/test_instructor_declined_from_workshop_strategy.py index 0390bc4af..23daaf80f 100644 --- a/amy/emails/tests/actions/test_instructor_declined_from_workshop_strategy.py +++ b/amy/emails/tests/actions/test_instructor_declined_from_workshop_strategy.py @@ -13,7 +13,7 @@ from emails.signals import INSTRUCTOR_DECLINED_FROM_WORKSHOP_SIGNAL_NAME from emails.types import StrategyEnum from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup -from workshops.models import Event, Organization, Person, Role, Tag, Task +from workshops.models import Event, Organization, Person, Tag class TestInstructorDeclinedFromWorkshopStrategy(TestCase): @@ -29,10 +29,6 @@ def setUp(self) -> None: swc_tag = Tag.objects.create(name="SWC") self.event.tags.set([swc_tag]) 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 - ) self.recruitment = InstructorRecruitment.objects.create( event=self.event, notes="Test notes" ) @@ -114,10 +110,6 @@ def setUp(self) -> None: 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 - ) self.recruitment = InstructorRecruitment.objects.create( event=self.event, notes="Test notes" ) diff --git a/amy/emails/tests/actions/test_instructor_declined_from_workshop_update_receiver.py b/amy/emails/tests/actions/test_instructor_declined_from_workshop_update_receiver.py index 492732394..99dc8030d 100644 --- a/amy/emails/tests/actions/test_instructor_declined_from_workshop_update_receiver.py +++ b/amy/emails/tests/actions/test_instructor_declined_from_workshop_update_receiver.py @@ -23,7 +23,7 @@ ) from emails.utils import api_model_url from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup -from workshops.models import Event, Organization, Person, Role, Tag, Task +from workshops.models import Event, Organization, Person, Tag from workshops.tests.base import TestBase @@ -34,10 +34,6 @@ def setUp(self) -> None: 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 - ) self.recruitment = InstructorRecruitment.objects.create( event=self.event, notes="Test notes" ) diff --git a/amy/emails/tests/actions/test_instructor_task_created_for_workshop_cancel_receiver.py b/amy/emails/tests/actions/test_instructor_task_created_for_workshop_cancel_receiver.py new file mode 100644 index 000000000..5296ece52 --- /dev/null +++ b/amy/emails/tests/actions/test_instructor_task_created_for_workshop_cancel_receiver.py @@ -0,0 +1,285 @@ +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_task_created_for_workshop import ( + instructor_task_created_for_workshop_cancel_receiver, + instructor_task_created_for_workshop_strategy, + run_instructor_task_created_for_workshop_strategy, +) +from emails.models import EmailTemplate, ScheduledEmail, ScheduledEmailStatus +from emails.signals import ( + INSTRUCTOR_TASK_CREATED_FOR_WORKSHOP_SIGNAL_NAME, + instructor_task_created_for_workshop_cancel_signal, +) +from workshops.models import Event, Organization, Person, Role, Tag, Task +from workshops.tests.base import TestBase + + +class TestInstructorTaskCreatedForWorkshopCancelReceiver(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_TASK_CREATED_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_task_created_for_workshop_cancel_receiver(None, request=request) + # Assert + mock_logger.debug.assert_called_once_with( + "EMAIL_MODULE feature flag not set, skipping " + "instructor_task_created_for_workshop_cancel" + ) + + def test_receiver_connected_to_signal(self) -> None: + # Arrange + original_receivers = ( + instructor_task_created_for_workshop_cancel_signal.receivers[:] + ) + + # Act + # attempt to connect the receiver + instructor_task_created_for_workshop_cancel_signal.connect( + instructor_task_created_for_workshop_cancel_receiver + ) + new_receivers = instructor_task_created_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.task, + ) + + # Act + with patch( + "emails.actions.base_action.messages_action_cancelled" + ) as mock_messages_action_cancelled: + instructor_task_created_for_workshop_cancel_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=self.person.pk, + event_id=self.event.pk, + task_id=self.task.pk, + ) + + # Assert + scheduled_email = ScheduledEmail.objects.get(template=template) + mock_messages_action_cancelled.assert_called_once_with( + request, + INSTRUCTOR_TASK_CREATED_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.task, + ) + + # Act + with patch( + "emails.actions.base_action.EmailController.cancel_email" + ) as mock_cancel_email: + instructor_task_created_for_workshop_cancel_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=self.person.pk, + event_id=self.event.pk, + task_id=self.task.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.task, + ) + scheduled_email2 = ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.task, + ) + + # Act + with patch( + "emails.actions.base_action.EmailController.cancel_email" + ) as mock_cancel_email: + instructor_task_created_for_workshop_cancel_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=self.person.pk, + event_id=self.event.pk, + task_id=self.task.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 TestInstructorTaskCreatedForWorkshopCancelIntegration(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_TASK_CREATED_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), + ) + event.tags.set([Tag.objects.get(name="SWC")]) + + 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="", + bluesky="@purdy_kelsi.bsky.social", + 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_task_created_for_workshop_strategy( + instructor_task_created_for_workshop_strategy(task), + request, + task=task, + person_id=task.person.pk, + event_id=task.event.pk, + task_id=task.pk, + ) + 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_task_created_for_workshop_receiver.py b/amy/emails/tests/actions/test_instructor_task_created_for_workshop_receiver.py new file mode 100644 index 000000000..975429b7e --- /dev/null +++ b/amy/emails/tests/actions/test_instructor_task_created_for_workshop_receiver.py @@ -0,0 +1,271 @@ +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_task_created_for_workshop import ( + instructor_task_created_for_workshop_receiver, +) +from emails.models import EmailTemplate, ScheduledEmail +from emails.schemas import ContextModel, ToHeaderModel +from emails.signals import ( + INSTRUCTOR_TASK_CREATED_FOR_WORKSHOP_SIGNAL_NAME, + instructor_task_created_for_workshop_signal, +) +from emails.utils import api_model_url, scalar_value_url +from workshops.models import Event, Organization, Person, Role, Tag, Task +from workshops.tests.base import TestBase + + +class TestInstructorTaskCreatedForWorkshopReceiver(TestCase): + @patch("emails.actions.base_action.logger") + def test_disabled_when_no_feature_flag(self, mock_logger: MagicMock) -> None: + # Arrange + request = RequestFactory().get("/") + with self.settings(FLAGS={"EMAIL_MODULE": [("boolean", False)]}): + # Act + instructor_task_created_for_workshop_receiver(None, request=request) + # Assert + mock_logger.debug.assert_called_once_with( + "EMAIL_MODULE feature flag not set, skipping " + "instructor_task_created_for_workshop" + ) + + def test_receiver_connected_to_signal(self) -> None: + # Arrange + original_receivers = instructor_task_created_for_workshop_signal.receivers[:] + + # Act + # attempt to connect the receiver + instructor_task_created_for_workshop_signal.connect( + instructor_task_created_for_workshop_receiver + ) + new_receivers = instructor_task_created_for_workshop_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 + organization = Organization.objects.first() + event = Event.objects.create( + slug="test-event", host=organization, administrator=organization + ) + person = Person.objects.create(email="test@example.org") + instructor_role = Role.objects.create(name="instructor") + task = Task.objects.create(person=person, event=event, role=instructor_role) + template = EmailTemplate.objects.create( + name="Test Email Template", + signal=instructor_task_created_for_workshop_signal.signal_name, + from_header="workshops@carpentries.org", + cc_header=["team@carpentries.org"], + bcc_header=[], + subject="Greetings {{ name }}", + body="Hello, {{ name }}! Nice to meet **you**.", + ) + request = RequestFactory().get("/") + + # Act + with patch( + "emails.actions.base_action.messages_action_scheduled" + ) as mock_messages_action_scheduled: + instructor_task_created_for_workshop_signal.send( + sender=task, + request=request, + task=task, + person_id=person.pk, + event_id=event.pk, + task_id=task.pk, + ) + + # Assert + scheduled_email = ScheduledEmail.objects.get(template=template) + mock_messages_action_scheduled.assert_called_once_with( + request, + instructor_task_created_for_workshop_signal.signal_name, + scheduled_email, + ) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @patch("emails.actions.base_action.messages_action_scheduled") + @patch("emails.actions.instructor_task_created_for_workshop.immediate_action") + def test_email_scheduled( + self, + mock_immediate_action: MagicMock, + mock_messages_action_scheduled: MagicMock, + ) -> None: + # Arrange + organization = Organization.objects.first() + event = Event.objects.create( + slug="test-event", host=organization, administrator=organization + ) + person = Person.objects.create(email="test@example.org") + instructor_role = Role.objects.create(name="instructor") + task = Task.objects.create(person=person, event=event, role=instructor_role) + request = RequestFactory().get("/") + + NOW = datetime(2023, 6, 1, 10, 0, 0, tzinfo=UTC) + mock_immediate_action.return_value = NOW + timedelta(hours=1) + signal = instructor_task_created_for_workshop_signal.signal_name + scheduled_at = NOW + timedelta(hours=1) + + # Act + with patch( + "emails.actions.base_action.EmailController.schedule_email" + ) as mock_schedule_email: + instructor_task_created_for_workshop_signal.send( + sender=task, + request=request, + person_id=person.pk, + event_id=event.pk, + task_id=task.pk, + ) + + # Assert + mock_schedule_email.assert_called_once_with( + signal=signal, + context_json=ContextModel( + { + "person": api_model_url("person", person.pk), + "event": api_model_url("event", event.pk), + "task": api_model_url("task", task.pk), + "task_id": scalar_value_url("int", task.pk), + } + ), + scheduled_at=scheduled_at, + to_header=[person.email], + to_header_context_json=ToHeaderModel( + [ + { + "api_uri": api_model_url("person", person.pk), + "property": "email", + } # type: ignore + ] + ), + generic_relation_obj=task, + author=None, + ) + + @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 + organization = Organization.objects.first() + event = Event.objects.create( + slug="test-event", host=organization, administrator=organization + ) + person = Person.objects.create() # no email will cause missing recipients error + instructor_role = Role.objects.create(name="instructor") + task = Task.objects.create(person=person, event=event, role=instructor_role) + request = RequestFactory().get("/") + signal = instructor_task_created_for_workshop_signal.signal_name + + # Act + instructor_task_created_for_workshop_signal.send( + sender=task, + request=request, + person_id=person.pk, + event_id=event.pk, + task_id=task.pk, + ) + + # Assert + mock_messages_missing_recipients.assert_called_once_with(request, signal) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @patch("emails.actions.base_action.messages_missing_template") + def test_missing_template(self, mock_messages_missing_template: MagicMock) -> None: + # Arrange + organization = Organization.objects.first() + event = Event.objects.create( + slug="test-event", host=organization, administrator=organization + ) + person = Person.objects.create(email="test@example.org") + instructor_role = Role.objects.create(name="instructor") + task = Task.objects.create(person=person, event=event, role=instructor_role) + request = RequestFactory().get("/") + signal = instructor_task_created_for_workshop_signal.signal_name + + # Act + instructor_task_created_for_workshop_signal.send( + sender=task, + request=request, + person_id=person.pk, + event_id=event.pk, + task_id=task.pk, + ) + + # Assert + mock_messages_missing_template.assert_called_once_with(request, signal) + + +class TestInstructorTaskCreatedForWorkshopReceiverIntegration(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_TASK_CREATED_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), + ) + event.tags.set([Tag.objects.get(name="SWC")]) + + 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="", + bluesky="@purdy_kelsi.bsky.social", + 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") + + url = reverse("task_add") + payload = { + "task-event": event.pk, + "task-person": instructor.pk, + "task-role": instructor_role.pk, + } + + # Act + rv = self.client.post(url, payload) + + # Assert + self.assertEqual(rv.status_code, 302) + ScheduledEmail.objects.get(template=template) diff --git a/amy/emails/tests/actions/test_instructor_task_created_for_workshop_strategy.py b/amy/emails/tests/actions/test_instructor_task_created_for_workshop_strategy.py new file mode 100644 index 000000000..06156fef9 --- /dev/null +++ b/amy/emails/tests/actions/test_instructor_task_created_for_workshop_strategy.py @@ -0,0 +1,261 @@ +from datetime import UTC, date, datetime, timedelta +from unittest.mock import MagicMock, patch + +from django.test import RequestFactory, TestCase +from django.utils import timezone + +from emails.actions.exceptions import EmailStrategyException +from emails.actions.instructor_task_created_for_workshop import ( + instructor_task_created_for_workshop_strategy, + run_instructor_task_created_for_workshop_strategy, +) +from emails.models import EmailTemplate, ScheduledEmail, ScheduledEmailStatus +from emails.signals import INSTRUCTOR_TASK_CREATED_FOR_WORKSHOP_SIGNAL_NAME +from emails.types import StrategyEnum +from workshops.models import Event, Organization, Person, Role, Tag, Task + + +class TestInstructorTaskCreatedForWorkshopStrategy(TestCase): + def setUp(self) -> None: + host = Organization.objects.create(domain="test.com", fullname="Test") + self.event = Event.objects.create( + slug="test-event", + host=host, + administrator=host, + start=timezone.now().date() + timedelta(days=2), + end=timezone.now().date() + timedelta(days=3), + ) + swc_tag = Tag.objects.create(name="SWC") + self.event.tags.set([swc_tag]) + 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_task_created_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_TASK_CREATED_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.task, + ) + + # Act + result = instructor_task_created_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_TASK_CREATED_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.task, + ) + + # Act + result = instructor_task_created_for_workshop_strategy(self.task) + + # Assert + self.assertEqual(result, StrategyEnum.CANCEL) + + +class TestRunInstructorTaskCreatedForWorkshopStrategy(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_task_created_for_workshop." + "instructor_task_created_for_workshop_signal" + ) + def test_strategy_calls_create_signal( + self, + mock_instructor_task_created_for_workshop_signal, + ) -> None: + # Arrange + strategy = StrategyEnum.CREATE + request = RequestFactory().get("/") + + # Act + run_instructor_task_created_for_workshop_strategy( + strategy, + request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + ) + + # Assert + mock_instructor_task_created_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, + ) + + @patch( + "emails.actions.instructor_task_created_for_workshop." + "instructor_task_created_for_workshop_update_signal" + ) + def test_strategy_calls_update_signal( + self, + mock_update_signal, + ) -> None: + # Arrange + strategy = StrategyEnum.UPDATE + request = RequestFactory().get("/") + + # Act + run_instructor_task_created_for_workshop_strategy( + strategy, + request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + ) + + # Assert + mock_update_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, + ) + + @patch( + "emails.actions.instructor_task_created_for_workshop." + "instructor_task_created_for_workshop_cancel_signal" + ) + def test_strategy_calls_cancel_signal( + self, + mock_cancel_signal, + ) -> None: + # Arrange + strategy = StrategyEnum.CANCEL + request = RequestFactory().get("/") + + # Act + run_instructor_task_created_for_workshop_strategy( + strategy, + request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + ) + + # Assert + mock_cancel_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, + ) + + @patch("emails.actions.base_strategy.logger") + @patch( + "emails.actions.instructor_task_created_for_workshop." + "instructor_task_created_for_workshop_signal" + ) + @patch( + "emails.actions.instructor_task_created_for_workshop." + "instructor_task_created_for_workshop_update_signal" + ) + @patch( + "emails.actions.instructor_task_created_for_workshop." + "instructor_task_created_for_workshop_cancel_signal" + ) + def test_invalid_strategy_no_signal_called( + self, + mock_instructor_task_created_for_workshop_cancel_signal, + mock_instructor_task_created_for_workshop_update_signal, + mock_instructor_task_created_for_workshop_signal, + mock_logger, + ) -> None: + # Arrange + strategy = StrategyEnum.NOOP + request = RequestFactory().get("/") + + # Act + run_instructor_task_created_for_workshop_strategy( + strategy, + request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + ) + + # Assert + mock_instructor_task_created_for_workshop_signal.send.assert_not_called() + mock_instructor_task_created_for_workshop_update_signal.send.assert_not_called() + mock_instructor_task_created_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_task_created_for_workshop_strategy( + strategy, + request, + task=self.task, + person_id=self.task.person.pk, + event_id=self.task.event.pk, + ) diff --git a/amy/emails/tests/actions/test_instructor_task_created_for_workshop_update_receiver.py b/amy/emails/tests/actions/test_instructor_task_created_for_workshop_update_receiver.py new file mode 100644 index 000000000..a2cad4e53 --- /dev/null +++ b/amy/emails/tests/actions/test_instructor_task_created_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_task_created_for_workshop import ( + instructor_task_created_for_workshop_strategy, + instructor_task_created_for_workshop_update_receiver, + run_instructor_task_created_for_workshop_strategy, +) +from emails.models import EmailTemplate, ScheduledEmail, ScheduledEmailStatus +from emails.schemas import ContextModel, ToHeaderModel +from emails.signals import ( + INSTRUCTOR_TASK_CREATED_FOR_WORKSHOP_SIGNAL_NAME, + instructor_task_created_for_workshop_update_signal, +) +from emails.utils import api_model_url, scalar_value_url +from workshops.forms import PersonForm +from workshops.models import Event, Organization, Person, Role, Tag, Task +from workshops.tests.base import TestBase + + +class TestInstructorTaskCreatedForWorkshopUpdateReceiver(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_TASK_CREATED_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_task_created_for_workshop_update_receiver(None, request=request) + # Assert + mock_logger.debug.assert_called_once_with( + "EMAIL_MODULE feature flag not set, skipping " + "instructor_task_created_for_workshop_update" + ) + + def test_receiver_connected_to_signal(self) -> None: + # Arrange + original_receivers = ( + instructor_task_created_for_workshop_update_signal.receivers[:] + ) + + # Act + # attempt to connect the receiver + instructor_task_created_for_workshop_update_signal.connect( + instructor_task_created_for_workshop_update_receiver + ) + new_receivers = instructor_task_created_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() + task = self.task + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=task, + ) + + # Act + with patch( + "emails.actions.base_action.messages_action_updated" + ) as mock_messages_action_updated: + instructor_task_created_for_workshop_update_signal.send( + sender=task, + request=request, + task=task, + person_id=self.person.pk, + event_id=self.event.pk, + task_id=task.pk, + ) + + # Assert + scheduled_email = ScheduledEmail.objects.get(template=template) + mock_messages_action_updated.assert_called_once_with( + request, + INSTRUCTOR_TASK_CREATED_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_task_created_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() + task = self.task + scheduled_email = ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=task, + ) + 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_task_created_for_workshop_update_signal.send( + sender=self.task, + request=request, + task=self.task, + person_id=self.person.pk, + event_id=self.event.pk, + task_id=task.pk, + ) + + # 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), + "task": api_model_url("task", task.pk), + "task_id": scalar_value_url("int", task.pk), + } + ), + 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=task, + 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_TASK_CREATED_FOR_WORKSHOP_SIGNAL_NAME + task = self.task + + # Act + instructor_task_created_for_workshop_update_signal.send( + sender=task, + request=request, + task=task, + person_id=self.person.pk, + event_id=self.event.pk, + task_id=task.pk, + ) + + # 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={task!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_TASK_CREATED_FOR_WORKSHOP_SIGNAL_NAME + template = self.setUpEmailTemplate() + task = self.task + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=task, + ) + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=task, + ) + + # Act + instructor_task_created_for_workshop_update_signal.send( + sender=task, + request=request, + task=task, + person_id=self.person.pk, + event_id=self.event.pk, + task_id=task.pk, + ) + + # 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={task!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() + task = self.task + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=task, + ) + signal = INSTRUCTOR_TASK_CREATED_FOR_WORKSHOP_SIGNAL_NAME + self.person.email = "" + self.person.save() + + # Act + instructor_task_created_for_workshop_update_signal.send( + sender=task, + request=request, + task=task, + person_id=self.person.pk, + event_id=self.event.pk, + task_id=task.pk, + ) + + # Assert + mock_messages_missing_recipients.assert_called_once_with(request, signal) + + +class TestInstructorTaskCreatedForWorkshopUpdateIntegration(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_TASK_CREATED_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), + ) + event.tags.set([Tag.objects.get(name="SWC")]) + + 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="", + bluesky="@purdy_kelsi.bsky.social", + 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_task_created_for_workshop_strategy( + instructor_task_created_for_workshop_strategy(task), + request, + task=task, + person_id=task.person.pk, + event_id=task.event.pk, + task_id=task.pk, + ) + 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/emails/types.py b/amy/emails/types.py index bbdbeaf4f..ec67d94dc 100644 --- a/amy/emails/types.py +++ b/amy/emails/types.py @@ -34,17 +34,14 @@ class InstructorConfirmedKwargs(TypedDict): request: HttpRequest person_id: int event_id: int - task_id: int - instructor_recruitment_id: int | None - instructor_recruitment_signup_id: int | None + instructor_recruitment_id: int + instructor_recruitment_signup_id: int class InstructorConfirmedContext(TypedDict): person: Person event: Event - task: Task | None - task_id: int - instructor_recruitment_signup: InstructorRecruitmentSignup | None + instructor_recruitment_signup: InstructorRecruitmentSignup class InstructorDeclinedKwargs(TypedDict): @@ -100,6 +97,20 @@ class PersonsMergedContext(TypedDict): person: Person +class InstructorTaskCreatedForWorkshopKwargs(TypedDict): + request: HttpRequest + person_id: int + event_id: int + task_id: int + + +class InstructorTaskCreatedForWorkshopContext(TypedDict): + person: Person + event: Event + task: Task | None + task_id: int | None + + class InstructorTrainingApproachingKwargs(TypedDict): request: HttpRequest event: Event diff --git a/amy/recruitment/tests/test_instructor_recruitment_views.py b/amy/recruitment/tests/test_instructor_recruitment_views.py index a396b84bc..d0840468e 100644 --- a/amy/recruitment/tests/test_instructor_recruitment_views.py +++ b/amy/recruitment/tests/test_instructor_recruitment_views.py @@ -595,8 +595,8 @@ def test_integration(self) -> None: # Assert self.assertEqual(response.status_code, 302) self.assertRedirects(response, success_url) - self.assertEqual(recruitment.signups.count(), 1) - signup = recruitment.signups.last() + self.assertEqual(recruitment.signups.count(), 1) # type: ignore + signup = recruitment.signups.last() # type: ignore self.assertEqual(signup.person, person) self.assertEqual(signup.user_notes, "") self.assertEqual(signup.notes, notes) @@ -665,8 +665,8 @@ def test_form_valid(self) -> None: view = InstructorRecruitmentSignupChangeState( object=mock_signup, request=request ) - view.add_instructor_task = mock.MagicMock() - view.remove_instructor_task = mock.MagicMock() + view.accept_signup = mock.MagicMock() + view.decline_signup = mock.MagicMock() data = {"action": "confirm"} form = InstructorRecruitmentSignupChangeStateForm(data) form.is_valid() @@ -675,12 +675,13 @@ def test_form_valid(self) -> None: # Assert self.assertEqual(mock_signup.state, "a") mock_signup.save.assert_called_once() - view.add_instructor_task.assert_called_once_with( + view.accept_signup.assert_called_once_with( request, mock_signup, mock_signup.person, mock_signup.recruitment.event ) - view.remove_instructor_task.assert_not_called() + view.decline_signup.assert_not_called() - def test_add_instructor_task(self) -> None: + @mock.patch("recruitment.views.messages") + def test_accept_signup(self, mock_messages: mock.MagicMock) -> None: # Arrange super()._setUpRoles() request = RequestFactory().post("/") @@ -697,12 +698,38 @@ def test_add_instructor_task(self) -> None: ) recruitment = InstructorRecruitment(event=event) signup = InstructorRecruitmentSignup(recruitment=recruitment, person=person) + role = Role.objects.get(name="instructor") + task = Task.objects.create(person=person, event=event, role=role) # Act - task = view.add_instructor_task(request, signup, person, event) + task2 = view.accept_signup(request, signup, person, event) + # Assert + self.assertEqual(task.pk, task2.pk) + mock_messages.warning.assert_called_once() + + def test_accept_signup__no_task(self) -> None: + # Arrange + super()._setUpRoles() + request = RequestFactory().post("/") + view = InstructorRecruitmentSignupChangeState(request=request) + person = Person.objects.create( + personal="Test", family="User", username="test_user" + ) + organization = self.org_alpha + event = Event.objects.create( + slug="test-event", + host=organization, + administrator=organization, + start=timezone.now().date(), + ) + recruitment = InstructorRecruitment(event=event) + signup = InstructorRecruitmentSignup(recruitment=recruitment, person=person) + # Act + task = view.accept_signup(request, signup, person, event) # Assert self.assertTrue(task.pk) - def test_remove_instructor_task(self) -> None: + @mock.patch("recruitment.views.messages") + def test_decline_signup(self, mock_messages: mock.MagicMock) -> None: # Arrange super()._setUpRoles() request = RequestFactory().post("/") @@ -721,13 +748,12 @@ def test_remove_instructor_task(self) -> None: signup = InstructorRecruitmentSignup(recruitment=recruitment, person=person) role = Role.objects.get(name="instructor") task = Task.objects.create(person=person, event=event, role=role) - # Act - view.remove_instructor_task(request, signup, person, event) - # Assert - with self.assertRaises(Task.DoesNotExist): - task.refresh_from_db() + # Act & Assert - no error - task is not removed, but a warning is added + view.decline_signup(request, signup, person, event) + task.refresh_from_db() + mock_messages.warning.assert_called_once() - def test_remove_instructor_task__no_task(self) -> None: + def test_decline_signup__no_task(self) -> None: # Arrange super()._setUpRoles() request = RequestFactory().post("/") @@ -744,7 +770,7 @@ def test_remove_instructor_task__no_task(self) -> None: recruitment = InstructorRecruitment(event=event) signup = InstructorRecruitmentSignup(recruitment=recruitment, person=person) # Act & Assert - no error - view.remove_instructor_task(request, signup, person, event) + view.decline_signup(request, signup, person, event) def test_post__form_valid(self) -> None: # Arrange @@ -935,13 +961,13 @@ def test_post__action_reopen(self) -> None: def test__validate_for_closing(self) -> None: # Arrange recruitment1 = InstructorRecruitment(event=None, status="o") - recruitment1.num_pending = 123 + recruitment1.num_pending = 123 # type: ignore recruitment2 = InstructorRecruitment(event=None, status="c") - recruitment2.num_pending = 123 + recruitment2.num_pending = 123 # type: ignore recruitment3 = InstructorRecruitment(event=None, status="o") - recruitment3.num_pending = 0 + recruitment3.num_pending = 0 # type: ignore recruitment4 = InstructorRecruitment(event=None, status="c") - recruitment4.num_pending = 0 + recruitment4.num_pending = 0 # type: ignore data = [ (recruitment1, False), (recruitment2, False), diff --git a/amy/recruitment/views.py b/amy/recruitment/views.py index 4fe211e70..c01b28199 100644 --- a/amy/recruitment/views.py +++ b/amy/recruitment/views.py @@ -12,6 +12,7 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import reverse +from django.utils.html import format_html from django.views.generic import View from django.views.generic.edit import FormMixin, FormView import django_rq @@ -67,7 +68,7 @@ class InstructorRecruitmentList(OnlyForAdminsMixin, FlaggedViewMixin, AMYListVie filter_class = InstructorRecruitmentFilter queryset = ( - InstructorRecruitment.objects.annotate_with_priority() + InstructorRecruitment.objects.annotate_with_priority() # type: ignore .select_related("event", "assigned_to") .prefetch_related( Prefetch( @@ -129,7 +130,7 @@ def get_filter_data(self): it's still possible to clear that filter value, in which case the query param will become `?assigned_to=` (empty).""" data = super().get_filter_data().copy() - data.setdefault("assigned_to", self.request.user.pk) + data.setdefault("assigned_to", self.request.user.pk) # type: ignore return data def get_context_data(self, **kwargs): @@ -214,19 +215,19 @@ def get_context_data(self, **kwargs): def get_initial(self) -> dict: try: - workshop_request = self.event.workshoprequest + workshop_request = self.event.workshoprequest # type: ignore return { "notes": ( f"{workshop_request.audience_description}\n\n" f"{workshop_request.user_notes}" ) } - except Event.workshoprequest.RelatedObjectDoesNotExist: + except Event.workshoprequest.RelatedObjectDoesNotExist: # type: ignore return {} def form_valid(self, form: InstructorRecruitmentCreateForm): self.object: InstructorRecruitment = form.save(commit=False) - self.object.assigned_to = self.request.user + self.object.assigned_to = self.request.user # type: ignore self.object.event = self.event self.object.save() return super().form_valid(form) @@ -240,7 +241,7 @@ class InstructorRecruitmentDetails( flag_name = "INSTRUCTOR_RECRUITMENT" permission_required = "recruitment.view_instructorrecruitment" queryset = ( - InstructorRecruitment.objects.annotate_with_priority() + InstructorRecruitment.objects.annotate_with_priority() # type: ignore .prefetch_related( Prefetch( "signups", @@ -400,8 +401,8 @@ def form_valid(self, form) -> HttpResponse: [HttpRequest, InstructorRecruitmentSignup, Person, Event], Task | None ], ] = { - "a": self.add_instructor_task, - "d": self.remove_instructor_task, + "a": self.accept_signup, + "d": self.decline_signup, } handler = state_to_method_action_mapping[self.object.state] try: @@ -420,7 +421,7 @@ def form_valid(self, form) -> HttpResponse: ) return HttpResponseRedirect(self.get_success_url()) - def add_instructor_task( + def accept_signup( self, request: HttpRequest, signup: InstructorRecruitmentSignup, @@ -428,19 +429,27 @@ def add_instructor_task( event: Event, ) -> Task: role = Role.objects.get(name="instructor") - task = Task.objects.create( + task, created = Task.objects.get_or_create( event=event, person=person, role=role, ) + if not created: + messages.warning( + request, + format_html( + "The signup was accepted, but instructor task already " + 'exists.', + task.get_absolute_url(), + ), + ) run_instructor_confirmed_for_workshop_strategy( - instructor_confirmed_for_workshop_strategy(task), + instructor_confirmed_for_workshop_strategy(signup), request, - task=task, + signup=signup, person_id=person.pk, event_id=event.pk, - task_id=task.pk, instructor_recruitment_id=signup.recruitment.pk, instructor_recruitment_signup_id=signup.pk, ) @@ -456,38 +465,35 @@ def add_instructor_task( return task - def remove_instructor_task( + def decline_signup( self, request: HttpRequest, signup: InstructorRecruitmentSignup, person: Person, event: Event, ) -> None: - """Remove instructor task from a Person only if the task exists. If it doesn't, - don't throw errors.""" try: task = Task.objects.get(role__name="instructor", person=person, event=event) - old_task = Task.objects.get( - role__name="instructor", person=person, event=event - ) # Fetched again to omit problem with referencing `task`. - except Task.DoesNotExist: - pass - else: - task.delete() - run_instructor_confirmed_for_workshop_strategy( - instructor_confirmed_for_workshop_strategy( - task, - optional_task_pk=old_task.pk, - ), + messages.warning( request, - task=old_task, - person_id=person.pk, - event_id=event.pk, - task_id=old_task.pk, - instructor_recruitment_id=signup.recruitment.pk, - instructor_recruitment_signup_id=signup.pk, + format_html( + "The signup was declined, but instructor task was " + 'found. ', + task.get_absolute_url(), + ), ) + except Task.DoesNotExist: + pass + run_instructor_confirmed_for_workshop_strategy( + instructor_confirmed_for_workshop_strategy(signup), + request, + signup=signup, + person_id=person.pk, + event_id=event.pk, + instructor_recruitment_id=signup.recruitment.pk, + instructor_recruitment_signup_id=signup.pk, + ) run_instructor_declined_from_workshop_strategy( instructor_declined_from_workshop_strategy(signup), request, diff --git a/amy/scripts/seed_emails.py b/amy/scripts/seed_emails.py index 48a6073b4..02cf6991c 100644 --- a/amy/scripts/seed_emails.py +++ b/amy/scripts/seed_emails.py @@ -127,6 +127,21 @@ "Log in to AMY to view your profile and verify things are correct." ), ), + EmailTemplateDef( + active=True, + id=UUID("8419c339-6997-47ce-b272-bfa98b447364"), + name="Instructor task created for workshop", + signal=SignalNameEnum.instructor_task_created_for_workshop, + from_header="workshops@carpentries.org", + reply_to_header="", + cc_header=[], + bcc_header=[], + subject="You will teach (workshop)", + body=( + "Hi, {{ person.personal }} {{ person.family }}. " + "We have added you to teach at (TODO event). For more details go to..." + ), + ), EmailTemplateDef( active=True, id=UUID("a002c623-b849-4843-a589-08020f4b8589"), diff --git a/amy/templates/workshops/person_edit_form.html b/amy/templates/workshops/person_edit_form.html index 263b4e84e..8a4bb9126 100644 --- a/amy/templates/workshops/person_edit_form.html +++ b/amy/templates/workshops/person_edit_form.html @@ -76,7 +76,6 @@

All tasks

Event - URL Role Member site seat Open applicant @@ -85,7 +84,6 @@ {% for t in tasks %} {{ t.event.slug }} - {{ t.url|default:"—"|urlize_newtab }} {{ t.role.name }} {% if t.seat_membership %} diff --git a/amy/templates/workshops/task.html b/amy/templates/workshops/task.html index 7f8cd1fe6..013093d9e 100644 --- a/amy/templates/workshops/task.html +++ b/amy/templates/workshops/task.html @@ -9,9 +9,7 @@ - - diff --git a/amy/workshops/forms.py b/amy/workshops/forms.py index 6c073aba2..428c9133c 100644 --- a/amy/workshops/forms.py +++ b/amy/workshops/forms.py @@ -719,7 +719,7 @@ def clean(self): # corresponds to role "instructor"; otherwise it's "instructor" community # role. community_role_name = "instructor" - if event.administrator.domain == "carpentries.org": + if event.administrator and event.administrator.domain == "carpentries.org": community_role_name = "trainer" person_community_roles = CommunityRole.objects.filter( diff --git a/amy/workshops/views.py b/amy/workshops/views.py index 04196356b..b1e7ab0c3 100644 --- a/amy/workshops/views.py +++ b/amy/workshops/views.py @@ -63,9 +63,9 @@ instructor_badge_awarded_strategy, run_instructor_badge_awarded_strategy, ) -from emails.actions.instructor_confirmed_for_workshop import ( - instructor_confirmed_for_workshop_strategy, - run_instructor_confirmed_for_workshop_strategy, +from emails.actions.instructor_task_created_for_workshop import ( + instructor_task_created_for_workshop_strategy, + run_instructor_task_created_for_workshop_strategy, ) from emails.actions.instructor_training_approaching import ( instructor_training_approaching_strategy, @@ -713,15 +713,13 @@ def form_valid(self, form): self.request, task.event, ) - run_instructor_confirmed_for_workshop_strategy( - instructor_confirmed_for_workshop_strategy(task), + run_instructor_task_created_for_workshop_strategy( + instructor_task_created_for_workshop_strategy(task), self.request, task=task, person_id=self.object.pk, event_id=task.event.pk, task_id=task.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, ) return result @@ -1741,15 +1739,13 @@ def form_valid(self, form): event, ) - run_instructor_confirmed_for_workshop_strategy( - instructor_confirmed_for_workshop_strategy(self.object), + run_instructor_task_created_for_workshop_strategy( + instructor_task_created_for_workshop_strategy(self.object), self.request, task=self.object, person_id=self.object.person.pk, event_id=event.pk, task_id=self.object.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, ) # return remembered results @@ -1828,15 +1824,13 @@ def form_valid(self, form): self.object.event, ) - run_instructor_confirmed_for_workshop_strategy( - instructor_confirmed_for_workshop_strategy(self.object), + run_instructor_task_created_for_workshop_strategy( + instructor_task_created_for_workshop_strategy(self.object), self.request, task=self.object, person_id=self.object.person.pk, event_id=self.object.event.pk, task_id=self.object.pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, ) return res @@ -1890,15 +1884,13 @@ def after_delete(self, *args, **kwargs): self.object.event, ) - run_instructor_confirmed_for_workshop_strategy( - instructor_confirmed_for_workshop_strategy(self.object, self.old_pk), + run_instructor_task_created_for_workshop_strategy( + instructor_task_created_for_workshop_strategy(self.object, self.old_pk), self.request, task=self.old, person_id=self.object.person.pk, event_id=self.event.pk, task_id=self.old_pk, - instructor_recruitment_id=None, - instructor_recruitment_signup_id=None, )
Event:{{ task.event }}
Person:{{ task.person }}
Title:{{ task.title }}
Role:{{ task.role }}
URL:{{ task.url|urlize_newtab }}
Member site seat :{% if task.seat_membership %}{{ task.seat_membership }}{% else %}—{% endif %}
Public or in-house seat :{{ task.get_seat_public_display }}
Open applicant :{{ task.seat_open_training|yesno }}