diff --git a/amy/api/v2/serializers.py b/amy/api/v2/serializers.py index 22194d575..80a06102c 100644 --- a/amy/api/v2/serializers.py +++ b/amy/api/v2/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from emails.models import MAX_LENGTH, ScheduledEmail +from extrequests.models import SelfOrganisedSubmission from recruitment.models import InstructorRecruitmentSignup from workshops.models import ( Award, @@ -308,3 +309,49 @@ class Meta: "event_required", "involvement_required", ) + + +class SelfOrganisedSubmissionSerializer(serializers.ModelSerializer): + event = serializers.SlugRelatedField(read_only=True, slug_field="slug") + additional_contact = serializers.CharField() + country = serializers.CharField() + workshop_types = serializers.SlugRelatedField( + many=True, read_only=True, slug_field="name" + ) + + class Meta: + model = SelfOrganisedSubmission + fields = ( + "pk", + "assigned_to", + "state", + "created_at", + "last_updated_at", + "data_privacy_agreement", + "code_of_conduct_agreement", + "host_responsibilities", + "event", + "personal", + "family", + "email", + "institution", + "institution_other_name", + "institution_other_URL", + "institution_department", + "member_code", + "online_inperson", + "workshop_listed", + "public_event", + "public_event_other", + "additional_contact", + "start", + "end", + "workshop_url", + "workshop_format", + "workshop_format_other", + "workshop_types", + "workshop_types_other", + "workshop_types_other_explain", + "country", + "language", + ) diff --git a/amy/api/v2/urls.py b/amy/api/v2/urls.py index 1ac236964..681d7da17 100644 --- a/amy/api/v2/urls.py +++ b/amy/api/v2/urls.py @@ -15,6 +15,7 @@ router.register("scheduledemail", views.ScheduledEmailViewSet) router.register("trainingprogress", views.TrainingProgressViewSet) router.register("trainingrequirement", views.TrainingRequirementViewSet) +router.register("selforganisedsubmission", views.SelfOrganisedSubmissionViewSet) urlpatterns = [ diff --git a/amy/api/v2/views.py b/amy/api/v2/views.py index 5ce89b854..479761c03 100644 --- a/amy/api/v2/views.py +++ b/amy/api/v2/views.py @@ -17,11 +17,13 @@ PersonSerializer, ScheduledEmailLogDetailsSerializer, ScheduledEmailSerializer, + SelfOrganisedSubmissionSerializer, TrainingProgressSerializer, TrainingRequirementSerializer, ) from emails.controller import EmailController from emails.models import ScheduledEmail, ScheduledEmailStatus +from extrequests.models import SelfOrganisedSubmission from recruitment.models import InstructorRecruitmentSignup from workshops.models import ( Award, @@ -251,3 +253,18 @@ class TrainingRequirementViewSet(viewsets.ReadOnlyModelViewSet): queryset = TrainingRequirement.objects.order_by("pk").all() serializer_class = TrainingRequirementSerializer pagination_class = StandardResultsSetPagination + + +class SelfOrganisedSubmissionViewSet(viewsets.ReadOnlyModelViewSet): + authentication_classes = ( + TokenAuthentication, + SessionAuthentication, + ) + permission_classes = ( + IsAuthenticated, + ApiAccessPermission, + ) + + queryset = SelfOrganisedSubmission.objects.order_by("pk").all() + serializer_class = SelfOrganisedSubmissionSerializer + pagination_class = StandardResultsSetPagination diff --git a/amy/emails/actions/__init__.py b/amy/emails/actions/__init__.py index 8cac79a9e..890da7061 100644 --- a/amy/emails/actions/__init__.py +++ b/amy/emails/actions/__init__.py @@ -32,6 +32,9 @@ new_membership_onboarding_receiver, new_membership_onboarding_update_receiver, ) +from emails.actions.new_self_organised_workshop import ( + new_self_organised_workshop_receiver, +) from emails.actions.persons_merged import persons_merged_receiver from emails.actions.post_workshop_7days import ( post_workshop_7days_cancel_receiver, diff --git a/amy/emails/actions/admin_signs_instructor_up_for_workshop.py b/amy/emails/actions/admin_signs_instructor_up_for_workshop.py index a17184d08..fe00ecefe 100644 --- a/amy/emails/actions/admin_signs_instructor_up_for_workshop.py +++ b/amy/emails/actions/admin_signs_instructor_up_for_workshop.py @@ -70,8 +70,8 @@ def get_recipients_context_json( { "api_uri": api_model_url("person", context["person"].pk), "property": "email", - }, - ], # type: ignore + }, # type: ignore + ], ) diff --git a/amy/emails/actions/host_instructors_introduction.py b/amy/emails/actions/host_instructors_introduction.py index a5d09f5cf..72f454a9b 100644 --- a/amy/emails/actions/host_instructors_introduction.py +++ b/amy/emails/actions/host_instructors_introduction.py @@ -22,7 +22,12 @@ HostInstructorsIntroductionKwargs, StrategyEnum, ) -from emails.utils import api_model_url, immediate_action, scalar_value_none +from emails.utils import ( + api_model_url, + immediate_action, + log_condition_elements, + scalar_value_none, +) from recruitment.models import InstructorRecruitment from workshops.models import Event, Task @@ -41,17 +46,20 @@ def host_instructors_introduction_strategy(event: Event) -> StrategyEnum: start_date_in_at_least_7days = event.start and event.start >= ( timezone.now().date() + timedelta(days=7) ) - logger.debug( - f"{no_open_recruitment=}, {not_self_organised=}, " - f"{start_date_in_at_least_7days=}" - ) - active = not event.tags.filter(name__in=["cancelled", "unresponsive", "stalled"]) host = Task.objects.filter(role__name="host", event=event).first() at_least_2_instructors = ( Task.objects.filter(role__name="instructor", event=event).count() >= 2 ) - logger.debug(f"{active=}, {host=}, {at_least_2_instructors=}") + + log_condition_elements( + not_self_organised=not_self_organised, + no_open_recruitment=no_open_recruitment, + start_date_in_at_least_7days=start_date_in_at_least_7days, + active=active, + host=host, + at_least_2_instructors=at_least_2_instructors, + ) email_should_exist = ( not_self_organised diff --git a/amy/emails/actions/instructor_badge_awarded.py b/amy/emails/actions/instructor_badge_awarded.py index 77d9cef52..dbedf9c30 100644 --- a/amy/emails/actions/instructor_badge_awarded.py +++ b/amy/emails/actions/instructor_badge_awarded.py @@ -61,8 +61,8 @@ def get_recipients_context_json( { "api_uri": api_model_url("person", context["person"].pk), "property": "email", - }, - ], # type: ignore + }, # type: ignore + ], ) diff --git a/amy/emails/actions/instructor_confirmed_for_workshop.py b/amy/emails/actions/instructor_confirmed_for_workshop.py index c4e529a2d..080efd3a8 100644 --- a/amy/emails/actions/instructor_confirmed_for_workshop.py +++ b/amy/emails/actions/instructor_confirmed_for_workshop.py @@ -68,8 +68,8 @@ def get_recipients_context_json( { "api_uri": api_model_url("person", context["person"].pk), "property": "email", - }, - ], # type: ignore + }, # type: ignore + ], ) diff --git a/amy/emails/actions/instructor_declined_from_workshop.py b/amy/emails/actions/instructor_declined_from_workshop.py index 8fe076fc7..45fa50d42 100644 --- a/amy/emails/actions/instructor_declined_from_workshop.py +++ b/amy/emails/actions/instructor_declined_from_workshop.py @@ -68,8 +68,8 @@ def get_recipients_context_json( { "api_uri": api_model_url("person", context["person"].pk), "property": "email", - }, - ], # type: ignore + }, # type: ignore + ], ) diff --git a/amy/emails/actions/instructor_signs_up_for_workshop.py b/amy/emails/actions/instructor_signs_up_for_workshop.py index dcae97f58..5254ead04 100644 --- a/amy/emails/actions/instructor_signs_up_for_workshop.py +++ b/amy/emails/actions/instructor_signs_up_for_workshop.py @@ -62,8 +62,8 @@ def get_recipients_context_json( { "api_uri": api_model_url("person", context["person"].pk), "property": "email", - }, - ], # type: ignore + }, # type: ignore + ], ) diff --git a/amy/emails/actions/instructor_training_approaching.py b/amy/emails/actions/instructor_training_approaching.py index 6e0e925f4..e245cbd76 100644 --- a/amy/emails/actions/instructor_training_approaching.py +++ b/amy/emails/actions/instructor_training_approaching.py @@ -22,7 +22,7 @@ InstructorTrainingApproachingKwargs, StrategyEnum, ) -from emails.utils import api_model_url, one_month_before +from emails.utils import api_model_url, log_condition_elements, one_month_before from workshops.models import Event, Task logger = logging.getLogger("amy") @@ -36,7 +36,12 @@ def instructor_training_approaching_strategy(event: Event) -> StrategyEnum: Task.objects.filter(event=event, role__name="instructor").count() >= 2 ) start_date_in_future = event.start and event.start >= timezone.now().date() - logger.debug(f"{has_TTT=}, {has_at_least_2_instructors=}, {start_date_in_future=}") + + log_condition_elements( + has_TTT=has_TTT, + has_at_least_2_instructors=has_at_least_2_instructors, + start_date_in_future=start_date_in_future, + ) email_should_exist = has_TTT and has_at_least_2_instructors and start_date_in_future logger.debug(f"{email_should_exist=}") diff --git a/amy/emails/actions/instructor_training_completed_not_badged.py b/amy/emails/actions/instructor_training_completed_not_badged.py index b522c7274..758d2b33e 100644 --- a/amy/emails/actions/instructor_training_completed_not_badged.py +++ b/amy/emails/actions/instructor_training_completed_not_badged.py @@ -22,7 +22,12 @@ InstructorTrainingCompletedNotBadgedKwargs, StrategyEnum, ) -from emails.utils import api_model_url, scalar_value_url, two_months_after +from emails.utils import ( + api_model_url, + log_condition_elements, + scalar_value_url, + two_months_after, +) from workshops.models import Person, TrainingProgress, TrainingRequirement logger = logging.getLogger("amy") @@ -82,6 +87,13 @@ def instructor_training_completed_not_badged_strategy(person: Person) -> Strateg ], ).exists() + log_condition_elements( + **{ + "person_annotated.passed_training": person_annotated.passed_training, + "all_requirements_passed": all_requirements_passed, + } + ) + email_should_exist = ( bool(person_annotated.passed_training) and not all_requirements_passed ) @@ -227,8 +239,8 @@ def get_recipients_context_json( { "api_uri": api_model_url("person", context["person"].pk), "property": "email", - } - ], # type: ignore + } # type: ignore + ], ) diff --git a/amy/emails/actions/new_membership_onboarding.py b/amy/emails/actions/new_membership_onboarding.py index 12190d446..b8efddba4 100644 --- a/amy/emails/actions/new_membership_onboarding.py +++ b/amy/emails/actions/new_membership_onboarding.py @@ -21,7 +21,12 @@ NewMembershipOnboardingKwargs, StrategyEnum, ) -from emails.utils import api_model_url, immediate_action, one_month_before +from emails.utils import ( + api_model_url, + immediate_action, + log_condition_elements, + one_month_before, +) from fiscal.models import MembershipTask from workshops.models import Membership @@ -40,15 +45,26 @@ def new_membership_onboarding_strategy(membership: Membership) -> StrategyEnum: template__signal=NEW_MEMBERSHIP_ONBOARDING_SIGNAL_NAME, state=ScheduledEmailStatus.SCHEDULED, ).exists() + task_count = MembershipTask.objects.filter( + membership=membership, role__name__in=MEMBERSHIP_TASK_ROLES_EXPECTED + ).count() + + log_condition_elements( + **{ + "membership.pk": membership.pk, + "membership.rolled_from_membership": getattr( + membership, "rolled_from_membership", None + ), + "task_count": task_count, + } + ) # Membership can't be removed without removing the tasks first. This is when the # email would be de-scheduled. email_should_exist = ( membership.pk and getattr(membership, "rolled_from_membership", None) is None - and MembershipTask.objects.filter( - membership=membership, role__name__in=MEMBERSHIP_TASK_ROLES_EXPECTED - ).count() + and task_count ) if not email_scheduled and email_should_exist: diff --git a/amy/emails/actions/new_self_organised_workshop.py b/amy/emails/actions/new_self_organised_workshop.py new file mode 100644 index 000000000..f1a4b4d29 --- /dev/null +++ b/amy/emails/actions/new_self_organised_workshop.py @@ -0,0 +1,142 @@ +from datetime import datetime +import logging +from typing import Unpack + +from django.utils import timezone + +from emails.actions.base_action import BaseAction +from emails.schemas import ContextModel, ToHeaderModel +from emails.signals import new_self_organised_workshop_signal +from emails.types import NewSelfOrganisedWorkshopContext, NewSelfOrganisedWorkshopKwargs +from emails.utils import ( + api_model_url, + immediate_action, + log_condition_elements, + scalar_value_none, + scalar_value_url, +) +from extrequests.models import SelfOrganisedSubmission +from workshops.fields import TAG_SEPARATOR +from workshops.models import Event + +logger = logging.getLogger("amy") + + +def new_self_organised_workshop_check(event: Event) -> bool: + logger.info(f"Checking NewSelfOrganisedWorkshop conditions for {event}") + + self_organised = ( + event.administrator and event.administrator.domain == "self-organized" + ) + start_date_in_future = event.start and event.start >= timezone.now().date() + active = not event.tags.filter(name__in=["cancelled", "unresponsive", "stalled"]) + submission = SelfOrganisedSubmission.objects.filter(event=event).exists() + + log_condition_elements( + self_organised=self_organised, + start_date_in_future=start_date_in_future, + active=active, + submission=submission, + ) + + email_should_exist = bool( + self_organised and start_date_in_future and active and submission + ) + logger.debug(f"{email_should_exist=}") + logger.debug(f"NewSelfOrganisedWorkshop condition check: {email_should_exist}") + return email_should_exist + + +class NewSelfOrganisedWorkshopReceiver(BaseAction): + signal = new_self_organised_workshop_signal.signal_name + + def get_scheduled_at( + self, **kwargs: Unpack[NewSelfOrganisedWorkshopKwargs] + ) -> datetime: + return immediate_action() + + def get_context( + self, **kwargs: Unpack[NewSelfOrganisedWorkshopKwargs] + ) -> NewSelfOrganisedWorkshopContext: + event = kwargs["event"] + self_organised_submission = kwargs["self_organised_submission"] + return { + # Both self-organised submission and event can have assignees, but we prefer + # the one from submission. + "assignee": self_organised_submission.assigned_to + or event.assigned_to + or None, + "event": event, + "self_organised_submission": self_organised_submission, + } + + def get_context_json( + self, context: NewSelfOrganisedWorkshopContext + ) -> ContextModel: + return ContextModel( + { + "assignee": ( + api_model_url("person", context["assignee"].pk) + if context["assignee"] + else scalar_value_none() + ), + "event": api_model_url("event", context["event"].pk), + "self_organised_submission": api_model_url( + "selforganisedsubmission", context["self_organised_submission"].pk + ), + }, + ) + + def get_generic_relation_object( + self, + context: NewSelfOrganisedWorkshopContext, + **kwargs: Unpack[NewSelfOrganisedWorkshopKwargs], + ) -> Event: + return context["event"] + + def get_recipients( + self, + context: NewSelfOrganisedWorkshopContext, + **kwargs: Unpack[NewSelfOrganisedWorkshopKwargs], + ) -> list[str]: + self_organised_submission = context["self_organised_submission"] + return list( + filter( + bool, + [self_organised_submission.email] + + self_organised_submission.additional_contact.split(TAG_SEPARATOR), + ) + ) + + def get_recipients_context_json( + self, + context: NewSelfOrganisedWorkshopContext, + **kwargs: Unpack[NewSelfOrganisedWorkshopKwargs], + ) -> ToHeaderModel: + self_organised_submission = context["self_organised_submission"] + return ToHeaderModel( + ( + [ + { + "api_uri": api_model_url( + "selforganisedsubmission", self_organised_submission.pk + ), + "property": "email", + } + ] + if self_organised_submission.email + else [] + ) + + [ + # Note: this won't update automatically + {"value_uri": scalar_value_url("str", email)} + for email in self_organised_submission.additional_contact.split( + TAG_SEPARATOR + ) + if email + ], # type: ignore + ) + + +new_self_organised_workshop_receiver = NewSelfOrganisedWorkshopReceiver() +new_self_organised_workshop_signal.connect(new_self_organised_workshop_receiver) diff --git a/amy/emails/actions/persons_merged.py b/amy/emails/actions/persons_merged.py index 6ef3f057d..aaef3188f 100644 --- a/amy/emails/actions/persons_merged.py +++ b/amy/emails/actions/persons_merged.py @@ -52,8 +52,8 @@ def get_recipients_context_json( { "api_uri": api_model_url("person", context["person"].pk), "property": "email", - }, - ], # type: ignore + }, # type: ignore + ], ) diff --git a/amy/emails/actions/post_workshop_7days.py b/amy/emails/actions/post_workshop_7days.py index 9a9a96f00..81b8d0311 100644 --- a/amy/emails/actions/post_workshop_7days.py +++ b/amy/emails/actions/post_workshop_7days.py @@ -20,6 +20,7 @@ from emails.types import PostWorkshop7DaysContext, PostWorkshop7DaysKwargs, StrategyEnum from emails.utils import ( api_model_url, + log_condition_elements, scalar_value_none, shift_date_and_apply_current_utc_time, ) @@ -39,17 +40,22 @@ def post_workshop_7days_strategy(event: Event) -> StrategyEnum: and event.administrator.domain != "carpentries.org/community-lessons/" ) end_date_in_future = event.end and event.end >= timezone.now().date() - logger.debug(f"{not_self_organised=}, {not_cldt=}, {end_date_in_future=}") - active = not event.tags.filter(name__in=["cancelled", "unresponsive", "stalled"]) carpentries_tag = event.tags.filter(name__in=["LC", "DC", "SWC", "Circuits"]) - logger.debug(f"{active=}, {carpentries_tag=}") - at_least_1_host = Task.objects.filter(role__name="host", event=event).count() >= 1 at_least_1_instructor = ( Task.objects.filter(role__name="instructor", event=event).count() >= 1 ) - logger.debug(f"{at_least_1_host=}, {at_least_1_instructor=}") + + log_condition_elements( + not_self_organised=not_self_organised, + not_cldt=not_cldt, + end_date_in_future=end_date_in_future, + active=active, + carpentries_tag=carpentries_tag, + at_least_1_host=at_least_1_host, + at_least_1_instructor=at_least_1_instructor, + ) email_should_exist = ( not_self_organised diff --git a/amy/emails/actions/recruit_helpers.py b/amy/emails/actions/recruit_helpers.py index 07d2c71c9..02985106c 100644 --- a/amy/emails/actions/recruit_helpers.py +++ b/amy/emails/actions/recruit_helpers.py @@ -20,6 +20,7 @@ from emails.types import RecruitHelpersContext, RecruitHelpersKwargs, StrategyEnum from emails.utils import ( api_model_url, + log_condition_elements, scalar_value_none, shift_date_and_apply_current_utc_time, ) @@ -38,14 +39,20 @@ def recruit_helpers_strategy(event: Event) -> StrategyEnum: timezone.now().date() + timedelta(days=14) ) active = not event.tags.filter(name__in=["cancelled", "unresponsive", "stalled"]) - logger.debug(f"{not_self_organised=}, {start_date_in_at_least_14days=}, {active=}") - at_least_1_host = Task.objects.filter(role__name="host", event=event).count() >= 1 at_least_1_instructor = ( Task.objects.filter(role__name="instructor", event=event).count() >= 1 ) no_helpers = Task.objects.filter(role__name="helper", event=event).count() == 0 - logger.debug(f"{at_least_1_host=}, {at_least_1_instructor=}, {no_helpers=}") + + log_condition_elements( + not_self_organised=not_self_organised, + start_date_in_at_least_14days=start_date_in_at_least_14days, + active=active, + at_least_1_host=at_least_1_host, + at_least_1_instructor=at_least_1_instructor, + no_helpers=no_helpers, + ) email_should_exist = ( not_self_organised diff --git a/amy/emails/schemas.py b/amy/emails/schemas.py index a1f842e90..4b6674d74 100644 --- a/amy/emails/schemas.py +++ b/amy/emails/schemas.py @@ -15,18 +15,23 @@ def uri_validator(uri: str, expected_scheme: str = "https") -> str: raise ValueError("Invalid URI2") +# custom URI for links to individual models in API, e.g. "api:person/1234" ApiUri = Annotated[str, AfterValidator(partial(uri_validator, expected_scheme="api"))] + ValueUri = Annotated[ str, AfterValidator(partial(uri_validator, expected_scheme="value")) ] class SinglePropertyLinkModel(BaseModel): - # custom URI for links to individual models in API, e.g. "api:person/1234" api_uri: ApiUri property: str -ToHeaderModel = RootModel[list[SinglePropertyLinkModel]] +class SingleValueLinkModel(BaseModel): + value_uri: ValueUri + + +ToHeaderModel = RootModel[list[SinglePropertyLinkModel | SingleValueLinkModel]] ContextModel = RootModel[dict[str, ApiUri | list[ApiUri] | ValueUri]] diff --git a/amy/emails/signals.py b/amy/emails/signals.py index edc3a5259..2547ec050 100644 --- a/amy/emails/signals.py +++ b/amy/emails/signals.py @@ -13,6 +13,7 @@ InstructorTrainingApproachingContext, InstructorTrainingCompletedNotBadgedContext, NewMembershipOnboardingContext, + NewSelfOrganisedWorkshopContext, PersonsMergedContext, PostWorkshop7DaysContext, RecruitHelpersContext, @@ -34,6 +35,7 @@ class SignalNameEnum(StrEnum): host_instructors_introduction = "host_instructors_introduction" recruit_helpers = "recruit_helpers" post_workshop_7days = "post_workshop_7days" + new_self_organised_workshop = "new_self_organised_workshop" @staticmethod def choices() -> list[tuple[str, str]]: @@ -134,8 +136,6 @@ def triple_signals(name: str, context_type: Any) -> tuple[Signal, Signal, Signal recruit_helpers_cancel_signal, ) = triple_signals(RECRUIT_HELPERS_SIGNAL_NAME, RecruitHelpersContext) -ALL_SIGNALS = [item for item in locals().values() if isinstance(item, Signal)] - POST_WORKSHOP_7DAYS_SIGNAL_NAME = "post_workshop_7days" ( post_workshop_7days_signal, @@ -143,4 +143,9 @@ def triple_signals(name: str, context_type: Any) -> tuple[Signal, Signal, Signal post_workshop_7days_cancel_signal, ) = triple_signals(POST_WORKSHOP_7DAYS_SIGNAL_NAME, PostWorkshop7DaysContext) +new_self_organised_workshop_signal = Signal( + signal_name=SignalNameEnum.new_self_organised_workshop, + context_type=NewSelfOrganisedWorkshopContext, +) + ALL_SIGNALS = [item for item in locals().values() if isinstance(item, Signal)] diff --git a/amy/emails/tests/actions/test_admin_signs_instructor_up_for_workshop.py b/amy/emails/tests/actions/test_admin_signs_instructor_up_for_workshop.py index 40dbcb73d..7ebe1efbe 100644 --- a/amy/emails/tests/actions/test_admin_signs_instructor_up_for_workshop.py +++ b/amy/emails/tests/actions/test_admin_signs_instructor_up_for_workshop.py @@ -146,8 +146,11 @@ def test_email_scheduled( to_header=[person.email], to_header_context_json=ToHeaderModel( [ - {"api_uri": api_model_url("person", person.pk), "property": "email"} - ] # type: ignore + { + "api_uri": api_model_url("person", person.pk), + "property": "email", + } # type: ignore + ] ), generic_relation_obj=signup, author=None, diff --git a/amy/emails/tests/actions/test_instructor_badge_awarded.py b/amy/emails/tests/actions/test_instructor_badge_awarded.py index 8a7f4b239..8d9ad6635 100644 --- a/amy/emails/tests/actions/test_instructor_badge_awarded.py +++ b/amy/emails/tests/actions/test_instructor_badge_awarded.py @@ -117,8 +117,11 @@ def test_email_scheduled( to_header=[person.email], to_header_context_json=ToHeaderModel( [ - {"api_uri": api_model_url("person", person.pk), "property": "email"} - ] # type: ignore + { + "api_uri": api_model_url("person", person.pk), + "property": "email", + } # type: ignore + ] ), generic_relation_obj=award, author=None, diff --git a/amy/emails/tests/actions/test_instructor_confirmed_for_workshop.py b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop.py index 56666cdc7..43bc7c71a 100644 --- a/amy/emails/tests/actions/test_instructor_confirmed_for_workshop.py +++ b/amy/emails/tests/actions/test_instructor_confirmed_for_workshop.py @@ -146,8 +146,11 @@ def test_email_scheduled( to_header=[person.email], to_header_context_json=ToHeaderModel( [ - {"api_uri": api_model_url("person", person.pk), "property": "email"} - ] # type: ignore + { + "api_uri": api_model_url("person", person.pk), + "property": "email", + } # type: ignore + ] ), generic_relation_obj=signup, author=None, diff --git a/amy/emails/tests/actions/test_instructor_declined_from_workshop.py b/amy/emails/tests/actions/test_instructor_declined_from_workshop.py index beb44cb35..940b2a05a 100644 --- a/amy/emails/tests/actions/test_instructor_declined_from_workshop.py +++ b/amy/emails/tests/actions/test_instructor_declined_from_workshop.py @@ -146,8 +146,11 @@ def test_email_scheduled( to_header=[person.email], to_header_context_json=ToHeaderModel( [ - {"api_uri": api_model_url("person", person.pk), "property": "email"} - ] # type: ignore + { + "api_uri": api_model_url("person", person.pk), + "property": "email", + } # type: ignore + ] ), generic_relation_obj=signup, author=None, diff --git a/amy/emails/tests/actions/test_instructor_signs_up_for_workshop.py b/amy/emails/tests/actions/test_instructor_signs_up_for_workshop.py index 6eb5a197c..97b17d5b0 100644 --- a/amy/emails/tests/actions/test_instructor_signs_up_for_workshop.py +++ b/amy/emails/tests/actions/test_instructor_signs_up_for_workshop.py @@ -146,8 +146,11 @@ def test_email_scheduled( to_header=[person.email], to_header_context_json=ToHeaderModel( [ - {"api_uri": api_model_url("person", person.pk), "property": "email"} - ] # type: ignore + { + "api_uri": api_model_url("person", person.pk), + "property": "email", + } # type: ignore + ] ), generic_relation_obj=signup, author=None, diff --git a/amy/emails/tests/actions/test_new_self_organised_workshop.py b/amy/emails/tests/actions/test_new_self_organised_workshop.py new file mode 100644 index 000000000..794d3c425 --- /dev/null +++ b/amy/emails/tests/actions/test_new_self_organised_workshop.py @@ -0,0 +1,351 @@ +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 import new_self_organised_workshop_receiver +from emails.actions.new_self_organised_workshop import new_self_organised_workshop_check +from emails.models import EmailTemplate, ScheduledEmail +from emails.schemas import ContextModel, ToHeaderModel +from emails.signals import new_self_organised_workshop_signal +from emails.utils import api_model_url, scalar_value_none +from extrequests.models import SelfOrganisedSubmission +from workshops.models import Event, Language, Organization, Tag +from workshops.tests.base import TestBase + + +class TestNewSelfOrganisedWorkshopCheck(TestCase): + def setUp(self) -> None: + self.submission = SelfOrganisedSubmission.objects.create( + state="p", + personal="Harry", + family="Potter", + email="harry@hogwarts.edu", + institution_other_name="Hogwarts", + workshop_url="", + workshop_format="", + workshop_format_other="", + workshop_types_other_explain="", + language=Language.objects.get(name="English"), + ) + self.self_org = Organization.objects.get(domain="self-organized") + self.swc_org = Organization.objects.create( + domain="software-carpentry.org", fullname="Software Carpentry" + ) + self.swc_tag = Tag.objects.create(name="SWC") + self.event_start = date.today() + timedelta(days=30) + + def test_check_not_all_conditions(self) -> None: + # Arrange + event = Event.objects.create( + slug="2024-05-31-test-event", + host=self.swc_org, + sponsor=self.swc_org, + administrator=self.self_org, + start=self.event_start, + ) + event.tags.add(self.swc_tag) + + # Missing link with submission + # self.submission.event = event # type: ignore + # self.submission.save() + + # Act + result = new_self_organised_workshop_check(event) + + # Assert + self.assertFalse(result) + + def test_check_all_conditions(self) -> None: + # Arrange + event = Event.objects.create( + slug="2024-05-31-test-event", + host=self.swc_org, + sponsor=self.swc_org, + administrator=self.self_org, + start=self.event_start, + ) + event.tags.add(self.swc_tag) + self.submission.event = event # type: ignore + self.submission.save() + + # Act + result = new_self_organised_workshop_check(event) + + # Assert + self.assertTrue(result) + + +class TestNewSelfOrganisedWorkshopReceiver(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 + new_self_organised_workshop_receiver(None, request=request) + # Assert + mock_logger.debug.assert_called_once_with( + "EMAIL_MODULE feature flag not set, skipping " + "new_self_organised_workshop" + ) + + def test_receiver_connected_to_signal(self) -> None: + # Arrange + original_receivers = new_self_organised_workshop_signal.receivers[:] + + # Act + # attempt to connect the receiver + new_self_organised_workshop_signal.connect(new_self_organised_workshop_receiver) + new_receivers = new_self_organised_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 + ) + submission = SelfOrganisedSubmission.objects.create( + state="p", + personal="Harry", + family="Potter", + email="harry@hogwarts.edu", + institution_other_name="Hogwarts", + workshop_url="", + workshop_format="", + workshop_format_other="", + workshop_types_other_explain="", + language=Language.objects.get(name="English"), + event=event, + ) + template = EmailTemplate.objects.create( + name="Test Email Template", + signal=new_self_organised_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: + new_self_organised_workshop_signal.send( + sender=event, + request=request, + event=event, + self_organised_submission=submission, + ) + + # Assert + scheduled_email = ScheduledEmail.objects.get(template=template) + mock_messages_action_scheduled.assert_called_once_with( + request, + new_self_organised_workshop_signal.signal_name, + scheduled_email, + ) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @patch("emails.actions.base_action.messages_action_scheduled") + @patch("emails.actions.new_self_organised_workshop.immediate_action") + def test_email_scheduled( + self, + mock_immediate_action: MagicMock, + mock_messages_action_scheduled: MagicMock, + ) -> None: + # Arrange + NOW = datetime(2023, 6, 1, 10, 0, 0, tzinfo=UTC) + mock_immediate_action.return_value = NOW + timedelta(hours=1) + organization = Organization.objects.first() + event = Event.objects.create( + slug="test-event", host=organization, administrator=organization + ) + submission = SelfOrganisedSubmission.objects.create( + state="p", + personal="Harry", + family="Potter", + email="harry@hogwarts.edu", + institution_other_name="Hogwarts", + workshop_url="", + workshop_format="", + workshop_format_other="", + workshop_types_other_explain="", + language=Language.objects.get(name="English"), + event=event, + ) + request = RequestFactory().get("/") + signal = new_self_organised_workshop_signal.signal_name + scheduled_at = NOW + timedelta(hours=1) + + # Act + with patch( + "emails.actions.base_action.EmailController.schedule_email" + ) as mock_schedule_email: + new_self_organised_workshop_signal.send( + sender=event, + request=request, + event=event, + self_organised_submission=submission, + ) + + # Assert + mock_schedule_email.assert_called_once_with( + signal=signal, + context_json=ContextModel( + { + "assignee": scalar_value_none(), + "event": api_model_url("event", event.pk), + "self_organised_submission": api_model_url( + "selforganisedsubmission", submission.pk + ), + } + ), + scheduled_at=scheduled_at, + to_header=[submission.email], + to_header_context_json=ToHeaderModel( + [ + { + "api_uri": api_model_url( + "selforganisedsubmission", submission.pk + ), + "property": "email", + } # type: ignore + ] + ), + generic_relation_obj=event, + 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 + ) + submission = SelfOrganisedSubmission.objects.create( + state="p", + personal="Harry", + family="Potter", + email="", # intentionally empty + institution_other_name="Hogwarts", + workshop_url="", + workshop_format="", + workshop_format_other="", + workshop_types_other_explain="", + language=Language.objects.get(name="English"), + event=event, + ) + request = RequestFactory().get("/") + signal = new_self_organised_workshop_signal.signal_name + + # Act + new_self_organised_workshop_signal.send( + sender=event, + request=request, + event=event, + self_organised_submission=submission, + ) + + # 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 + ) + submission = SelfOrganisedSubmission.objects.create( + state="p", + personal="Harry", + family="Potter", + email="harry@hogwarts.edu", + institution_other_name="Hogwarts", + workshop_url="", + workshop_format="", + workshop_format_other="", + workshop_types_other_explain="", + language=Language.objects.get(name="English"), + event=event, + ) + request = RequestFactory().get("/") + signal = new_self_organised_workshop_signal.signal_name + + # Act + new_self_organised_workshop_signal.send( + sender=event, + request=request, + event=event, + self_organised_submission=submission, + ) + + # Assert + mock_messages_missing_template.assert_called_once_with(request, signal) + + +class TestNewSelfOrganisedWorkshopIntegration(TestBase): + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + def test_integration(self) -> None: + # Arrange + self._setUpUsersAndLogin() + self._setUpTags() + self._setUpRoles() + submission = SelfOrganisedSubmission.objects.create( + state="p", + personal="Harry", + family="Potter", + email="harry@hogwarts.edu", + institution_other_name="Hogwarts", + workshop_url="", + workshop_format="", + workshop_format_other="", + workshop_types_other_explain="", + language=Language.objects.get(name="English"), + ) + self_org = Organization.objects.get(domain="self-organized") + swc_org = Organization.objects.create( + domain="software-carpentry.org", fullname="Software Carpentry" + ) + start = date.today() + timedelta(days=30) + data = { + "slug": "2024-05-31-test-event", + "host": swc_org.pk, + "sponsor": swc_org.pk, + "administrator": self_org.pk, + "tags": [Tag.objects.get(name="SWC").pk], + "start": f"{start:%Y-%m-%d}", + } + url = reverse("selforganisedsubmission_accept_event", args=[submission.pk]) + + template = EmailTemplate.objects.create( + name="Test Email Template", + signal=new_self_organised_workshop_signal.signal_name, + from_header="workshops@carpentries.org", + cc_header=["team@carpentries.org"], + bcc_header=[], + subject="Greetings {{ person.personal }}", + body="Hello, {{ person.personal }}! Nice to meet **you**.", + ) + + # Act + rv = self.client.post(url, data=data) + + # Assert + self.assertEqual(rv.status_code, 302) + Event.objects.get(slug="2024-05-31-test-event") + ScheduledEmail.objects.get(template=template) diff --git a/amy/emails/tests/actions/test_persons_merged.py b/amy/emails/tests/actions/test_persons_merged.py index 6309c71b1..cf737e8e8 100644 --- a/amy/emails/tests/actions/test_persons_merged.py +++ b/amy/emails/tests/actions/test_persons_merged.py @@ -111,8 +111,11 @@ def test_email_scheduled( to_header=[person.email], to_header_context_json=ToHeaderModel( [ - {"api_uri": api_model_url("person", person.pk), "property": "email"} - ] # type: ignore + { + "api_uri": api_model_url("person", person.pk), + "property": "email", + } # type: ignore + ] ), generic_relation_obj=person, author=None, diff --git a/amy/emails/types.py b/amy/emails/types.py index 748d9fc17..23a298b5a 100644 --- a/amy/emails/types.py +++ b/amy/emails/types.py @@ -4,6 +4,7 @@ from django.http import HttpRequest +from extrequests.models import SelfOrganisedSubmission from recruitment.models import InstructorRecruitmentSignup from workshops.models import ( Award, @@ -165,6 +166,17 @@ class PostWorkshop7DaysKwargs(TypedDict): event_end_date: date +class NewSelfOrganisedWorkshopContext(TypedDict): + event: Event + self_organised_submission: SelfOrganisedSubmission + assignee: Person | None + + +class NewSelfOrganisedWorkshopKwargs(TypedDict): + event: Event + self_organised_submission: SelfOrganisedSubmission + + class StrategyEnum(StrEnum): CREATE = "create" UPDATE = "update" diff --git a/amy/emails/utils.py b/amy/emails/utils.py index dcb24aba6..67fac6605 100644 --- a/amy/emails/utils.py +++ b/amy/emails/utils.py @@ -1,7 +1,7 @@ from datetime import UTC, date, datetime, timedelta from functools import partial import logging -from typing import Iterable, cast +from typing import Any, Iterable, Literal, cast from django.conf import settings from django.contrib import messages @@ -154,8 +154,14 @@ def api_model_url(model: str, pk: int) -> str: return f"api:{model}#{pk}" -def scalar_value_url(type_: str, value: str) -> str: +def scalar_value_url( + type_: Literal["str", "int", "float", "bool", "date", "none"], value: str +) -> str: return f"value:{type_}#{value}" scalar_value_none = partial(scalar_value_url, "none", "") + + +def log_condition_elements(**condition_elements: Any) -> None: + logger.debug(f"{condition_elements=}") diff --git a/amy/extrequests/tests/test_selforganised_submissions.py b/amy/extrequests/tests/test_selforganised_submissions.py index abf079228..16b7e6e04 100644 --- a/amy/extrequests/tests/test_selforganised_submissions.py +++ b/amy/extrequests/tests/test_selforganised_submissions.py @@ -1,4 +1,5 @@ from datetime import date, timedelta +from unittest import skip from django.conf import settings from django.db.models import QuerySet @@ -764,6 +765,7 @@ def tearDown(self): extrequests.views.scheduler = self._saved_scheduler extrequests.views.redis_connection = self._saved_redis_connection + @skip("Test disabled because SelfOrganisedRequestAction is disabled") def test_jobs_created(self): data = { "slug": "xxxx-xx-xx-test-event", diff --git a/amy/extrequests/views.py b/amy/extrequests/views.py index 252253824..4272a0942 100644 --- a/amy/extrequests/views.py +++ b/amy/extrequests/views.py @@ -1,6 +1,7 @@ import csv import io import logging +from typing import cast from django.conf import settings from django.contrib import messages @@ -15,16 +16,16 @@ import django_rq from requests.exceptions import HTTPError, RequestException -from autoemails.actions import SelfOrganisedRequestAction -from autoemails.base_views import ActionManageMixin from autoemails.forms import GenericEmailScheduleForm -from autoemails.models import EmailTemplate, Trigger +from autoemails.models import EmailTemplate from consents.models import Term, TermOption, TrainingRequestConsent from consents.util import reconsent_for_term_option_type +from emails.actions.new_self_organised_workshop import new_self_organised_workshop_check from emails.actions.post_workshop_7days import ( post_workshop_7days_strategy, run_post_workshop_7days_strategy, ) +from emails.signals import new_self_organised_workshop_signal from extrequests.base_views import AMYCreateAndFetchObjectView, WRFInitial from extrequests.filters import ( SelfOrganisedSubmissionFilter, @@ -113,12 +114,13 @@ class WorkshopRequestDetails(OnlyForAdminsMixin, AMYDetailView): context_object_name = "object" template_name = "requests/workshoprequest.html" pk_url_kwarg = "request_id" + object: WorkshopRequest def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["title"] = "Workshop request #{}".format(self.get_object().pk) - member_code = self.get_object().member_code + member_code = cast(WorkshopRequest, self.get_object()).member_code context["membership"] = get_membership_or_none_from_code(member_code) person_lookup_form = AdminLookupForm() @@ -174,7 +176,7 @@ def get_context_data(self, **kwargs): context["title"] = "Accept and create a new event" - member_code = self.get_other_object().member_code + member_code = cast(WorkshopRequest, self.get_other_object()).member_code context["membership"] = get_membership_or_none_from_code(member_code) return context @@ -186,9 +188,9 @@ def form_valid(self, form): self.object = form.save() event = self.object - wr = self.other_object + workshop_request = cast(WorkshopRequest, self.other_object) - person = wr.host() + person = workshop_request.host() if person: Task.objects.create( event=event, person=person, role=Role.objects.get(name="host") @@ -215,9 +217,9 @@ def form_valid(self, form): event, ) - wr.state = "a" - wr.event = event - wr.save() + workshop_request.state = "a" + workshop_request.event = event + workshop_request.save() return super().form_valid(form) @@ -248,6 +250,7 @@ class WorkshopInquiryDetails(OnlyForAdminsMixin, AMYDetailView): context_object_name = "object" template_name = "requests/workshopinquiry.html" pk_url_kwarg = "inquiry_id" + object: WorkshopInquiryRequest def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -310,9 +313,9 @@ def form_valid(self, form): self.object = form.save() event = self.object - wr = self.other_object + inquiry = cast(WorkshopInquiryRequest, self.other_object) - person = wr.host() + person = inquiry.host() if person: Task.objects.create( event=event, person=person, role=Role.objects.get(name="host") @@ -339,9 +342,9 @@ def form_valid(self, form): event, ) - wr.state = "a" - wr.event = event - wr.save() + inquiry.state = "a" + inquiry.event = event + inquiry.save() return super().form_valid(form) @@ -372,6 +375,7 @@ class SelfOrganisedSubmissionDetails(OnlyForAdminsMixin, AMYDetailView): context_object_name = "object" template_name = "requests/selforganisedsubmission.html" pk_url_kwarg = "submission_id" + object: SelfOrganisedSubmission def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -426,6 +430,7 @@ class SelfOrganisedSubmissionAcceptEvent( queryset_other = SelfOrganisedSubmission.objects.filter(state="p") context_other_object_name = "object" pk_url_kwarg = "submission_id" + other_object: SelfOrganisedSubmission def get_form_kwargs(self): """Extend form kwargs with `initial` values. @@ -494,31 +499,31 @@ def form_valid(self, form): self.object = form.save() event = self.object - wr = self.other_object + submission = cast(SelfOrganisedSubmission, self.other_object) - person = wr.host() + person = submission.host() if person: Task.objects.create( event=event, person=person, role=Role.objects.get(name="host") ) - wr.state = "a" - wr.event = event - wr.save() - - if SelfOrganisedRequestAction.check(event): - objs = dict(event=event, request=wr) - jobs, rqjobs = ActionManageMixin.add( - action_class=SelfOrganisedRequestAction, - logger=logger, - scheduler=scheduler, - triggers=Trigger.objects.filter( - active=True, action="self-organised-request-form" - ), - context_objects=objs, - object_=event, - request=self.request, - ) + submission.state = "a" + submission.event = event + submission.save() + + # if SelfOrganisedRequestAction.check(event): + # objs = dict(event=event, request=wr) + # jobs, rqjobs = ActionManageMixin.add( + # action_class=SelfOrganisedRequestAction, + # logger=logger, + # scheduler=scheduler, + # triggers=Trigger.objects.filter( + # active=True, action="self-organised-request-form" + # ), + # context_objects=objs, + # object_=event, + # request=self.request, + # ) # if PostWorkshopAction.check(event): # objs = dict(event=event, request=wr) @@ -541,6 +546,14 @@ def form_valid(self, form): event, ) + if new_self_organised_workshop_check(event): + new_self_organised_workshop_signal.send( + sender=event, + request=self.request, + event=event, + self_organised_submission=submission, + ) + return super().form_valid(form)