From 13b722cf15ab56c69bccef7058a1f2fd2c485a37 Mon Sep 17 00:00:00 2001 From: Robert Davey Date: Wed, 6 Nov 2024 12:02:54 +0000 Subject: [PATCH 01/46] Update upload-artifact version to v4 --- .github/workflows/accessibility.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/accessibility.yml b/.github/workflows/accessibility.yml index 547905cd2..4d1f93e9e 100644 --- a/.github/workflows/accessibility.yml +++ b/.github/workflows/accessibility.yml @@ -16,14 +16,14 @@ jobs: # pa11y runs faster and produces smaller artifacts # lighthouse includes performance tests and other checks for non-a11y best practices # for now, just use pa11y as we focus on improving a11y specifically - package: ["pa11y"] + package: ["pa11y"] fail-fast: false # don't cancel lighthouse job if pa11y fails, and vice versa env: AMY_DATABASE_HOST: localhost AMY_DATABASE_PORT: 5432 AMY_DATABASE_NAME: test_amy AMY_DATABASE_USER: postgres - AMY_DATABASE_PASSWORD: postgres + AMY_DATABASE_PASSWORD: postgres AMY_INSTRUCTOR_RECRUITMENT_ENABLED: True services: @@ -40,7 +40,7 @@ jobs: --health-retries 5 ports: - 5432:5432 - + steps: # Database setup @@ -94,7 +94,7 @@ jobs: - name: run Lighthouse CI run: | - npm install -g @lhci/cli@0.12.x + npm install -g @lhci/cli@0.12.x npm install -g puppeteer lhci autorun env: @@ -109,7 +109,7 @@ jobs: if: matrix.package == 'lighthouse' # Pa11y - + - name: Start server in background run: | pipenv run make serve & @@ -123,7 +123,7 @@ jobs: continue-on-error: true - name: Upload pa11y test results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: pa11y-ci-report path: pa11y-ci-report/ From 4af3390921ca59bac9b5c2106bd06de96ad64826 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Sat, 30 Nov 2024 18:01:57 +0100 Subject: [PATCH 02/46] Bump version to v4.6.0-dev --- amy/__init__.py | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index d27c05820..29b5d9758 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -1 +1 @@ -__version__ = "v4.5.0-dev" +__version__ = "v4.6.0-dev" diff --git a/package.json b/package.json index 62a2cb716..c0376ca79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amy", - "version": "v4.5.0-dev", + "version": "v4.6.0-dev", "description": "A web-based workshop administration application for The Carpentries", "main": "index.js", "repository": "git@github.com:carpentries/amy.git", From ed4c451b62e7aefa05e38521eeb00fc72ba904a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:49:18 +0000 Subject: [PATCH 03/46] Bump django from 4.2.16 to 4.2.17 Bumps [django](https://github.com/django/django) from 4.2.16 to 4.2.17. - [Commits](https://github.com/django/django/compare/4.2.16...4.2.17) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Pipfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index e8d2664c1..e1075b5f2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -319,12 +319,12 @@ }, "django": { "hashes": [ - "sha256:1ddc333a16fc139fd253035a1606bb24261951bbc3a6ca256717fa06cc41a898", - "sha256:6f1616c2786c408ce86ab7e10f792b8f15742f7b7b7460243929cb371e7f1dad" + "sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0", + "sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.2.16" + "version": "==4.2.17" }, "django-anymail": { "hashes": [ @@ -1073,11 +1073,11 @@ }, "sqlparse": { "hashes": [ - "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", - "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e" + "sha256:9e37b35e16d1cc652a2545f0997c1deb23ea28fa1f3eefe609eee3063c3b105f", + "sha256:e99bc85c78160918c3e1d9230834ab8d80fc06c59d03f8db2618f65f65dda55e" ], "markers": "python_version >= '3.8'", - "version": "==0.5.1" + "version": "==0.5.2" }, "typing-extensions": { "hashes": [ From 8eadb463539f0e01355c4ce5fa69f8c72814b1ac Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Sun, 15 Dec 2024 17:56:21 +0100 Subject: [PATCH 04/46] feat(#2728): Turn "Instructor Declined From Workshop" into a complex email action --- .../instructor_declined_from_workshop.py | 288 +++++++++++++++--- amy/emails/signals.py | 11 +- 2 files changed, 259 insertions(+), 40 deletions(-) diff --git a/amy/emails/actions/instructor_declined_from_workshop.py b/amy/emails/actions/instructor_declined_from_workshop.py index 45fa50d42..9480bd328 100644 --- a/amy/emails/actions/instructor_declined_from_workshop.py +++ b/amy/emails/actions/instructor_declined_from_workshop.py @@ -1,79 +1,293 @@ from datetime import datetime +import logging +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 +from emails.actions.base_action import BaseAction, BaseActionCancel, BaseActionUpdate +from emails.actions.base_strategy import run_strategy +from emails.models import ScheduledEmail, ScheduledEmailStatus from emails.schemas import ContextModel, ToHeaderModel -from emails.signals import instructor_declined_from_workshop_signal -from emails.types import InstructorDeclinedContext, InstructorDeclinedKwargs -from emails.utils import api_model_url, immediate_action +from emails.signals import ( + INSTRUCTOR_DECLINED_FROM_WORKSHOP_SIGNAL_NAME, + Signal, + instructor_declined_from_workshop_cancel_signal, + instructor_declined_from_workshop_signal, + instructor_declined_from_workshop_update_signal, +) +from emails.types import ( + InstructorDeclinedContext, + InstructorDeclinedKwargs, + StrategyEnum, +) +from emails.utils import api_model_url, immediate_action, log_condition_elements from recruitment.models import InstructorRecruitmentSignup -from workshops.models import Event, Person +from workshops.models import Event, Person, TagQuerySet + +logger = logging.getLogger("amy") + + +def instructor_declined_from_workshop_strategy( + signup: InstructorRecruitmentSignup, +) -> StrategyEnum: + logger.info(f"Running InstructorDeclinedFromWorkshop strategy for {signup=}") + + signup_is_declined = signup.state == "d" + 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 = ( + event.administrator and event.administrator.domain != "self-organized" + ) + start_date_in_future = event.start and event.start >= timezone.now().date() + + log_condition_elements( + signup=signup, + signup_pk=signup.pk, + signup_is_declined=signup_is_declined, + event=event, + 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 = ( + signup_is_declined + 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(InstructorRecruitmentSignup) + email_exists = ScheduledEmail.objects.filter( + generic_relation_content_type=ct, + generic_relation_pk=signup.pk, + template__signal=INSTRUCTOR_DECLINED_FROM_WORKSHOP_SIGNAL_NAME, + state=ScheduledEmailStatus.SCHEDULED, + ).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"InstructorDeclinedFromWorkshop strategy {result = }") + return result + + +def run_instructor_declined_from_workshop_strategy( + strategy: StrategyEnum, + request: HttpRequest, + signup: InstructorRecruitmentSignup, + **kwargs, +) -> None: + signal_mapping: dict[StrategyEnum, Signal | None] = { + StrategyEnum.CREATE: instructor_declined_from_workshop_signal, + StrategyEnum.UPDATE: instructor_declined_from_workshop_update_signal, + StrategyEnum.CANCEL: instructor_declined_from_workshop_cancel_signal, + StrategyEnum.NOOP: None, + } + return run_strategy( + strategy, + signal_mapping, + request, + sender=signup, + signup=signup, + **kwargs, + ) + + +def get_scheduled_at(**kwargs: Unpack[InstructorDeclinedKwargs]) -> datetime: + return immediate_action() + + +def get_context( + **kwargs: Unpack[InstructorDeclinedKwargs], +) -> InstructorDeclinedContext: + person = Person.objects.get(pk=kwargs["person_id"]) + event = Event.objects.get(pk=kwargs["event_id"]) + instructor_recruitment_signup = InstructorRecruitmentSignup.objects.get( + pk=kwargs["instructor_recruitment_signup_id"] + ) + return { + "person": person, + "event": event, + "instructor_recruitment_signup": instructor_recruitment_signup, + } + + +def get_context_json(context: InstructorDeclinedContext) -> ContextModel: + return ContextModel( + { + "person": api_model_url("person", context["person"].pk), + "event": api_model_url("event", context["event"].pk), + "instructor_recruitment_signup": api_model_url( + "instructorrecruitmentsignup", + context["instructor_recruitment_signup"].pk, + ), + }, + ) + + +def get_generic_relation_object( + context: InstructorDeclinedContext, + **kwargs: Unpack[InstructorDeclinedKwargs], +) -> InstructorRecruitmentSignup: + return context["instructor_recruitment_signup"] + + +def get_recipients( + context: InstructorDeclinedContext, + **kwargs: Unpack[InstructorDeclinedKwargs], +) -> list[str]: + person = context["person"] + return [person.email] if person.email else [] + + +def get_recipients_context_json( + context: InstructorDeclinedContext, + **kwargs: Unpack[InstructorDeclinedKwargs], +) -> ToHeaderModel: + return ToHeaderModel( + [ + { + "api_uri": api_model_url("person", context["person"].pk), + "property": "email", + }, # type: ignore + ], + ) class InstructorDeclinedFromWorkshopReceiver(BaseAction): signal = instructor_declined_from_workshop_signal.signal_name def get_scheduled_at(self, **kwargs: Unpack[InstructorDeclinedKwargs]) -> datetime: - return immediate_action() + return get_scheduled_at(**kwargs) def get_context( self, **kwargs: Unpack[InstructorDeclinedKwargs] ) -> InstructorDeclinedContext: - person = Person.objects.get(pk=kwargs["person_id"]) - event = Event.objects.get(pk=kwargs["event_id"]) - instructor_recruitment_signup = InstructorRecruitmentSignup.objects.get( - pk=kwargs["instructor_recruitment_signup_id"] - ) - return { - "person": person, - "event": event, - "instructor_recruitment_signup": instructor_recruitment_signup, - } + return get_context(**kwargs) def get_context_json(self, context: InstructorDeclinedContext) -> ContextModel: - return ContextModel( - { - "person": api_model_url("person", context["person"].pk), - "event": api_model_url("event", context["event"].pk), - "instructor_recruitment_signup": api_model_url( - "instructorrecruitmentsignup", - context["instructor_recruitment_signup"].pk, - ), - }, - ) + return get_context_json(context) def get_generic_relation_object( self, context: InstructorDeclinedContext, **kwargs: Unpack[InstructorDeclinedKwargs], ) -> InstructorRecruitmentSignup: - return context["instructor_recruitment_signup"] + return get_generic_relation_object(context, **kwargs) def get_recipients( self, context: InstructorDeclinedContext, **kwargs: Unpack[InstructorDeclinedKwargs], ) -> list[str]: - person = context["person"] - return [person.email] if person.email else [] + return get_recipients(context, **kwargs) def get_recipients_context_json( self, context: InstructorDeclinedContext, **kwargs: Unpack[InstructorDeclinedKwargs], ) -> ToHeaderModel: - return ToHeaderModel( - [ - { - "api_uri": api_model_url("person", context["person"].pk), - "property": "email", - }, # type: ignore - ], - ) + return get_recipients_context_json(context, **kwargs) + + +class InstructorDeclinedFromWorkshopUpdateReceiver(BaseActionUpdate): + signal = instructor_declined_from_workshop_signal.signal_name + + def get_scheduled_at(self, **kwargs: Unpack[InstructorDeclinedKwargs]) -> datetime: + return get_scheduled_at(**kwargs) + + def get_context( + self, **kwargs: Unpack[InstructorDeclinedKwargs] + ) -> InstructorDeclinedContext: + return get_context(**kwargs) + + def get_context_json(self, context: InstructorDeclinedContext) -> ContextModel: + return get_context_json(context) + + def get_generic_relation_object( + self, + context: InstructorDeclinedContext, + **kwargs: Unpack[InstructorDeclinedKwargs], + ) -> InstructorRecruitmentSignup: + return get_generic_relation_object(context, **kwargs) + + def get_recipients( + self, + context: InstructorDeclinedContext, + **kwargs: Unpack[InstructorDeclinedKwargs], + ) -> list[str]: + return get_recipients(context, **kwargs) + + def get_recipients_context_json( + self, + context: InstructorDeclinedContext, + **kwargs: Unpack[InstructorDeclinedKwargs], + ) -> ToHeaderModel: + return get_recipients_context_json(context, **kwargs) + + +class InstructorDeclinedFromWorkshopCancelReceiver(BaseActionCancel): + signal = instructor_declined_from_workshop_signal.signal_name + + def get_context( + self, **kwargs: Unpack[InstructorDeclinedKwargs] + ) -> InstructorDeclinedContext: + return get_context(**kwargs) + + def get_context_json(self, context: InstructorDeclinedContext) -> ContextModel: + return get_context_json(context) + + def get_generic_relation_object( + self, + context: InstructorDeclinedContext, + **kwargs: Unpack[InstructorDeclinedKwargs], + ) -> InstructorRecruitmentSignup: + return get_generic_relation_object(context, **kwargs) + + def get_recipients( + self, + context: InstructorDeclinedContext, + **kwargs: Unpack[InstructorDeclinedKwargs], + ) -> list[str]: + return get_recipients(context, **kwargs) + + def get_recipients_context_json( + self, + context: InstructorDeclinedContext, + **kwargs: Unpack[InstructorDeclinedKwargs], + ) -> ToHeaderModel: + return get_recipients_context_json(context, **kwargs) instructor_declined_from_workshop_receiver = InstructorDeclinedFromWorkshopReceiver() instructor_declined_from_workshop_signal.connect( instructor_declined_from_workshop_receiver ) +instructor_declined_from_workshop_update_receiver = ( + InstructorDeclinedFromWorkshopUpdateReceiver() +) +instructor_declined_from_workshop_update_signal.connect( + instructor_declined_from_workshop_update_receiver +) +instructor_declined_from_workshop_cancel_receiver = ( + InstructorDeclinedFromWorkshopCancelReceiver() +) +instructor_declined_from_workshop_cancel_signal.connect( + instructor_declined_from_workshop_cancel_receiver +) diff --git a/amy/emails/signals.py b/amy/emails/signals.py index 163412895..b561ec99b 100644 --- a/amy/emails/signals.py +++ b/amy/emails/signals.py @@ -82,9 +82,14 @@ def triple_signals(name: str, context_type: Any) -> tuple[Signal, Signal, Signal SignalNameEnum.instructor_confirmed_for_workshop, InstructorConfirmedContext, ) -instructor_declined_from_workshop_signal = Signal( - signal_name=SignalNameEnum.instructor_declined_from_workshop, - context_type=InstructorDeclinedContext, +INSTRUCTOR_DECLINED_FROM_WORKSHOP_SIGNAL_NAME = "instructor_declined_from_workshop" +( + instructor_declined_from_workshop_signal, + instructor_declined_from_workshop_update_signal, + instructor_declined_from_workshop_cancel_signal, +) = triple_signals( + SignalNameEnum.instructor_declined_from_workshop, + InstructorDeclinedContext, ) instructor_signs_up_for_workshop_signal = Signal( signal_name=SignalNameEnum.instructor_signs_up_for_workshop, From 93df51faab117923a9b3766f87551da4d466eebc Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Sun, 15 Dec 2024 17:56:43 +0100 Subject: [PATCH 05/46] feat(#2728): Fix instructor confirmed/declined tests --- ..._instructor_confirmed_for_workshop_receiver.py | 11 +++++++++-- .../test_instructor_declined_from_workshop.py | 15 +++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) 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 222c1ed7a..5151dc7d1 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 @@ -13,7 +13,7 @@ from emails.signals import instructor_confirmed_for_workshop_signal from emails.utils import api_model_url, scalar_value_url from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup -from workshops.models import Event, Organization, Person, Role, Task +from workshops.models import Event, Organization, Person, Role, Tag, Task from workshops.tests.base import TestBase @@ -249,6 +249,8 @@ def test_integration( ) -> None: # Arrange self._setUpRoles() + self._setUpTags() + self._setUpAdministrators() host = Organization.objects.create(domain="test.com", fullname="Test") person = Person.objects.create_user( # type: ignore username="test_test", @@ -269,8 +271,13 @@ def test_integration( person=person, ) event = Event.objects.create( - slug="test-event", host=host, start=date(2023, 7, 22), end=date(2023, 7, 23) + 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 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 aac2b2289..36226cb14 100644 --- a/amy/emails/tests/actions/test_instructor_declined_from_workshop.py +++ b/amy/emails/tests/actions/test_instructor_declined_from_workshop.py @@ -11,7 +11,7 @@ from emails.signals import instructor_declined_from_workshop_signal from emails.utils import api_model_url from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup -from workshops.models import Event, Organization, Person +from workshops.models import Event, Organization, Person, Tag from workshops.tests.base import TestBase @@ -228,15 +228,13 @@ class TestInstructorDeclinedFromWorkshopReceiverIntegration(TestBase): "EMAIL_MODULE": [("boolean", True)], } ) - @patch("django.contrib.messages.views.messages") - @patch("emails.actions.base_action.messages_action_scheduled") def test_integration( self, - mock_messages_action_scheduled: MagicMock, - mock_contrib_messages_views: MagicMock, ) -> None: # Arrange self._setUpRoles() + self._setUpTags() + self._setUpAdministrators() host = Organization.objects.create(domain="test.com", fullname="Test") person = Person.objects.create_user( # type: ignore username="test_test", @@ -257,8 +255,13 @@ def test_integration( person=person, ) event = Event.objects.create( - slug="test-event", host=host, start=date(2023, 7, 22), end=date(2023, 7, 23) + 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 From 2cfba35dacdaf83f71c115fe3bfbe7b9db47773a Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Wed, 18 Dec 2024 21:53:15 +0100 Subject: [PATCH 06/46] feat(#2728): Run accept/decline strategies when changing recruitment signup state --- .../test_instructor_recruitment_views.py | 27 ++++----- amy/recruitment/views.py | 56 ++++++++++++++----- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/amy/recruitment/tests/test_instructor_recruitment_views.py b/amy/recruitment/tests/test_instructor_recruitment_views.py index 5ec60c88f..a396b84bc 100644 --- a/amy/recruitment/tests/test_instructor_recruitment_views.py +++ b/amy/recruitment/tests/test_instructor_recruitment_views.py @@ -6,6 +6,7 @@ from django.test import override_settings from django.test.client import RequestFactory from django.urls import reverse +from django.utils import timezone from communityroles.models import CommunityRole, CommunityRoleConfig from emails.types import StrategyEnum @@ -679,14 +680,10 @@ def test_form_valid(self) -> None: ) view.remove_instructor_task.assert_not_called() - # Disable email module so that signals don't fail on fetching a mocked object - # from DB. - @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", False)]}) def test_add_instructor_task(self) -> None: # Arrange super()._setUpRoles() request = RequestFactory().post("/") - mock_signup = mock.MagicMock() view = InstructorRecruitmentSignupChangeState(request=request) person = Person.objects.create( personal="Test", family="User", username="test_user" @@ -696,20 +693,19 @@ def test_add_instructor_task(self) -> None: slug="test-event", host=organization, administrator=organization, + start=timezone.now().date(), ) + recruitment = InstructorRecruitment(event=event) + signup = InstructorRecruitmentSignup(recruitment=recruitment, person=person) # Act - task = view.add_instructor_task(request, mock_signup, person, event) + task = view.add_instructor_task(request, signup, person, event) # Assert self.assertTrue(task.pk) - # Disable email module so that signals don't fail on fetching a mocked object - # from DB. - @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", False)]}) def test_remove_instructor_task(self) -> None: # Arrange super()._setUpRoles() request = RequestFactory().post("/") - mock_signup = mock.MagicMock() view = InstructorRecruitmentSignupChangeState(request=request) person = Person.objects.create( personal="Test", family="User", username="test_user" @@ -719,23 +715,22 @@ def test_remove_instructor_task(self) -> None: slug="test-event", host=organization, administrator=organization, + start=timezone.now().date(), ) + 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 - view.remove_instructor_task(request, mock_signup, person, event) + view.remove_instructor_task(request, signup, person, event) # Assert with self.assertRaises(Task.DoesNotExist): task.refresh_from_db() - # Disable email module so that signals don't fail on fetching a mocked object - # from DB. - @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", False)]}) def test_remove_instructor_task__no_task(self) -> None: # Arrange super()._setUpRoles() request = RequestFactory().post("/") - mock_signup = mock.MagicMock() view = InstructorRecruitmentSignupChangeState(request=request) person = Person.objects.create( personal="Test", family="User", username="test_user" @@ -746,8 +741,10 @@ def test_remove_instructor_task__no_task(self) -> None: host=organization, administrator=organization, ) + recruitment = InstructorRecruitment(event=event) + signup = InstructorRecruitmentSignup(recruitment=recruitment, person=person) # Act & Assert - no error - view.remove_instructor_task(request, mock_signup, person, event) + view.remove_instructor_task(request, signup, person, event) def test_post__form_valid(self) -> None: # Arrange diff --git a/amy/recruitment/views.py b/amy/recruitment/views.py index 5e7ed1282..4fe211e70 100644 --- a/amy/recruitment/views.py +++ b/amy/recruitment/views.py @@ -21,11 +21,15 @@ host_instructors_introduction_strategy, run_host_instructors_introduction_strategy, ) -from emails.signals import ( - admin_signs_instructor_up_for_workshop_signal, - instructor_confirmed_for_workshop_signal, - instructor_declined_from_workshop_signal, +from emails.actions.instructor_confirmed_for_workshop import ( + instructor_confirmed_for_workshop_strategy, + run_instructor_confirmed_for_workshop_strategy, ) +from emails.actions.instructor_declined_from_workshop import ( + instructor_declined_from_workshop_strategy, + run_instructor_declined_from_workshop_strategy, +) +from emails.signals import admin_signs_instructor_up_for_workshop_signal from recruitment.filters import InstructorRecruitmentFilter from recruitment.forms import ( InstructorRecruitmentAddSignupForm, @@ -408,7 +412,8 @@ def form_valid(self, form) -> HttpResponse: self.object.recruitment.event, ) return super().form_valid(form) - except IntegrityError: + except IntegrityError as exc: + logger.error(f"{exc}") messages.error( self.request, "Unable to create or remove instructor task due to database error.", @@ -429,17 +434,25 @@ def add_instructor_task( role=role, ) - # Note: there's a strategy for this email, but this case may be simple enough - # that we don't need to use it. - instructor_confirmed_for_workshop_signal.send( - sender=signup, - request=request, + run_instructor_confirmed_for_workshop_strategy( + instructor_confirmed_for_workshop_strategy(task), + request, + task=task, person_id=person.pk, event_id=event.pk, task_id=task.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, + signup=signup, + person_id=person.pk, + event_id=event.pk, + instructor_recruitment_id=signup.recruitment.pk, + instructor_recruitment_signup_id=signup.pk, + ) return task @@ -454,14 +467,31 @@ def remove_instructor_task( 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, + ), + 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, + ) - instructor_declined_from_workshop_signal.send( - sender=signup, - request=request, + run_instructor_declined_from_workshop_strategy( + instructor_declined_from_workshop_strategy(signup), + request, + signup=signup, person_id=person.pk, event_id=event.pk, instructor_recruitment_id=signup.recruitment.pk, From e26a219782273ae18ed004041f7d3c548870056b Mon Sep 17 00:00:00 2001 From: Maneesha Sane Date: Fri, 20 Dec 2024 13:50:25 -0500 Subject: [PATCH 07/46] add UI features extensions to docs --- mkdocs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index ffa7338bb..5f86b6dec 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -58,6 +58,8 @@ nav: markdown_extensions: - toc: permalink: True + - admonition + - pymdownx.details - pymdownx.superfences: custom_fences: - name: mermaid From a83a2982c0345b739d4781a442338d9782140f35 Mon Sep 17 00:00:00 2001 From: Maneesha Sane Date: Fri, 20 Dec 2024 13:51:11 -0500 Subject: [PATCH 08/46] update user documentation on new auto email system --- docs/users_guide/admin_index.md | 153 +++++++++++++++++++++----------- 1 file changed, 103 insertions(+), 50 deletions(-) diff --git a/docs/users_guide/admin_index.md b/docs/users_guide/admin_index.md index 07d198080..171eb61a7 100644 --- a/docs/users_guide/admin_index.md +++ b/docs/users_guide/admin_index.md @@ -514,56 +514,109 @@ An admin user can view their profile as if they were an ordinary user on [this d ### Automated emails -AMY sends automated emails for workshop administration. The following seven email automations are currently enabled. The content of each email can be viewed by authorized users only. View by selecting `Django Admin` in the top right menu, and select `Email Templates` from the admin page. - -**Email description:** Introduction of instructors and host. -**Sent to:** All Workshop Hosts + Instructors -**Sent from:** Regional coordinator email -**Subject:** Instructors for {{ workshop_main_type }} workshop organized by {{ workshop_host.fullname }} {% if dates %} on {{ dates }}{% endif %} -**Date sent:** One hour from when conditions are met. -**Conditions:** Start date more than seven days in future. Tag is not stalled, canceled, or unresponsive. Has administrator. Has at least two instructors and at least one host. Has at least one supporting instructor if online. Centrally organized only. - -**Email description:** Recruit Helpers. -**Sent to:** All Workshop Hosts + Instructors -**Sent from:** Regional coordinator email (admin-xx@carpentries.org) -**Subject:** Time to Recruit Helpers for {% if workshop_main_type %}{{ workshop_main_type }}{% endif %} workshop at {{ workshop.venue }} {% if dates %} on {{ dates }}{% endif %} -**Date sent:** 21 days before start date. -**Conditions:** Start date in future. Tag is not stalled, canceled, or unresponsive. Has administrator. Has at least one instructor or at least one host. Has no helpers. Centrally organized only. - -**Email description:** Website URL is missing. -**Sent to:** All Instructors -**Sent from:** Regional coordinator email -**Subject:** Workshop Website needed for {DC, SWC, LC} workshop at {site} on {dates} -**Date sent:** 30 days before start date. -**Conditions:** Start date in future. Tag is not stalled, canceled, or unresponsive. Has administrator. No workshop URL. At least one instructor. Centrally organized or Self organized. - -**Email description:** Supporting Instructor is added to the workshop. -**Sent to:** Single new supporting instructor -**Sent from:** Regional coordinator email -**Subject:** Confirmation of your participation as a Supporting-Instructor for {% if workshop_main_type %}{{ workshop_main_type }}{% endif %} workshop organized by {{ host.fullname }} {% if dates %}({{ dates }}){% endif %} -**Date sent:** One hour after conditions are met. -**Conditions:** New role of supporting instructor added. Start date in future. Tag is not stalled, canceled, or unresponsive. Has administrator. Online workshop. Centrally organized only. - -**Email description:** Instructor is added to the workshop. -**Sent to:** Single new instructor -**Sent from:** Regional coordinator email -**Subject:** Confirmation of your participation as an instructor for {% if workshop_main_type %}{{ workshop_main_type }}{% endif %} workshop organized by {{ host.fullname }} {% if dates %} ({{ dates }}){% endif %} -**Date sent:** One hour after conditions are met. -**Conditions:** New role of instructor added. Start date in future. Tag is not stalled, canceled, or unresponsive. Has administrator. Centrally organized only. - -**Email description:** A new event is created from Self-Organised Request Form. -**Sent to:** Form submitter and additional contacts -**Sent from:** Regional coordinator email -**Subject:** {{ workshop.host.fullname }} ({{ workshop.slug }}) Workshop -**Date sent:** One hour after conditions are met. -**Conditions:** Start date in future. Tag is not stalled, canceled, or unresponsive. Event created from SO form. Self organized only. - -**Email description:** 7 days past the end date of an active workshop. -**Sent to:** All Workshop Hosts + Instructors -**Sent from:** Regional coordinator email -**Subject:** Completed {% if workshop_main_type %}{{ workshop_main_type }}{% else %}Carpentries{% endif %} workshop at {{ workshop.venue }} on {{ dates }} ({{ workshop.slug }}) -**Date sent:** 7 days after end date. -**Conditions:** End date in past. Tag is not stalled, canceled, or unresponsive. Tag is one of ["LC", "DC", "SWC", "Circuits"]. Centrally organized or Self organized. +AMY sends automated emails for membership, instructor training, and workshop administration. This system was completely revamped in October 2024. + +[Email templates](https://amy.carpentries.org/emails/templates/) and [status of scheduled emails](https://amy.carpentries.org/emails/scheduled_emails/) can be viewed directly through the user interface under the "More" menu. Not all templated emails are currently active. + +Email recipients, schedule, and conditions are all set in the AMY codebase. + +From, Reply to, CC, and BCC are all set in the AMY interface. The email subject and body are also set in the AMY interface. These fields can all be modified by admin users at any time. + +#### Active emails (Workshops) + +??? abstract "Admin Signs Instructor Up for Workshop" + **[View in AMY](https://amy.carpentries.org/emails/template/53209bdb-fbd2-4620-988b-16703d38a083/)** + **Email description:** Send email to Instructor informing them the admin has signed them up as interested in teaching a workshop + **Sent to:** Instructor + **Date sent:** One hour after admin action + +??? abstract "Ask for website" + **[View in AMY](https://amy.carpentries.org/emails/template/8b29643c-d4f2-4b58-bc3c-5ebc4ff3d7b4/)** + **Email description:** Send email to workshop Hosts & Instructors reminding them to send us workshop website link + **Sent to:** Instructors and Hosts + **Date sent:** One month before event start + **Conditions:** Has administrator; Has at least one Instructor; Tagged SWC/DC/LC; Start date in future; not stalled/cancelled; Missing website + +??? abstract "Host-instructors introduction" + [View in AMY](https://amy.carpentries.org/emails/template/fb7286a4-e4a1-4d92-81b8-33e698170178/) + **Email description:** Send email introducing workshop Hosts & Instructors providing general inforamtion + **Sent to:** Instructors and Hosts + **Date sent:** One month before event start + **Conditions:** Centrally-Organised; Instructor recruitment closed; Not stalled/cancelled; Has at least two Instructors; Has host; Tagged SWC/DC/LC; At least seven days in future + +??? abstract "Instructor confirmed for workshop" + [View in AMY](https://amy.carpentries.org/emails/template/ba2d0130-d123-402d-97d5-8818b72af963/) + **Email description:** Emails instructor to confirm they have been assigned to a workshop + **Sent to:** Instructor + **Date sent:** One hour after conditions are met + **Conditions:** Instructor task created; Tagged SWC/DC/LC; Centrally-Organised; Start date in future + +??? abstract "Instructor declined for workshop" + [View in AMY](https://amy.carpentries.org/emails/template/be7a8e81-d4fb-47c1-b78f-1e5f809bc128/) + **Email description:** Emails instructor to inform them they have not been assigned to a workshop + **Sent to:** Instructor + **Date sent:** One hour after conditions are met + **Conditions:** Instructor declined in recruitment process + +??? abstract "Instructor Signs Up for Workshop" + [View in AMY](https://amy.carpentries.org/emails/template/43754daa-c312-41db-a0f6-00ac4f88bafd/) + **Email description:** Emails instructor to confirm they have expressed interest in a workshop + **Sent to:** Instructor + **Date sent:** One hour after conditions are met + **Conditions:** Instructor signs up in recruitment process + +??? abstract "New Self-Organised Workshop" + [View in AMY](https://amy.carpentries.org/emails/template/f967122e-55a8-483e-a571-c3dff43dd33e/) + **Email description:** Emails Self-Organised workshop host to confirm their submission has been accepted + **Sent to:** Workshop Host + **Date sent:** One hour after conditions are met + **Conditions:** Self organized submission form accepted + +??? abstract "Post Workshop 7 days" + [View in AMY](https://amy.carpentries.org/emails/template/4809378c-c1aa-4118-a3a7-cbc9586c686b/) + **Email description:** Follow up one week after Centrally-Organised workshop is complete + **Sent to:** Hosts and Instructors + **Date sent:** Later of seven days after workshop end or seven days from now + **Conditions:** Centrally-Organised; At least one host; At least one Instructor; Tagged SWC/DC/LC; + +??? abstract "Recruit helpers" + [View in AMY](https://amy.carpentries.org/emails/template/d1b20e74-a245-4c7d-9182-663f3acd8a48/) + **Email description:** Reminder to host to recruit helpers + **Sent to:** Hosts and Instructors + **Date sent:** 21 days before event + **Conditions:** Centrally-Organised; At least one host; At least 1 Instructor; Tagged SWC/DC/LC; Not stalled/cancelled; At least one host; At least one Instructor; No helpers; Start date at least 14 days in future + + + +#### Active emails (Instructor Training) + +??? abstract "Instructor Training (One month away)" + [View in AMY](https://amy.carpentries.org/emails/template/bafe53c4-e486-4a6e-8b5d-894790946913/) + **Email description:** Send Trainers information about upcoming Instructor Training + **Sent to:** Trainers + **Date sent:** One month before event start + **Conditions:** Tag TTT; At least 2 Trainers; Start date in future + +#### Active emails (Memberships) + +??? abstract "New / renewing membership starting (member onboarding)" + [View in AMY](https://amy.carpentries.org/emails/template/f1311aaf-c546-429b-930c-c36a5a8009fb/) + **Email description:** Onboarding message for new/renewing memberships + **Sent to:** Programmatic and billing contacts + **Date sent:** One month before membership start date or immediately (whichever is first) + **Conditions:** Membership roles exist + +#### Inactive emails + +* [Instructor Badge awarded](https://amy.carpentries.org/emails/template/35bab114-43eb-48d1-8744-b6e1a356b0f1/) +* [Instructor Training completed but not yet badged](https://amy.carpentries.org/emails/template/93f71266-60cb-4606-b4a6-b74266cfb04a/) +* [Person records are merged](https://amy.carpentries.org/emails/template/f043ec2e-ebdb-4b61-b09e-b69e31868b8f/) + +#### Managing scheduled emails + +When an email is scheduled, it will be listed in the relevant event or person page. It will also be listed on the main [Scheduled Emails page](https://amy.carpentries.org/emails/scheduled_emails/). + +Click on the listing for an individual scheduled email to see more information about it, to reschedule it, or to edit the recipients, subject, or body. ### Other Tasks From 4146d946587655394462b25d99ff3a49f73878b5 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Sun, 22 Dec 2024 18:45:17 +0100 Subject: [PATCH 09/46] feat(#2728): Implement tests for complex Instructor Declined From Workshop email --- amy/emails/actions/__init__.py | 4 + ..._declined_from_workshop_cancel_receiver.py | 293 +++++++++++++ ...ructor_declined_from_workshop_receiver.py} | 11 +- ...tructor_declined_from_workshop_strategy.py | 290 +++++++++++++ ..._declined_from_workshop_update_receiver.py | 385 ++++++++++++++++++ 5 files changed, 978 insertions(+), 5 deletions(-) create mode 100644 amy/emails/tests/actions/test_instructor_declined_from_workshop_cancel_receiver.py rename amy/emails/tests/actions/{test_instructor_declined_from_workshop.py => test_instructor_declined_from_workshop_receiver.py} (97%) create mode 100644 amy/emails/tests/actions/test_instructor_declined_from_workshop_strategy.py create mode 100644 amy/emails/tests/actions/test_instructor_declined_from_workshop_update_receiver.py diff --git a/amy/emails/actions/__init__.py b/amy/emails/actions/__init__.py index 48ce267ed..cc9f7c836 100644 --- a/amy/emails/actions/__init__.py +++ b/amy/emails/actions/__init__.py @@ -14,10 +14,14 @@ ) from emails.actions.instructor_badge_awarded import instructor_badge_awarded_receiver from emails.actions.instructor_confirmed_for_workshop import ( + instructor_confirmed_for_workshop_cancel_receiver, instructor_confirmed_for_workshop_receiver, + instructor_confirmed_for_workshop_update_receiver, ) from emails.actions.instructor_declined_from_workshop import ( + instructor_declined_from_workshop_cancel_receiver, instructor_declined_from_workshop_receiver, + instructor_declined_from_workshop_update_receiver, ) from emails.actions.instructor_signs_up_for_workshop import ( instructor_signs_up_for_workshop_receiver, 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 new file mode 100644 index 000000000..4c50f66fd --- /dev/null +++ b/amy/emails/tests/actions/test_instructor_declined_from_workshop_cancel_receiver.py @@ -0,0 +1,293 @@ +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 communityroles.models import CommunityRole, CommunityRoleConfig +from emails.actions.instructor_declined_from_workshop import ( + instructor_declined_from_workshop_cancel_receiver, + instructor_declined_from_workshop_strategy, + run_instructor_declined_from_workshop_strategy, +) +from emails.models import EmailTemplate, ScheduledEmail, ScheduledEmailStatus +from emails.signals import ( + INSTRUCTOR_DECLINED_FROM_WORKSHOP_SIGNAL_NAME, + instructor_declined_from_workshop_cancel_signal, +) +from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup +from workshops.models import Event, Organization, Person, Role, Tag, Task +from workshops.tests.base import TestBase + + +class TestInstructorDeclinedFromWorkshopCancelReceiver(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 + ) + 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: + return EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_DECLINED_FROM_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: MagicMock) -> None: + # Arrange + request = RequestFactory().get("/") + with self.settings(FLAGS={"EMAIL_MODULE": [("boolean", False)]}): + # Act + instructor_declined_from_workshop_cancel_receiver(None, request=request) + # Assert + mock_logger.debug.assert_called_once_with( + "EMAIL_MODULE feature flag not set, skipping " + "instructor_declined_from_workshop_cancel" + ) + + def test_receiver_connected_to_signal(self) -> None: + # Arrange + original_receivers = instructor_declined_from_workshop_cancel_signal.receivers[ + : + ] + + # Act + # attempt to connect the receiver + instructor_declined_from_workshop_cancel_signal.connect( + instructor_declined_from_workshop_cancel_receiver + ) + new_receivers = instructor_declined_from_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.signup, + ) + + # Act + with patch( + "emails.actions.base_action.messages_action_cancelled" + ) as mock_messages_action_cancelled: + instructor_declined_from_workshop_cancel_signal.send( + sender=self.signup, + request=request, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, + ) + + # Assert + scheduled_email = ScheduledEmail.objects.get(template=template) + mock_messages_action_cancelled.assert_called_once_with( + request, + INSTRUCTOR_DECLINED_FROM_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.signup, + ) + + # Act + with patch( + "emails.actions.base_action.EmailController.cancel_email" + ) as mock_cancel_email: + instructor_declined_from_workshop_cancel_signal.send( + sender=self.signup, + request=request, + 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_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.signup, + ) + scheduled_email2 = ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.signup, + ) + + # Act + with patch( + "emails.actions.base_action.EmailController.cancel_email" + ) as mock_cancel_email: + instructor_declined_from_workshop_cancel_signal.send( + sender=self.signup, + request=request, + 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_cancel_email.assert_has_calls( + [ + call( + scheduled_email=scheduled_email1, + author=None, + ), + call( + scheduled_email=scheduled_email2, + author=None, + ), + ] + ) + + +class TestInstructorDeclinedFromWorkshopCancelIntegration(TestBase): + @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="d" + ) + + template = EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_DECLINED_FROM_WORKSHOP_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**.", + ) + + request = RequestFactory().get("/") + with patch( + "emails.actions.base_action.messages_action_scheduled" + ) as mock_action_scheduled: + run_instructor_declined_from_workshop_strategy( + instructor_declined_from_workshop_strategy(signup), + request, + 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("instructorrecruitmentsignup_changestate", args=[signup.pk]) + payload = {"action": "confirm"} + + # Act + rv = self.client.post(url, payload) + + # Assert + 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_declined_from_workshop.py b/amy/emails/tests/actions/test_instructor_declined_from_workshop_receiver.py similarity index 97% rename from amy/emails/tests/actions/test_instructor_declined_from_workshop.py rename to amy/emails/tests/actions/test_instructor_declined_from_workshop_receiver.py index 36226cb14..d93c1146f 100644 --- a/amy/emails/tests/actions/test_instructor_declined_from_workshop.py +++ b/amy/emails/tests/actions/test_instructor_declined_from_workshop_receiver.py @@ -8,7 +8,10 @@ from emails.actions import instructor_declined_from_workshop_receiver from emails.models import EmailTemplate, ScheduledEmail from emails.schemas import ContextModel, ToHeaderModel -from emails.signals import instructor_declined_from_workshop_signal +from emails.signals import ( + INSTRUCTOR_DECLINED_FROM_WORKSHOP_SIGNAL_NAME, + instructor_declined_from_workshop_signal, +) from emails.utils import api_model_url from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup from workshops.models import Event, Organization, Person, Tag @@ -228,9 +231,7 @@ class TestInstructorDeclinedFromWorkshopReceiverIntegration(TestBase): "EMAIL_MODULE": [("boolean", True)], } ) - def test_integration( - self, - ) -> None: + def test_integration(self) -> None: # Arrange self._setUpRoles() self._setUpTags() @@ -269,7 +270,7 @@ def test_integration( template = EmailTemplate.objects.create( name="Test Email Template", - signal=instructor_declined_from_workshop_signal.signal_name, + signal=INSTRUCTOR_DECLINED_FROM_WORKSHOP_SIGNAL_NAME, from_header="workshops@carpentries.org", cc_header=["team@carpentries.org"], bcc_header=[], 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 new file mode 100644 index 000000000..0390bc4af --- /dev/null +++ b/amy/emails/tests/actions/test_instructor_declined_from_workshop_strategy.py @@ -0,0 +1,290 @@ +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_declined_from_workshop import ( + instructor_declined_from_workshop_strategy, + run_instructor_declined_from_workshop_strategy, +) +from emails.models import EmailTemplate, ScheduledEmail, ScheduledEmailStatus +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 + + +class TestInstructorDeclinedFromWorkshopStrategy(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 + ) + self.recruitment = InstructorRecruitment.objects.create( + event=self.event, notes="Test notes" + ) + self.signup = InstructorRecruitmentSignup.objects.create( + recruitment=self.recruitment, person=self.person, state="d" + ) + + def test_strategy_create(self) -> None: + # Arrange + + # Act + result = instructor_declined_from_workshop_strategy(self.signup) + + # Assert + self.assertEqual(result, StrategyEnum.CREATE) + + def test_strategy_update(self) -> None: + # Arrange + template = EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_DECLINED_FROM_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.signup, + ) + + # Act + result = instructor_declined_from_workshop_strategy(self.signup) + + # Assert + self.assertEqual(result, StrategyEnum.UPDATE) + + def test_strategy_cancel(self) -> None: + # Arrange + self.signup.state = "a" + self.signup.save() + + template = EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_DECLINED_FROM_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.signup, + ) + + # Act + result = instructor_declined_from_workshop_strategy(self.signup) + + # Assert + self.assertEqual(result, StrategyEnum.CANCEL) + + +class TestRunInstructorDeclinedFromWorkshopStrategy(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 + ) + self.recruitment = InstructorRecruitment.objects.create( + event=self.event, notes="Test notes" + ) + self.signup = InstructorRecruitmentSignup.objects.create( + recruitment=self.recruitment, person=self.person + ) + + @patch( + "emails.actions.instructor_declined_from_workshop." + "instructor_declined_from_workshop_signal" + ) + def test_strategy_calls_create_signal( + self, + mock_instructor_declined_from_workshop_signal, + ) -> None: + # Arrange + strategy = StrategyEnum.CREATE + request = RequestFactory().get("/") + + # Act + run_instructor_declined_from_workshop_strategy( + strategy, + request, + 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_declined_from_workshop_signal.send.assert_called_once_with( + sender=self.signup, + request=request, + 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.instructor_declined_from_workshop." + "instructor_declined_from_workshop_update_signal" + ) + def test_strategy_calls_update_signal( + self, + mock_update_signal, + ) -> None: + # Arrange + strategy = StrategyEnum.UPDATE + request = RequestFactory().get("/") + + # Act + run_instructor_declined_from_workshop_strategy( + strategy, + request, + 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_update_signal.send.assert_called_once_with( + sender=self.signup, + request=request, + 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.instructor_declined_from_workshop." + "instructor_declined_from_workshop_cancel_signal" + ) + def test_strategy_calls_cancel_signal( + self, + mock_cancel_signal, + ) -> None: + # Arrange + strategy = StrategyEnum.CANCEL + request = RequestFactory().get("/") + + # Act + run_instructor_declined_from_workshop_strategy( + strategy, + request, + 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_cancel_signal.send.assert_called_once_with( + sender=self.signup, + request=request, + 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") + @patch( + "emails.actions.instructor_declined_from_workshop." + "instructor_declined_from_workshop_signal" + ) + @patch( + "emails.actions.instructor_declined_from_workshop." + "instructor_declined_from_workshop_update_signal" + ) + @patch( + "emails.actions.instructor_declined_from_workshop." + "instructor_declined_from_workshop_cancel_signal" + ) + def test_invalid_strategy_no_signal_called( + self, + mock_instructor_declined_from_workshop_cancel_signal, + mock_instructor_declined_from_workshop_update_signal, + mock_instructor_declined_from_workshop_signal, + mock_logger, + ) -> None: + # Arrange + strategy = StrategyEnum.NOOP + request = RequestFactory().get("/") + + # Act + run_instructor_declined_from_workshop_strategy( + strategy, + request, + 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_declined_from_workshop_signal.send.assert_not_called() + mock_instructor_declined_from_workshop_update_signal.send.assert_not_called() + mock_instructor_declined_from_workshop_cancel_signal.send.assert_not_called() + mock_logger.debug.assert_called_once_with( + f"Strategy {strategy} for {self.signup} 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_declined_from_workshop_strategy( + strategy, + request, + 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_declined_from_workshop_update_receiver.py b/amy/emails/tests/actions/test_instructor_declined_from_workshop_update_receiver.py new file mode 100644 index 000000000..492732394 --- /dev/null +++ b/amy/emails/tests/actions/test_instructor_declined_from_workshop_update_receiver.py @@ -0,0 +1,385 @@ +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 communityroles.models import CommunityRole, CommunityRoleConfig +from emails.actions.instructor_declined_from_workshop import ( + instructor_declined_from_workshop_strategy, + instructor_declined_from_workshop_update_receiver, + run_instructor_declined_from_workshop_strategy, +) +from emails.models import ( + EmailTemplate, + ScheduledEmail, + ScheduledEmailLog, + ScheduledEmailStatus, +) +from emails.schemas import ContextModel, ToHeaderModel +from emails.signals import ( + INSTRUCTOR_DECLINED_FROM_WORKSHOP_SIGNAL_NAME, + instructor_declined_from_workshop_update_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.tests.base import TestBase + + +class TestInstructorDeclinedFromWorkshopUpdateReceiver(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 + ) + 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: + return EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_DECLINED_FROM_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_declined_from_workshop_update_receiver(None, request=request) + # Assert + mock_logger.debug.assert_called_once_with( + "EMAIL_MODULE feature flag not set, skipping " + "instructor_declined_from_workshop_update" + ) + + def test_receiver_connected_to_signal(self) -> None: + # Arrange + original_receivers = instructor_declined_from_workshop_update_signal.receivers[ + : + ] + + # Act + # attempt to connect the receiver + instructor_declined_from_workshop_update_signal.connect( + instructor_declined_from_workshop_update_receiver + ) + new_receivers = instructor_declined_from_workshop_update_signal.receivers[:] + + # Assert + # the same receiver list means this receiver has already been connected + self.assertEqual(original_receivers, new_receivers) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + def test_action_triggered(self) -> None: + # Arrange + request = RequestFactory().get("/") + + template = self.setUpEmailTemplate() + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.signup, + ) + + # Act + with patch( + "emails.actions.base_action.messages_action_updated" + ) as mock_messages_action_updated: + instructor_declined_from_workshop_update_signal.send( + sender=self.signup, + request=request, + person_id=self.person.pk, + event_id=self.event.pk, + instructor_recruitment_id=self.recruitment.pk, + instructor_recruitment_signup_id=self.signup.pk, + ) + + # Assert + scheduled_email = ScheduledEmail.objects.get(template=template) + mock_messages_action_updated.assert_called_once_with( + request, + INSTRUCTOR_DECLINED_FROM_WORKSHOP_SIGNAL_NAME, + scheduled_email, + ) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @patch("emails.actions.base_action.messages_action_updated") + @patch("emails.actions.instructor_declined_from_workshop.immediate_action") + def test_email_updated( + self, + mock_immediate_action: MagicMock, + mock_messages_action_updated: MagicMock, + ) -> None: + # Arrange + request = RequestFactory().get("/") + template = self.setUpEmailTemplate() + scheduled_email = ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.signup, + ) + scheduled_at = datetime(2024, 12, 22, 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_declined_from_workshop_update_signal.send( + sender=self.signup, + request=request, + 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_update_scheduled_email.assert_called_once_with( + scheduled_email=scheduled_email, + context_json=ContextModel( + { + "person": api_model_url("person", self.person.pk), + "event": api_model_url("event", self.event.pk), + "instructor_recruitment_signup": api_model_url( + "instructorrecruitmentsignup", self.signup.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=self.signup, + 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_DECLINED_FROM_WORKSHOP_SIGNAL_NAME + + # Act + instructor_declined_from_workshop_update_signal.send( + sender=self.signup, + request=request, + 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_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={signup!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_DECLINED_FROM_WORKSHOP_SIGNAL_NAME + template = self.setUpEmailTemplate() + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.signup, + ) + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.signup, + ) + + # Act + instructor_declined_from_workshop_update_signal.send( + sender=self.signup, + request=request, + 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_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={self.signup!r}. Can't update them." + ) + + @override_settings(FLAGS={"EMAIL_MODULE": [("boolean", True)]}) + @patch("emails.actions.base_action.messages_missing_recipients") + def test_missing_recipients( + self, mock_messages_missing_recipients: MagicMock + ) -> None: + # Arrange + request = RequestFactory().get("/") + template = self.setUpEmailTemplate() + ScheduledEmail.objects.create( + template=template, + scheduled_at=datetime.now(UTC), + to_header=[], + cc_header=[], + bcc_header=[], + state=ScheduledEmailStatus.SCHEDULED, + generic_relation=self.signup, + ) + signal = INSTRUCTOR_DECLINED_FROM_WORKSHOP_SIGNAL_NAME + self.person.email = "" + self.person.save() + + # Act + instructor_declined_from_workshop_update_signal.send( + sender=self.signup, + request=request, + 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_messages_missing_recipients.assert_called_once_with(request, signal) + + +class TestInstructorDeclinedFromWorkshopUpdateIntegration(TestBase): + @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="d" + ) + + template = EmailTemplate.objects.create( + name="Test Email Template", + signal=INSTRUCTOR_DECLINED_FROM_WORKSHOP_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**.", + ) + + request = RequestFactory().get("/") + with patch( + "emails.actions.base_action.messages_action_scheduled" + ) as mock_action_scheduled: + run_instructor_declined_from_workshop_strategy( + instructor_declined_from_workshop_strategy(signup), + request, + 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("instructorrecruitmentsignup_changestate", args=[signup.pk]) + payload = {"action": "decline"} + + # Act + rv = self.client.post(url, payload) + + # Assert + mock_action_scheduled.assert_called_once() + self.assertEqual(rv.status_code, 302) + scheduled_email.refresh_from_db() + self.assertEqual(scheduled_email.state, ScheduledEmailStatus.SCHEDULED) + # 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) From 1199770f849ce7281b2fe67d310b0be743757f7c Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Mon, 30 Dec 2024 21:51:49 +0100 Subject: [PATCH 10/46] feat(#2044): Update help text for Event host, sponsor, membership fields --- amy/workshops/forms.py | 3 +- ...nt_host_alter_event_membership_and_more.py | 46 +++++++++++++++++++ amy/workshops/models.py | 9 ++-- 3 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 amy/workshops/migrations/0272_alter_event_host_alter_event_membership_and_more.py diff --git a/amy/workshops/forms.py b/amy/workshops/forms.py index 6050273d7..fd4e220b2 100644 --- a/amy/workshops/forms.py +++ b/amy/workshops/forms.py @@ -383,8 +383,7 @@ class EventForm(forms.ModelForm): sponsor = forms.ModelChoiceField( label="Organiser", required=True, - help_text=Event._meta.get_field("sponsor").help_text - + " Previously called 'Sponsor'.", + help_text=Event._meta.get_field("sponsor").help_text, queryset=Organization.objects.all(), widget=ModelSelect2Widget(data_view="organization-lookup"), ) diff --git a/amy/workshops/migrations/0272_alter_event_host_alter_event_membership_and_more.py b/amy/workshops/migrations/0272_alter_event_host_alter_event_membership_and_more.py new file mode 100644 index 000000000..0085c5d27 --- /dev/null +++ b/amy/workshops/migrations/0272_alter_event_host_alter_event_membership_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.16 on 2024-12-30 20:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("workshops", "0271_alter_workshoprequest_administrative_fee"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="host", + field=models.ForeignKey( + help_text="The institution where the workshop is taking place (or would take place for online workshops).", + on_delete=django.db.models.deletion.PROTECT, + related_name="hosted_events", + to="workshops.organization", + ), + ), + migrations.AlterField( + model_name="event", + name="membership", + field=models.ForeignKey( + blank=True, + help_text="The membership this workshop should count towards.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="workshops.membership", + ), + ), + migrations.AlterField( + model_name="event", + name="sponsor", + field=models.ForeignKey( + help_text="The institution responsible for organizing and funding the workshop (often the same as Host).", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="sponsored_events", + to="workshops.organization", + ), + ), + ] diff --git a/amy/workshops/models.py b/amy/workshops/models.py index 3d4e2b94e..bcbf74d01 100644 --- a/amy/workshops/models.py +++ b/amy/workshops/models.py @@ -1307,22 +1307,25 @@ class Event(AssignmentMixin, RQJobsMixin, models.Model): null=False, blank=False, related_name="hosted_events", - help_text="Organisation hosting the event.", + help_text="The institution where the workshop is taking place (or would take " + "place for online workshops).", ) + # Currently this is organiser sponsor = models.ForeignKey( Organization, on_delete=models.PROTECT, null=True, blank=False, related_name="sponsored_events", - help_text="Institution that is funding or organising the workshop.", + help_text="The institution responsible for organizing and funding the workshop " + "(often the same as Host).", ) membership = models.ForeignKey( Membership, on_delete=models.PROTECT, null=True, blank=True, - help_text="A membership this event should be counted towards.", + help_text="The membership this workshop should count towards.", ) administrator = models.ForeignKey( Organization, From 64b485374e497c828a7c0d5ac4ba414bd6500dbf Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Mon, 30 Dec 2024 22:25:14 +0100 Subject: [PATCH 11/46] feat(#2094): Remove Task url, title fields --- amy/api/v2/serializers.py | 2 -- amy/workshops/forms.py | 2 -- amy/workshops/management/commands/fake_database.py | 6 ------ ...ership_and_more.py => 0272_multiple_changes.py} | 14 ++++++++++++++ amy/workshops/models.py | 6 +----- docs/amy_database_structure.md | 1 - 6 files changed, 15 insertions(+), 16 deletions(-) rename amy/workshops/migrations/{0272_alter_event_host_alter_event_membership_and_more.py => 0272_multiple_changes.py} (81%) diff --git a/amy/api/v2/serializers.py b/amy/api/v2/serializers.py index 87f054776..f4fb72b8a 100644 --- a/amy/api/v2/serializers.py +++ b/amy/api/v2/serializers.py @@ -304,8 +304,6 @@ class Meta: "event", "person", "role", - "title", - "url", "seat_membership", "seat_public", "seat_open_training", diff --git a/amy/workshops/forms.py b/amy/workshops/forms.py index fd4e220b2..cdc112e7c 100644 --- a/amy/workshops/forms.py +++ b/amy/workshops/forms.py @@ -674,8 +674,6 @@ class Meta: "event", "person", "role", - "title", - "url", "seat_membership", "seat_public", "seat_open_training", diff --git a/amy/workshops/management/commands/fake_database.py b/amy/workshops/management/commands/fake_database.py index b24db005a..cb7277f0f 100644 --- a/amy/workshops/management/commands/fake_database.py +++ b/amy/workshops/management/commands/fake_database.py @@ -619,12 +619,6 @@ def fake_tasks(self, count=120): event=event, person=person, role=role, - title=( - self.faker.sentence(nb_words=4, variable_nb_words=True) - if randbool(0.2) - else "" - ), - url=self.faker.url() if randbool(0.2) else "", ) def fake_unmatched_training_requests(self, count=20): diff --git a/amy/workshops/migrations/0272_alter_event_host_alter_event_membership_and_more.py b/amy/workshops/migrations/0272_multiple_changes.py similarity index 81% rename from amy/workshops/migrations/0272_alter_event_host_alter_event_membership_and_more.py rename to amy/workshops/migrations/0272_multiple_changes.py index 0085c5d27..5191a7337 100644 --- a/amy/workshops/migrations/0272_alter_event_host_alter_event_membership_and_more.py +++ b/amy/workshops/migrations/0272_multiple_changes.py @@ -11,6 +11,7 @@ class Migration(migrations.Migration): ] operations = [ + # #2044 migrations.AlterField( model_name="event", name="host", @@ -43,4 +44,17 @@ class Migration(migrations.Migration): to="workshops.organization", ), ), + # #2094 + migrations.AlterUniqueTogether( + name="task", + unique_together={("event", "person", "role")}, + ), + migrations.RemoveField( + model_name="task", + name="title", + ), + migrations.RemoveField( + model_name="task", + name="url", + ), ] diff --git a/amy/workshops/models.py b/amy/workshops/models.py index bcbf74d01..bd82f6b44 100644 --- a/amy/workshops/models.py +++ b/amy/workshops/models.py @@ -1677,8 +1677,6 @@ class Task(RQJobsMixin, models.Model): event = models.ForeignKey(Event, on_delete=models.PROTECT) person = models.ForeignKey(Person, on_delete=models.PROTECT) role = models.ForeignKey(Role, on_delete=models.PROTECT) - title = models.CharField(max_length=STR_LONG, blank=True) - url = models.URLField(blank=True, verbose_name="URL") seat_membership = models.ForeignKey( Membership, on_delete=models.PROTECT, @@ -1714,12 +1712,10 @@ class Task(RQJobsMixin, models.Model): objects = TaskManager() class Meta: - unique_together = ("event", "person", "role", "url") + unique_together = ("event", "person", "role") ordering = ("role__name", "event") def __str__(self): - if self.title: - return self.title return "{0}/{1}={2}".format(self.event, self.person, self.role) def get_absolute_url(self): diff --git a/docs/amy_database_structure.md b/docs/amy_database_structure.md index b2edad063..a2325ca38 100644 --- a/docs/amy_database_structure.md +++ b/docs/amy_database_structure.md @@ -298,7 +298,6 @@ The primary tables used in AMY (that will appear in most queries) are those that * `seat_membership_id` Used for Instructor Training Learner role only. An integer representing the membership this seat was assigned to. * `seat_public` Used for Instructor Training Learner role only. Determines if the seat counts as public or in-house for the specific membership. * `seat_open_training` Used for Instructor Training Learner role only. Boolean field noting whether this was an open (non-member) training seat. - * `title` and`url` are not used. ### Tags From 09645437a1b734b8a95c784167c10bb6bee34360 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Mon, 30 Dec 2024 22:25:32 +0100 Subject: [PATCH 12/46] feat(#2094): Fix tests broken after the changes --- amy/autoemails/actions.py | 2 +- .../test_instructorshostintroductionaction.py | 14 ++--- amy/workshops/tests/test_tasks.py | 51 ------------------- 3 files changed, 8 insertions(+), 59 deletions(-) diff --git a/amy/autoemails/actions.py b/amy/autoemails/actions.py index 5c5646d3a..177b2ef0c 100644 --- a/amy/autoemails/actions.py +++ b/amy/autoemails/actions.py @@ -788,7 +788,7 @@ def get_additional_context(self, objects, *args, **kwargs): task_emails = [t.person.email for t in tasks] contacts = event.contact.split(TAG_SEPARATOR) - context["all_emails"] = list(filter(bool, task_emails + contacts)) + context["all_emails"] = sorted(filter(bool, task_emails + contacts)) context["assignee"] = ( event.assigned_to.full_name if event.assigned_to else "Regional Coordinator" diff --git a/amy/autoemails/tests/test_instructorshostintroductionaction.py b/amy/autoemails/tests/test_instructorshostintroductionaction.py index a367a8631..130aae65e 100644 --- a/amy/autoemails/tests/test_instructorshostintroductionaction.py +++ b/amy/autoemails/tests/test_instructorshostintroductionaction.py @@ -250,13 +250,13 @@ def testContext(self): supporting_instructor2=supporting_instructor2.person, hosts=[host.person], all_emails=[ - "hp@magic.uk", - "rw@magic.uk", "hg@magic.uk", - "peter@webslinger.net", + "hp@magic.uk", "me@stark.com", - "test@hogwart.com", + "peter@webslinger.net", + "rw@magic.uk", "test2@magic.uk", + "test@hogwart.com", ], assignee="Regional Coordinator", tags=["automated-email", "LC"], @@ -314,11 +314,11 @@ def testRecipients(self): self.assertEqual( email.to, [ + "hg@magic.uk", "hp@magic.uk", "rw@magic.uk", - "hg@magic.uk", - "test@hogwart.com", "test2@magic.uk", + "test@hogwart.com", ], ) @@ -409,6 +409,6 @@ def test_drop_empty_contacts(self): ) ctx = a.get_additional_context(objects=dict(event=e)) - expected = ["rw@magic.uk", "hg@magic.uk"] + expected = ["hg@magic.uk", "rw@magic.uk"] self.assertEqual(ctx["all_emails"], expected) self.assertEqual(a.all_recipients(), "rw@magic.uk, hg@magic.uk") diff --git a/amy/workshops/tests/test_tasks.py b/amy/workshops/tests/test_tasks.py index 5f3a40ce3..17843bd36 100644 --- a/amy/workshops/tests/test_tasks.py +++ b/amy/workshops/tests/test_tasks.py @@ -126,65 +126,14 @@ def test_task_detail_view_reachable_from_event_person_and_role_of_task(self): response = self.client.get(reverse("task_details", args=[str(correct_task.id)])) assert response.context["task"].pk == correct_task.pk - def test_add_task_with_correct_url(self): - """Ensure that task can be saved with correct URL field""" - task = self.fixtures["test_task_1"] - payload = { - "event": task.event.pk, - "person": task.person.pk, - "role": task.role.pk, - "title": "Task title", - "url": "http://example.org", - } - response = self.client.post( - reverse("task_edit", kwargs={"task_id": task.pk}), payload, follow=True - ) - self.assertRedirects( - response, reverse("task_details", kwargs={"task_id": task.pk}) - ) - task.refresh_from_db() - self.assertEqual(task.url, "http://example.org") - self.assertEqual(response.context["task"].url, "http://example.org") - - def test_add_task_with_incorrect_url(self): - """Ensure that a task object cannot be saved with incorrect URL field""" - task = self.fixtures["test_task_1"] - payload = { - "event": task.event.pk, - "person": task.person.pk, - "role": task.role.pk, - "title": "Task title", - "url": "htp://example.org", - } - response = self.client.post( - reverse("task_edit", kwargs={"task_id": task.pk}), - payload, - ) - self.assertEqual(response.status_code, 200) - task.refresh_from_db() - self.assertEqual(task.url, "") - def test_add_duplicate_task(self): - """Ensure that duplicate tasks with empty url field cannot exist""" - task_1 = self.fixtures["test_task_1"] - with self.assertRaises(IntegrityError): - Task.objects.create( - event=task_1.event, - person=task_1.person, - role=task_1.role, - ) - - def test_add_duplicate_task_with_url(self): """Ensure that duplicate tasks cannot exist""" task_1 = self.fixtures["test_task_1"] - task_1.url = "http://example.org" - task_1.save() with self.assertRaises(IntegrityError): Task.objects.create( event=task_1.event, person=task_1.person, role=task_1.role, - url=task_1.url, ) def test_task_edit_view_reachable_from_event_person_and_role_of_task(self): From c33004c96264a696428bf487ac83e0e05d28d701 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Mon, 30 Dec 2024 22:54:27 +0100 Subject: [PATCH 13/46] feat(#2726): Add BlueSky user ID field to Person, allow to change it, update tests to reflect that, update docs --- amy/api/v1/serializers.py | 2 ++ amy/api/v1/tests/test_export.py | 1 + amy/api/v2/serializers.py | 1 + amy/dashboard/forms.py | 1 + amy/dashboard/tests/test_autoupdate_profile.py | 1 + .../test_ask_for_website_cancel_receiver.py | 1 + .../actions/test_ask_for_website_receiver.py | 2 ++ .../test_ask_for_website_update_receiver.py | 1 + ..._instructors_introduction_cancel_receiver.py | 2 ++ ...st_host_instructors_introduction_receiver.py | 2 ++ ..._instructors_introduction_update_receiver.py | 2 ++ ..._instructor_badge_awarded_cancel_receiver.py | 1 + .../test_instructor_badge_awarded_receiver.py | 1 + ...or_confirmed_for_workshop_cancel_receiver.py | 1 + ...or_confirmed_for_workshop_update_receiver.py | 1 + ...ctor_training_approaching_cancel_receiver.py | 2 ++ ..._instructor_training_approaching_receiver.py | 2 ++ ...ctor_training_approaching_update_receiver.py | 2 ++ ...training_completed_not_badged_integration.py | 3 +++ amy/emails/tests/actions/test_persons_merged.py | 3 +++ .../test_post_workshop_7days_cancel_receiver.py | 2 ++ .../test_post_workshop_7days_receiver.py | 2 ++ .../test_post_workshop_7days_update_receiver.py | 2 ++ .../test_recruit_helpers_cancel_receiver.py | 2 ++ .../actions/test_recruit_helpers_receiver.py | 2 ++ .../test_recruit_helpers_update_receiver.py | 2 ++ amy/templates/workshops/persons_merge.html | 5 +++++ amy/workshops/forms.py | 6 ++++++ .../management/commands/fake_database.py | 3 +++ .../migrations/0272_multiple_changes.py | 12 ++++++++++++ amy/workshops/models.py | 9 +++++++++ amy/workshops/tests/base.py | 1 + amy/workshops/tests/test_person.py | 6 ++++++ amy/workshops/views.py | 1 + docs/amy_database_structure.md | 1 + docs/users_guide/community_index.md | 17 +++++++++-------- 36 files changed, 97 insertions(+), 8 deletions(-) diff --git a/amy/api/v1/serializers.py b/amy/api/v1/serializers.py index e30935102..f472597bf 100644 --- a/amy/api/v1/serializers.py +++ b/amy/api/v1/serializers.py @@ -119,6 +119,7 @@ class Meta: "country", "github", "twitter", + "bluesky", "url", "orcid", "affiliation", @@ -492,6 +493,7 @@ class Meta: "country", "github", "twitter", + "bluesky", "url", "orcid", "affiliation", diff --git a/amy/api/v1/tests/test_export.py b/amy/api/v1/tests/test_export.py index 1cb591dbc..685ad86d0 100644 --- a/amy/api/v1/tests/test_export.py +++ b/amy/api/v1/tests/test_export.py @@ -297,6 +297,7 @@ def test_all_related_objects_shown(self): "gender", "github", "twitter", + "bluesky", "url", "user_notes", "affiliation", diff --git a/amy/api/v2/serializers.py b/amy/api/v2/serializers.py index f4fb72b8a..8bc69a874 100644 --- a/amy/api/v2/serializers.py +++ b/amy/api/v2/serializers.py @@ -245,6 +245,7 @@ class Meta: "airport", "github", "twitter", + "bluesky", "url", "user_notes", "affiliation", diff --git a/amy/dashboard/forms.py b/amy/dashboard/forms.py index 2ce25dafe..e31144151 100644 --- a/amy/dashboard/forms.py +++ b/amy/dashboard/forms.py @@ -84,6 +84,7 @@ class Meta: "airport", "github", "twitter", + "bluesky", "url", "username", "affiliation", diff --git a/amy/dashboard/tests/test_autoupdate_profile.py b/amy/dashboard/tests/test_autoupdate_profile.py index e520cee33..5c8e6c15d 100644 --- a/amy/dashboard/tests/test_autoupdate_profile.py +++ b/amy/dashboard/tests/test_autoupdate_profile.py @@ -62,6 +62,7 @@ def test_update_profile(self): "airport": self.airport_0_0.pk, "github": "changed", "twitter": "", + "bluesky": "", "url": "", "username": "changed", "affiliation": "", diff --git a/amy/emails/tests/actions/test_ask_for_website_cancel_receiver.py b/amy/emails/tests/actions/test_ask_for_website_cancel_receiver.py index c82fbb16a..95b5064af 100644 --- a/amy/emails/tests/actions/test_ask_for_website_cancel_receiver.py +++ b/amy/emails/tests/actions/test_ask_for_website_cancel_receiver.py @@ -235,6 +235,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", diff --git a/amy/emails/tests/actions/test_ask_for_website_receiver.py b/amy/emails/tests/actions/test_ask_for_website_receiver.py index 7cf7e2832..1ac88af7e 100644 --- a/amy/emails/tests/actions/test_ask_for_website_receiver.py +++ b/amy/emails/tests/actions/test_ask_for_website_receiver.py @@ -223,6 +223,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -240,6 +241,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_ask_for_website_update_receiver.py b/amy/emails/tests/actions/test_ask_for_website_update_receiver.py index 02bca334a..dceb7f584 100644 --- a/amy/emails/tests/actions/test_ask_for_website_update_receiver.py +++ b/amy/emails/tests/actions/test_ask_for_website_update_receiver.py @@ -315,6 +315,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", diff --git a/amy/emails/tests/actions/test_host_instructors_introduction_cancel_receiver.py b/amy/emails/tests/actions/test_host_instructors_introduction_cancel_receiver.py index 812f10276..1dbaae463 100644 --- a/amy/emails/tests/actions/test_host_instructors_introduction_cancel_receiver.py +++ b/amy/emails/tests/actions/test_host_instructors_introduction_cancel_receiver.py @@ -255,6 +255,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -272,6 +273,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_host_instructors_introduction_receiver.py b/amy/emails/tests/actions/test_host_instructors_introduction_receiver.py index b507a5844..0ecf2d1d0 100644 --- a/amy/emails/tests/actions/test_host_instructors_introduction_receiver.py +++ b/amy/emails/tests/actions/test_host_instructors_introduction_receiver.py @@ -253,6 +253,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -270,6 +271,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_host_instructors_introduction_update_receiver.py b/amy/emails/tests/actions/test_host_instructors_introduction_update_receiver.py index 374dd8037..2d96bb474 100644 --- a/amy/emails/tests/actions/test_host_instructors_introduction_update_receiver.py +++ b/amy/emails/tests/actions/test_host_instructors_introduction_update_receiver.py @@ -348,6 +348,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -365,6 +366,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_instructor_badge_awarded_cancel_receiver.py b/amy/emails/tests/actions/test_instructor_badge_awarded_cancel_receiver.py index 218cfea10..1d5450018 100644 --- a/amy/emails/tests/actions/test_instructor_badge_awarded_cancel_receiver.py +++ b/amy/emails/tests/actions/test_instructor_badge_awarded_cancel_receiver.py @@ -207,6 +207,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", diff --git a/amy/emails/tests/actions/test_instructor_badge_awarded_receiver.py b/amy/emails/tests/actions/test_instructor_badge_awarded_receiver.py index 9b2707327..87c983197 100644 --- a/amy/emails/tests/actions/test_instructor_badge_awarded_receiver.py +++ b/amy/emails/tests/actions/test_instructor_badge_awarded_receiver.py @@ -190,6 +190,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", 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 f104b22e0..6423f7f71 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 @@ -251,6 +251,7 @@ def test_integration(self) -> None: 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", 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 89eb8d627..5f7775940 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 @@ -339,6 +339,7 @@ def test_integration(self) -> None: 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", diff --git a/amy/emails/tests/actions/test_instructor_training_approaching_cancel_receiver.py b/amy/emails/tests/actions/test_instructor_training_approaching_cancel_receiver.py index c6ae13e14..39e578a34 100644 --- a/amy/emails/tests/actions/test_instructor_training_approaching_cancel_receiver.py +++ b/amy/emails/tests/actions/test_instructor_training_approaching_cancel_receiver.py @@ -250,6 +250,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -267,6 +268,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_instructor_training_approaching_receiver.py b/amy/emails/tests/actions/test_instructor_training_approaching_receiver.py index 7789a3c3d..610153726 100644 --- a/amy/emails/tests/actions/test_instructor_training_approaching_receiver.py +++ b/amy/emails/tests/actions/test_instructor_training_approaching_receiver.py @@ -244,6 +244,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -261,6 +262,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_instructor_training_approaching_update_receiver.py b/amy/emails/tests/actions/test_instructor_training_approaching_update_receiver.py index efe1517d7..828fe15f3 100644 --- a/amy/emails/tests/actions/test_instructor_training_approaching_update_receiver.py +++ b/amy/emails/tests/actions/test_instructor_training_approaching_update_receiver.py @@ -332,6 +332,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -349,6 +350,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_instructor_training_completed_not_badged_integration.py b/amy/emails/tests/actions/test_instructor_training_completed_not_badged_integration.py index 2ed3a80ab..d84e26615 100644 --- a/amy/emails/tests/actions/test_instructor_training_completed_not_badged_integration.py +++ b/amy/emails/tests/actions/test_instructor_training_completed_not_badged_integration.py @@ -70,6 +70,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -141,6 +142,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -240,6 +242,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", diff --git a/amy/emails/tests/actions/test_persons_merged.py b/amy/emails/tests/actions/test_persons_merged.py index cf737e8e8..17c5ca768 100644 --- a/amy/emails/tests/actions/test_persons_merged.py +++ b/amy/emails/tests/actions/test_persons_merged.py @@ -180,6 +180,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -197,6 +198,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", @@ -218,6 +220,7 @@ def test_integration(self) -> None: "airport": "obj_a", "github": "obj_b", "twitter": "obj_a", + "bluesky": "obj_a", "url": "obj_b", "affiliation": "obj_b", "occupation": "obj_a", diff --git a/amy/emails/tests/actions/test_post_workshop_7days_cancel_receiver.py b/amy/emails/tests/actions/test_post_workshop_7days_cancel_receiver.py index 6c832fd0f..d17041a12 100644 --- a/amy/emails/tests/actions/test_post_workshop_7days_cancel_receiver.py +++ b/amy/emails/tests/actions/test_post_workshop_7days_cancel_receiver.py @@ -246,6 +246,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -263,6 +264,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_post_workshop_7days_receiver.py b/amy/emails/tests/actions/test_post_workshop_7days_receiver.py index ae8c62594..3f5a3fb77 100644 --- a/amy/emails/tests/actions/test_post_workshop_7days_receiver.py +++ b/amy/emails/tests/actions/test_post_workshop_7days_receiver.py @@ -239,6 +239,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -256,6 +257,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_post_workshop_7days_update_receiver.py b/amy/emails/tests/actions/test_post_workshop_7days_update_receiver.py index 282412392..0ef1c58b0 100644 --- a/amy/emails/tests/actions/test_post_workshop_7days_update_receiver.py +++ b/amy/emails/tests/actions/test_post_workshop_7days_update_receiver.py @@ -334,6 +334,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -351,6 +352,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_recruit_helpers_cancel_receiver.py b/amy/emails/tests/actions/test_recruit_helpers_cancel_receiver.py index 66dd21485..c3af005fb 100644 --- a/amy/emails/tests/actions/test_recruit_helpers_cancel_receiver.py +++ b/amy/emails/tests/actions/test_recruit_helpers_cancel_receiver.py @@ -242,6 +242,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -259,6 +260,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_recruit_helpers_receiver.py b/amy/emails/tests/actions/test_recruit_helpers_receiver.py index d5d16b962..8977f6068 100644 --- a/amy/emails/tests/actions/test_recruit_helpers_receiver.py +++ b/amy/emails/tests/actions/test_recruit_helpers_receiver.py @@ -235,6 +235,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -252,6 +253,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/emails/tests/actions/test_recruit_helpers_update_receiver.py b/amy/emails/tests/actions/test_recruit_helpers_update_receiver.py index a4378697f..1dc3d3d17 100644 --- a/amy/emails/tests/actions/test_recruit_helpers_update_receiver.py +++ b/amy/emails/tests/actions/test_recruit_helpers_update_receiver.py @@ -327,6 +327,7 @@ def test_integration(self) -> None: airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -344,6 +345,7 @@ def test_integration(self) -> None: airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", diff --git a/amy/templates/workshops/persons_merge.html b/amy/templates/workshops/persons_merge.html index f91a354b4..389ea5303 100644 --- a/amy/templates/workshops/persons_merge.html +++ b/amy/templates/workshops/persons_merge.html @@ -77,6 +77,11 @@ {{ obj_a.twitter|default:"—" }}{{ obj_b.twitter|default:"—" }} {% include "includes/merge_radio.html" with field=form.twitter %} + + BlueSky + {{ obj_a.bluesky|default:"—" }}{{ obj_b.bluesky|default:"—" }} + {% include "includes/merge_radio.html" with field=form.bluesky %} + URL {{ obj_a.url|default:"—"|urlize_newtab }}{{ obj_b.url|default:"—"|urlize_newtab }} diff --git a/amy/workshops/forms.py b/amy/workshops/forms.py index cdc112e7c..6c073aba2 100644 --- a/amy/workshops/forms.py +++ b/amy/workshops/forms.py @@ -778,6 +778,7 @@ class Meta: "affiliation", "github", "twitter", + "bluesky", "url", "occupation", "orcid", @@ -958,6 +959,11 @@ class PersonsMergeForm(forms.Form): initial=DEFAULT, widget=forms.RadioSelect, ) + bluesky = forms.ChoiceField( + choices=TWO, + initial=DEFAULT, + widget=forms.RadioSelect, + ) url = forms.ChoiceField( choices=TWO, initial=DEFAULT, diff --git a/amy/workshops/management/commands/fake_database.py b/amy/workshops/management/commands/fake_database.py index cb7277f0f..45b8b1f5d 100644 --- a/amy/workshops/management/commands/fake_database.py +++ b/amy/workshops/management/commands/fake_database.py @@ -416,6 +416,7 @@ def fake_person(self, *, is_instructor, is_trainer=False): github = social_username twitter = social_username + bluesky = f"@{social_username}.bsky.social" url = self.faker.url() if randbool(0.5) else "" person = Person.objects.create( @@ -426,6 +427,7 @@ def fake_person(self, *, is_instructor, is_trainer=False): gender_other=gender_other, airport=airport, twitter=twitter, + bluesky=bluesky, github=github, url=url, username=username, @@ -639,6 +641,7 @@ def fake_duplicated_people(self, count=5): # avoid integrity errors due to unique constraints p.username = create_username(p.personal, p.family) p.twitter = None + p.bluesky = None p.github = None p.email = self.faker.email() diff --git a/amy/workshops/migrations/0272_multiple_changes.py b/amy/workshops/migrations/0272_multiple_changes.py index 5191a7337..bf3b90a50 100644 --- a/amy/workshops/migrations/0272_multiple_changes.py +++ b/amy/workshops/migrations/0272_multiple_changes.py @@ -57,4 +57,16 @@ class Migration(migrations.Migration): model_name="task", name="url", ), + # #2726 + migrations.AddField( + model_name="person", + name="bluesky", + field=models.CharField( + blank=True, + max_length=100, + null=True, + unique=True, + verbose_name="BlueSky username", + ), + ), ] diff --git a/amy/workshops/models.py b/amy/workshops/models.py index bd82f6b44..d23752788 100644 --- a/amy/workshops/models.py +++ b/amy/workshops/models.py @@ -809,6 +809,13 @@ class Person( blank=True, verbose_name="Twitter username", ) + bluesky = models.CharField( + max_length=STR_LONG, + unique=True, + null=True, + blank=True, + verbose_name="BlueSky username", + ) url = models.CharField( max_length=STR_LONG, blank=True, @@ -1026,6 +1033,7 @@ def save(self, *args, **kwargs): self.airport = self.airport or None self.github = self.github or None self.twitter = self.twitter or None + self.bluesky = self.bluesky or None super().save(*args, **kwargs) def archive(self) -> None: @@ -1041,6 +1049,7 @@ def archive(self) -> None: self.airport = None self.github = None self.twitter = None + self.bluesky = None self.url = "" self.user_notes = "" self.affiliation = "" diff --git a/amy/workshops/tests/base.py b/amy/workshops/tests/base.py index efa3afd97..a3b055344 100644 --- a/amy/workshops/tests/base.py +++ b/amy/workshops/tests/base.py @@ -215,6 +215,7 @@ def _setUpInstructors(self): airport=self.airport_0_0, github="herself", twitter="herself", + bluesky="@herself.bsky.social", url="http://hermione.org", username="granger_hermione", country="GB", diff --git a/amy/workshops/tests/test_person.py b/amy/workshops/tests/test_person.py index 717d28c4c..9a7f9a64b 100644 --- a/amy/workshops/tests/test_person.py +++ b/amy/workshops/tests/test_person.py @@ -762,6 +762,7 @@ def setUp(self): airport=self.airport_0_0, github="purdy_kelsi", twitter="purdy_kelsi", + bluesky="@purdy_kelsi.bsky.social", url="http://kelsipurdy.com/", affiliation="University of Arizona", occupation="TA at Biology Department", @@ -858,6 +859,7 @@ def setUp(self): airport=self.airport_0_50, github="deckow_jayden", twitter="deckow_jayden", + bluesky="@deckow_jayden.bsky.social", url="http://jaydendeckow.com/", affiliation="UFlo", occupation="Staff", @@ -947,6 +949,7 @@ def setUp(self): "airport": "obj_a", "github": "obj_b", "twitter": "obj_a", + "bluesky": "obj_a", "url": "obj_b", "affiliation": "obj_b", "occupation": "obj_a", @@ -986,6 +989,7 @@ def test_form_invalid_values(self): "airport": "combine", "github": "combine", "twitter": "combine", + "bluesky": "combine", "url": "combine", "affiliation": "combine", "occupation": "combine", @@ -1049,6 +1053,7 @@ def test_merging_basic_attributes(self): "airport": self.person_a.airport, "github": self.person_b.github, "twitter": self.person_a.twitter, + "bluesky": self.person_a.bluesky, "url": self.person_b.url, "affiliation": self.person_b.affiliation, "occupation": self.person_a.occupation, @@ -1604,6 +1609,7 @@ def _assert_personal_info_removed(self, archived_profile: Person) -> None: self.assertIsNone(archived_profile.airport) self.assertIsNone(archived_profile.github) self.assertIsNone(archived_profile.twitter) + self.assertIsNone(archived_profile.bluesky) self.assertEqual(archived_profile.url, "") self.assertEqual(archived_profile.user_notes, "") self.assertEqual(archived_profile.affiliation, "") diff --git a/amy/workshops/views.py b/amy/workshops/views.py index b2174988e..04196356b 100644 --- a/amy/workshops/views.py +++ b/amy/workshops/views.py @@ -886,6 +886,7 @@ def persons_merge(request): "airport", "github", "twitter", + "bluesky", "url", "affiliation", "occupation", diff --git a/docs/amy_database_structure.md b/docs/amy_database_structure.md index a2325ca38..2d36325fb 100644 --- a/docs/amy_database_structure.md +++ b/docs/amy_database_structure.md @@ -51,6 +51,7 @@ The primary tables used in AMY (that will appear in most queries) are those that * `may_contact` A boolean field. We may not contact people if this field is false. This field has been replaced by [new-style consents](#consent) (see also [2021 Consents Project](./design/projects/2021_consents.md)). * `github` Individual's GitHub user id * `twitter` Individual's Twitter user id +* `bluesky` Individual's BlueSky user id * `orcid` Individual's ORCID iD * `url` Link to the individual's personal website * `airport_id` An integer representing the person's self identified nearest. This is linked to the `workshops_airport` table diff --git a/docs/users_guide/community_index.md b/docs/users_guide/community_index.md index 53a7298e5..ba4eb05fe 100644 --- a/docs/users_guide/community_index.md +++ b/docs/users_guide/community_index.md @@ -18,8 +18,9 @@ The Carpentries can maintain the following information about individuals in our * Country * Airport (Airports are used as approximate geographic identifiers for our instructors. Instructors can self-select the airport closest to them or the airport they most frequently use. If your aiport is not in the drop down, please contact so we can add it in.) * Affiliation -* GitHub username +* GitHub username * Twitter username +* BlueSky username * Personal website * Current occupation/career stage * ORCID ID @@ -62,13 +63,13 @@ Carpentries Instructor trainees can [log in to AMY](#logging-in) to view their c ![AMY Training Progress Menu](images/training_progress_menu.png) -You may submit your "Get Involved" step here, to be evaluated by The Carpentries Instructor Training team. The Instructor Training team will also record your status in completing the Teaching Demo and Welcome Session requirements and award your certificate upon successful completion of the Training and all three checkout steps. +You may submit your "Get Involved" step here, to be evaluated by The Carpentries Instructor Training team. The Instructor Training team will also record your status in completing the Teaching Demo and Welcome Session requirements and award your certificate upon successful completion of the Training and all three checkout steps. **Please allow 7-10 days for each of these steps to be evaluated and recorded.** ### Submitting your "Get Involved" step -To submit your "Get Involved" step, click on the blue "Submit a Get Involved Activity" button. +To submit your "Get Involved" step, click on the blue "Submit a Get Involved Activity" button. ![AMY Training Progress Submit](images/training_progress_submit.png) @@ -91,15 +92,15 @@ Once you submit your Get Involved step, your summary page will show your submiss ### Teaching Demonstration -The Trainer leading your Teaching Demonstration will inform the Instructor Training Team whether you passed or were asked to repeat your teaching demo, and your participation will be recorded by our Instructor Training team. +The Trainer leading your Teaching Demonstration will inform the Instructor Training Team whether you passed or were asked to repeat your teaching demo, and your participation will be recorded by our Instructor Training team. ### Welcome Session -If you signed in on the [Welcome Session Etherpad](https://pad.carpentries.org/welcome-sessions-2024), your participation will be recorded by our Instructor Training team. +If you signed in on the [Welcome Session Etherpad](https://pad.carpentries.org/welcome-sessions-2024), your participation will be recorded by our Instructor Training team. ### Certificate -Once you have completed all three steps, your summary page will show your status as complete. The Instructor Training team will award your badge and you will receive an email with a pdf certificate attached. +Once you have completed all three steps, your summary page will show your status as complete. The Instructor Training team will award your badge and you will receive an email with a pdf certificate attached. ![AMY Checkout Complete](images/checkout_complete.png) @@ -114,13 +115,13 @@ The next page will list all upcoming teaching opportunites. This list can be so ![AMY Instructor signup filter view](images/upcoming_teaching_opportunities.png) -Instructors can then register their interest in teaching specific workshop. +Instructors can then register their interest in teaching specific workshop. ![AMY Instructor signup form](images/workshop_intersted_signup.png) A workshop administrator will follow up shortly to confirm or decline each instructor. -### Notes about signing up +### Notes about signing up * Instructors will get a warning if they sign up as interested in concurrent workshops or workshops within two weeks of confirmed workshops. This is to remind Instructors about possible scheduling conflicts and that they may be overextending themsleves. This does not prevent Instructors from signing up. * Instructors will be blocked from signing up for workshops if they are already confirmed to teach a concurrent workshop. This is to prevent actual scheduling conflicts. Instructors may contact if they are interested in teaching concurrent workshops to discuss scheduling options. From b16c35f80ade76b519bb8d1f55479a80c2ab7e66 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Tue, 31 Dec 2024 20:15:14 +0100 Subject: [PATCH 14/46] feat(#2415): Leverage full-text search in Postgres to improve searching for people and training requests --- amy/dashboard/views.py | 108 ++++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 62 deletions(-) diff --git a/amy/dashboard/views.py b/amy/dashboard/views.py index 28f03c53e..2b8d50920 100644 --- a/amy/dashboard/views.py +++ b/amy/dashboard/views.py @@ -1,10 +1,10 @@ from datetime import date, timedelta -import re from urllib.parse import unquote from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.postgres.search import SearchVector from django.db.models import ( Case, Count, @@ -585,6 +585,12 @@ def get_redirect_url(self) -> str: def search(request): """Search the database by term.""" + def multiple_Q_icontains(term: str, *args: str) -> Q: + q = Q() + for arg in args: + q |= Q(**{f"{arg}__icontains": term}) + return q + term = "" organizations = None memberships = None @@ -598,7 +604,6 @@ def search(request): form = SearchForm(request.GET) if form.is_valid(): term = form.cleaned_data.get("term", "").strip() - tokens = re.split(r"\s+", term) results_combined = [] organizations = list( @@ -628,79 +633,58 @@ def search(request): ) results_combined += events - # if user searches for two words, assume they mean a person - # name - if len(tokens) == 2: - name1, name2 = tokens - complex_q = ( - (Q(personal__icontains=name1) & Q(family__icontains=name2)) - | (Q(personal__icontains=name2) & Q(family__icontains=name1)) - | Q(email__icontains=term) - | Q(secondary_email__icontains=term) - | Q(github__icontains=term) + persons = ( + Person.objects.annotate( + search=SearchVector("personal", "middle", "family") ) - persons = list(Person.objects.filter(complex_q).order_by("family")) - else: - persons = list( - Person.objects.filter( - Q(personal__icontains=term) - | Q(family__icontains=term) - | Q(email__icontains=term) - | Q(secondary_email__icontains=term) - | Q(github__icontains=term) - ).order_by("family") + .filter( + Q(search=term) + | multiple_Q_icontains(term, "email", "secondary_email", "github") ) - - results_combined += persons + .order_by("family") + ) + results_combined += list(persons) airports = list( Airport.objects.filter( - Q(iata__icontains=term) | Q(fullname__icontains=term) + multiple_Q_icontains(term, "iata", "fullname") ).order_by("iata") ) results_combined += airports - if len(tokens) == 2: - name1, name2 = tokens - complex_q = ( - Q(member_code__icontains=term) - | (Q(personal__icontains=name1) & Q(family__icontains=name2)) - | (Q(personal__icontains=name2) & Q(family__icontains=name1)) - | Q(email__icontains=term) - | Q(secondary_email__icontains=term) - | Q(github__icontains=term) - | Q(affiliation__icontains=term) - | Q(location__icontains=term) - | Q(user_notes__icontains=term) + training_requests = ( + TrainingRequest.objects.annotate( + search=SearchVector("personal", "middle", "family") ) - training_requests = list( - TrainingRequest.objects.filter(complex_q).order_by("family") - ) - - else: - training_requests = list( - TrainingRequest.objects.filter( - Q(member_code__icontains=term) - | Q(family__icontains=term) - | Q(email__icontains=term) - | Q(github__icontains=term) - | Q(affiliation__icontains=term) - | Q(location__icontains=term) - | Q(user_notes__icontains=term) - ).order_by("family") + .filter( + Q(search=term) + | multiple_Q_icontains( + term, + "member_code", + "email", + "secondary_email", + "github", + "affiliation", + "location", + "user_notes", + ) ) - - results_combined += training_requests + .order_by("family") + ) + results_combined += list(training_requests) comments = list( Comment.objects.filter( - Q(comment__icontains=term) - | Q(user_name__icontains=term) - | Q(user_email__icontains=term) - | Q(user__personal__icontains=term) - | Q(user__family__icontains=term) - | Q(user__email__icontains=term) - | Q(user__github__icontains=term) + multiple_Q_icontains( + term, + "comment", + "user_name", + "user_email", + "user__personal", + "user__family", + "user__email", + "user__github", + ) ).prefetch_related("content_object") ) results_combined += comments @@ -709,7 +693,7 @@ def search(request): if len(results_combined) == 1 and not form.cleaned_data["no_redirect"]: result = results_combined[0] msg = format_html( - "You were moved to this page, because your search {} " + "You were moved to this page, because your search {} " "yields only this result.", term, ) From 52ab3371143ebdbf3c6d4643623e86e8c8aadc54 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Wed, 8 Jan 2025 19:29:44 +0100 Subject: [PATCH 15/46] feat(#2262): Display tasks per role summary in instructor dashboard --- amy/dashboard/views.py | 32 +++++++++++-------- .../dashboard/instructor_dashboard.html | 12 +++++++ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/amy/dashboard/views.py b/amy/dashboard/views.py index 2b8d50920..693d049fe 100644 --- a/amy/dashboard/views.py +++ b/amy/dashboard/views.py @@ -137,21 +137,25 @@ def admin_dashboard(request): @login_required def instructor_dashboard(request): - qs = Person.objects.select_related("airport").prefetch_related( - "badges", - "lessons", - "domains", - "languages", - Prefetch( - "task_set", - queryset=Task.objects.select_related("event", "role").order_by( - "event__start", "event__slug" + qs = ( + Person.objects.annotate_with_role_count() # type: ignore + .select_related("airport") + .prefetch_related( + "badges", + "lessons", + "domains", + "languages", + Prefetch( + "task_set", + queryset=Task.objects.select_related("event", "role").order_by( + "event__start", "event__slug" + ), ), - ), - Prefetch( - "membershiptask_set", - queryset=MembershipTask.objects.select_related("membership", "role"), - ), + Prefetch( + "membershiptask_set", + queryset=MembershipTask.objects.select_related("membership", "role"), + ), + ) ) user = get_object_or_404(qs, id=request.user.id) diff --git a/amy/templates/dashboard/instructor_dashboard.html b/amy/templates/dashboard/instructor_dashboard.html index c627c8dea..5f26da576 100644 --- a/amy/templates/dashboard/instructor_dashboard.html +++ b/amy/templates/dashboard/instructor_dashboard.html @@ -139,6 +139,18 @@ {% endif %} {% endwith %} + + Your activity summary: + +
    +
  • Instructor: {{ user.num_instructor }} times
  • +
  • Trainer: {{ user.num_trainer }} times
  • +
  • Supporting Instructor: {{ user.num_supporting }} times
  • +
  • Helper: {{ user.num_helper }} times
  • +
  • Learner: {{ user.num_learner }} times
  • +
+ + {% with membership_tasks=user.membershiptask_set.all %} {% if membership_tasks %} Your roles in memberships: From 81f4a4f04b1b526047f0899b5513e58f2c900d4b Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Wed, 8 Jan 2025 20:07:30 +0100 Subject: [PATCH 16/46] feat(#2260): Allow admins to invoke upcoming teaching opportunities views --- amy/dashboard/views.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/amy/dashboard/views.py b/amy/dashboard/views.py index 693d049fe..b1b3c7faa 100644 --- a/amy/dashboard/views.py +++ b/amy/dashboard/views.py @@ -380,6 +380,9 @@ def get_queryset(self): return super().get_queryset() def get_view_enabled(self, request) -> bool: + if request.user.is_admin: + return True + try: role = CommunityRole.objects.get( person=self.request.user, config__name="instructor" @@ -438,6 +441,9 @@ class SignupForRecruitment( template_name = "dashboard/signup_for_recruitment.html" def get_view_enabled(self, request) -> bool: + if request.user.is_admin: + return True + try: role = CommunityRole.objects.get( person=self.request.user, config__name="instructor" @@ -563,6 +569,9 @@ def post(self, request, *args, **kwargs): return redirect(redirect_url) def get_view_enabled(self, request) -> bool: + if request.user.is_admin: + return True + try: role = CommunityRole.objects.get( person=self.request.user, config__name="instructor" From 17f0960d654e4cc9985f0ec05e1bbfc83c51c001 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Wed, 8 Jan 2025 20:08:03 +0100 Subject: [PATCH 17/46] feat(#2260): Show upcoming teaching opportunities link/btn to admins --- amy/templates/dashboard/instructor_dashboard.html | 6 ++++-- amy/templates/navigation_instructor_dashboard.html | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/amy/templates/dashboard/instructor_dashboard.html b/amy/templates/dashboard/instructor_dashboard.html index 5f26da576..ff13ed6f8 100644 --- a/amy/templates/dashboard/instructor_dashboard.html +++ b/amy/templates/dashboard/instructor_dashboard.html @@ -12,8 +12,10 @@ {% flag_enabled 'INSTRUCTOR_RECRUITMENT' as INSTRUCTOR_RECRUITMENT_ENABLED %} {% get_community_role user role_name="instructor" as INSTRUCTOR_COMMUNITY_ROLE %} -{% if INSTRUCTOR_RECRUITMENT_ENABLED and INSTRUCTOR_COMMUNITY_ROLE.is_active %} -View upcoming teaching opportunities with The Carpentries +{% if INSTRUCTOR_RECRUITMENT_ENABLED %} + {% if INSTRUCTOR_COMMUNITY_ROLE.is_active or user.is_admin %} + View upcoming teaching opportunities with The Carpentries + {% endif %} {% endif %}
diff --git a/amy/templates/navigation_instructor_dashboard.html b/amy/templates/navigation_instructor_dashboard.html index 6bdf4b98c..e97c1f03e 100644 --- a/amy/templates/navigation_instructor_dashboard.html +++ b/amy/templates/navigation_instructor_dashboard.html @@ -19,8 +19,10 @@ {% flag_enabled 'INSTRUCTOR_RECRUITMENT' as INSTRUCTOR_RECRUITMENT_ENABLED %} {% get_community_role user role_name="instructor" as INSTRUCTOR_COMMUNITY_ROLE %} - {% if INSTRUCTOR_RECRUITMENT_ENABLED and INSTRUCTOR_COMMUNITY_ROLE.is_active %} - {% navbar_element "Upcoming Teaching Opportunities" "upcoming-teaching-opportunities" %} + {% if INSTRUCTOR_RECRUITMENT_ENABLED %} + {% if INSTRUCTOR_COMMUNITY_ROLE.is_active or user.is_admin %} + {% navbar_element "Upcoming Teaching Opportunities" "upcoming-teaching-opportunities" %} + {% endif %} {% endif %} From ea2f4a5b303df520704d6ae9a3c0916baf2f337d Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Wed, 8 Jan 2025 20:08:27 +0100 Subject: [PATCH 18/46] feat(#2260): Add navigation link to instructor's dashboard for admins --- amy/templates/navigation.html | 1 + 1 file changed, 1 insertion(+) diff --git a/amy/templates/navigation.html b/amy/templates/navigation.html index 8d868782d..59ef401e4 100644 --- a/amy/templates/navigation.html +++ b/amy/templates/navigation.html @@ -94,6 +94,7 @@ {% if user.is_superuser %} Django Admin + Instructor dashboard {% endif %} Feature flags From 253927324a72210bc54c6335a492739615e5da58 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Wed, 8 Jan 2025 20:15:37 +0100 Subject: [PATCH 19/46] feat(#2260): Add tests confirming admin's access to instructor recruitment dashboard --- .../test_instructor_recruitment_views.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/amy/dashboard/tests/test_instructor_recruitment_views.py b/amy/dashboard/tests/test_instructor_recruitment_views.py index 47552ae54..e0b12696f 100644 --- a/amy/dashboard/tests/test_instructor_recruitment_views.py +++ b/amy/dashboard/tests/test_instructor_recruitment_views.py @@ -86,6 +86,19 @@ def test_view_enabled__community_role_active(self): self.assertEqual(role.is_active(), True) self.assertEqual(view.get_view_enabled(request), True) + @override_settings(FLAGS={"INSTRUCTOR_RECRUITMENT": [("boolean", True)]}) + def test_view_enabled__admin(self): + # Arrange + request = RequestFactory().get("/") + person = Person.objects.create( + personal="Test", family="User", email="test@user.com", is_superuser=True + ) + request.user = person + # Act + view = UpcomingTeachingOpportunitiesList(request=request) + # Assert + self.assertEqual(view.get_view_enabled(request), True) + def test_get_queryset(self): # Arrange host = Organization.objects.create(domain="test.com", fullname="Test") @@ -265,6 +278,19 @@ def test_view_enabled__community_role_active(self): self.assertEqual(role.is_active(), True) self.assertEqual(view.get_view_enabled(request), True) + @override_settings(FLAGS={"INSTRUCTOR_RECRUITMENT": [("boolean", True)]}) + def test_view_enabled__admin(self): + # Arrange + request = RequestFactory().get("/") + person = Person.objects.create( + personal="Test", family="User", email="test@user.com", is_superuser=True + ) + request.user = person + # Act + view = SignupForRecruitment(request=request) + # Assert + self.assertEqual(view.get_view_enabled(request), True) + def test_get_context_data(self): # Arrange host = Organization.objects.create(domain="test.com", fullname="Test") @@ -581,6 +607,19 @@ def test_view_enabled__community_role_active(self): self.assertEqual(role.is_active(), True) self.assertEqual(view.get_view_enabled(request), True) + @override_settings(FLAGS={"INSTRUCTOR_RECRUITMENT": [("boolean", True)]}) + def test_view_enabled__admin(self): + # Arrange + request = RequestFactory().get("/") + person = Person.objects.create( + personal="Test", family="User", email="test@user.com", is_superuser=True + ) + request.user = person + # Act + view = ResignFromRecruitment(request=request) + # Assert + self.assertEqual(view.get_view_enabled(request), True) + def test_get_queryset(self): # Arrange host = Organization.objects.create(domain="test.com", fullname="Test") From 5dfdff3f4236e9f4b66f76f4340db50b8e71b4a1 Mon Sep 17 00:00:00 2001 From: maneesha <829690+maneesha@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:12:46 -0500 Subject: [PATCH 20/46] un-bold links to email templates --- docs/users_guide/admin_index.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/users_guide/admin_index.md b/docs/users_guide/admin_index.md index 171eb61a7..c28be827a 100644 --- a/docs/users_guide/admin_index.md +++ b/docs/users_guide/admin_index.md @@ -525,41 +525,41 @@ From, Reply to, CC, and BCC are all set in the AMY interface. The email subject #### Active emails (Workshops) ??? abstract "Admin Signs Instructor Up for Workshop" - **[View in AMY](https://amy.carpentries.org/emails/template/53209bdb-fbd2-4620-988b-16703d38a083/)** + [View in AMY](https://amy.carpentries.org/emails/template/53209bdb-fbd2-4620-988b-16703d38a083/) **Email description:** Send email to Instructor informing them the admin has signed them up as interested in teaching a workshop **Sent to:** Instructor **Date sent:** One hour after admin action ??? abstract "Ask for website" - **[View in AMY](https://amy.carpentries.org/emails/template/8b29643c-d4f2-4b58-bc3c-5ebc4ff3d7b4/)** + [View in AMY](https://amy.carpentries.org/emails/template/8b29643c-d4f2-4b58-bc3c-5ebc4ff3d7b4/) **Email description:** Send email to workshop Hosts & Instructors reminding them to send us workshop website link **Sent to:** Instructors and Hosts **Date sent:** One month before event start **Conditions:** Has administrator; Has at least one Instructor; Tagged SWC/DC/LC; Start date in future; not stalled/cancelled; Missing website ??? abstract "Host-instructors introduction" - [View in AMY](https://amy.carpentries.org/emails/template/fb7286a4-e4a1-4d92-81b8-33e698170178/) + [View in AMY](https://amy.carpentries.org/emails/template/fb7286a4-e4a1-4d92-81b8-33e698170178/) **Email description:** Send email introducing workshop Hosts & Instructors providing general inforamtion **Sent to:** Instructors and Hosts **Date sent:** One month before event start **Conditions:** Centrally-Organised; Instructor recruitment closed; Not stalled/cancelled; Has at least two Instructors; Has host; Tagged SWC/DC/LC; At least seven days in future ??? abstract "Instructor confirmed for workshop" - [View in AMY](https://amy.carpentries.org/emails/template/ba2d0130-d123-402d-97d5-8818b72af963/) + [View in AMY](https://amy.carpentries.org/emails/template/ba2d0130-d123-402d-97d5-8818b72af963/) **Email description:** Emails instructor to confirm they have been assigned to a workshop **Sent to:** Instructor **Date sent:** One hour after conditions are met **Conditions:** Instructor task created; Tagged SWC/DC/LC; Centrally-Organised; Start date in future ??? abstract "Instructor declined for workshop" - [View in AMY](https://amy.carpentries.org/emails/template/be7a8e81-d4fb-47c1-b78f-1e5f809bc128/) + [View in AMY](https://amy.carpentries.org/emails/template/be7a8e81-d4fb-47c1-b78f-1e5f809bc128/) **Email description:** Emails instructor to inform them they have not been assigned to a workshop **Sent to:** Instructor **Date sent:** One hour after conditions are met **Conditions:** Instructor declined in recruitment process ??? abstract "Instructor Signs Up for Workshop" - [View in AMY](https://amy.carpentries.org/emails/template/43754daa-c312-41db-a0f6-00ac4f88bafd/) + [View in AMY](https://amy.carpentries.org/emails/template/43754daa-c312-41db-a0f6-00ac4f88bafd/) **Email description:** Emails instructor to confirm they have expressed interest in a workshop **Sent to:** Instructor **Date sent:** One hour after conditions are met From b3c78274bb74830db3c467b1ff0cebc55cb6860f Mon Sep 17 00:00:00 2001 From: maneesha <829690+maneesha@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:18:20 -0500 Subject: [PATCH 21/46] update documentation about auto email conditions --- docs/users_guide/admin_index.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/users_guide/admin_index.md b/docs/users_guide/admin_index.md index c28be827a..f088cf37d 100644 --- a/docs/users_guide/admin_index.md +++ b/docs/users_guide/admin_index.md @@ -532,10 +532,10 @@ From, Reply to, CC, and BCC are all set in the AMY interface. The email subject ??? abstract "Ask for website" [View in AMY](https://amy.carpentries.org/emails/template/8b29643c-d4f2-4b58-bc3c-5ebc4ff3d7b4/) - **Email description:** Send email to workshop Hosts & Instructors reminding them to send us workshop website link - **Sent to:** Instructors and Hosts + **Email description:** Send email to workshop Instructors reminding them to send us workshop website link + **Sent to:** Instructors **Date sent:** One month before event start - **Conditions:** Has administrator; Has at least one Instructor; Tagged SWC/DC/LC; Start date in future; not stalled/cancelled; Missing website + **Conditions:** Has administrator; Has at least one Instructor; Tagged SWC/DC/LC; Start date in future; not stalled/cancelled/unresponsive; Missing website ??? abstract "Host-instructors introduction" [View in AMY](https://amy.carpentries.org/emails/template/fb7286a4-e4a1-4d92-81b8-33e698170178/) @@ -568,23 +568,23 @@ From, Reply to, CC, and BCC are all set in the AMY interface. The email subject ??? abstract "New Self-Organised Workshop" [View in AMY](https://amy.carpentries.org/emails/template/f967122e-55a8-483e-a571-c3dff43dd33e/) **Email description:** Emails Self-Organised workshop host to confirm their submission has been accepted - **Sent to:** Workshop Host + **Sent to:** Contacts from Self-Organised Workshop submission form **Date sent:** One hour after conditions are met - **Conditions:** Self organized submission form accepted + **Conditions:** Self organized submission form accepted and linked to event; start date in future; not stalled/cancelled/unresponsive; ??? abstract "Post Workshop 7 days" [View in AMY](https://amy.carpentries.org/emails/template/4809378c-c1aa-4118-a3a7-cbc9586c686b/) **Email description:** Follow up one week after Centrally-Organised workshop is complete **Sent to:** Hosts and Instructors **Date sent:** Later of seven days after workshop end or seven days from now - **Conditions:** Centrally-Organised; At least one host; At least one Instructor; Tagged SWC/DC/LC; + **Conditions:** Centrally-Organised; At least one host; At least one Instructor; Tagged SWC/DC/LC; Not CLDT; end date in future; not stalled/cancelled/unresponsive ??? abstract "Recruit helpers" [View in AMY](https://amy.carpentries.org/emails/template/d1b20e74-a245-4c7d-9182-663f3acd8a48/) **Email description:** Reminder to host to recruit helpers **Sent to:** Hosts and Instructors **Date sent:** 21 days before event - **Conditions:** Centrally-Organised; At least one host; At least 1 Instructor; Tagged SWC/DC/LC; Not stalled/cancelled; At least one host; At least one Instructor; No helpers; Start date at least 14 days in future + **Conditions:** Centrally-Organised; Tagged SWC/DC/LC; Not stalled/cancelled; At least one host; At least one Instructor; No helpers; Start date at least 14 days in future; not stalled/cancelled/unresponsive @@ -603,7 +603,7 @@ From, Reply to, CC, and BCC are all set in the AMY interface. The email subject [View in AMY](https://amy.carpentries.org/emails/template/f1311aaf-c546-429b-930c-c36a5a8009fb/) **Email description:** Onboarding message for new/renewing memberships **Sent to:** Programmatic and billing contacts - **Date sent:** One month before membership start date or immediately (whichever is first) + **Date sent:** One month before membership start date or immediately (whichever is later) **Conditions:** Membership roles exist #### Inactive emails From 193f36b136546125fd39bd76e10b569a8842b809 Mon Sep 17 00:00:00 2001 From: maneesha <829690+maneesha@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:19:02 -0500 Subject: [PATCH 22/46] typo --- docs/users_guide/admin_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users_guide/admin_index.md b/docs/users_guide/admin_index.md index f088cf37d..0731c5f27 100644 --- a/docs/users_guide/admin_index.md +++ b/docs/users_guide/admin_index.md @@ -539,7 +539,7 @@ From, Reply to, CC, and BCC are all set in the AMY interface. The email subject ??? abstract "Host-instructors introduction" [View in AMY](https://amy.carpentries.org/emails/template/fb7286a4-e4a1-4d92-81b8-33e698170178/) - **Email description:** Send email introducing workshop Hosts & Instructors providing general inforamtion + **Email description:** Send email introducing workshop Hosts & Instructors providing general information **Sent to:** Instructors and Hosts **Date sent:** One month before event start **Conditions:** Centrally-Organised; Instructor recruitment closed; Not stalled/cancelled; Has at least two Instructors; Has host; Tagged SWC/DC/LC; At least seven days in future From a1166d841f7fbcad55056534d0a80804b8d3b11f Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Wed, 8 Jan 2025 22:33:39 +0100 Subject: [PATCH 23/46] chore: Run the CICD workflow in this branch This will come useful later when testing CICD pipeline. --- .github/workflows/cicd_develop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd_develop.yml b/.github/workflows/cicd_develop.yml index 2bd36e1a2..f6e6351c2 100644 --- a/.github/workflows/cicd_develop.yml +++ b/.github/workflows/cicd_develop.yml @@ -4,7 +4,7 @@ name: CI/CD (develop) on: push: - branches: [ develop ] + branches: [ develop, 'chore/Update-cicd-workflow' ] pull_request: branches: [ develop ] From c8fc55279e2df939189e1ee49e1c9dc2d91e26f0 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Wed, 8 Jan 2025 22:33:59 +0100 Subject: [PATCH 24/46] chore: Update aws-actions/configure-aws-credentials to v4 --- .github/workflows/cicd_develop.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd_develop.yml b/.github/workflows/cicd_develop.yml index f6e6351c2..d1f3e42b1 100644 --- a/.github/workflows/cicd_develop.yml +++ b/.github/workflows/cicd_develop.yml @@ -30,7 +30,7 @@ jobs: uses: actions/checkout@v3 - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v1-node16 + uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_ECR_ROLE_TO_ASSUME }} aws-region: ${{ vars.AWS_REGION }} @@ -62,7 +62,7 @@ jobs: steps: - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v1-node16 + uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_ECS_ROLE_TO_ASSUME }} aws-region: ${{ vars.AWS_REGION }} From a4c12f124fda36eef4930cc526aef430cf491973 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Wed, 8 Jan 2025 22:48:28 +0100 Subject: [PATCH 25/46] chore: Update aws-actions/amazon-ecr-login to v2 --- .github/workflows/cicd_develop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd_develop.yml b/.github/workflows/cicd_develop.yml index d1f3e42b1..e4334bb41 100644 --- a/.github/workflows/cicd_develop.yml +++ b/.github/workflows/cicd_develop.yml @@ -38,7 +38,7 @@ jobs: - name: Login to Amazon ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@v1 + uses: aws-actions/amazon-ecr-login@v2 - name: Build, tag, and push image to Amazon ECR id: build-image From 2680b04d70ebb1bed730a0e00f50e85753dcd22c Mon Sep 17 00:00:00 2001 From: maneesha <829690+maneesha@users.noreply.github.com> Date: Thu, 9 Jan 2025 14:32:26 -0500 Subject: [PATCH 26/46] update docs on when host-instructor intro email is sent --- docs/users_guide/admin_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users_guide/admin_index.md b/docs/users_guide/admin_index.md index 0731c5f27..802b3bba9 100644 --- a/docs/users_guide/admin_index.md +++ b/docs/users_guide/admin_index.md @@ -541,7 +541,7 @@ From, Reply to, CC, and BCC are all set in the AMY interface. The email subject [View in AMY](https://amy.carpentries.org/emails/template/fb7286a4-e4a1-4d92-81b8-33e698170178/) **Email description:** Send email introducing workshop Hosts & Instructors providing general information **Sent to:** Instructors and Hosts - **Date sent:** One month before event start + **Date sent:** Immediately after conditions are met **Conditions:** Centrally-Organised; Instructor recruitment closed; Not stalled/cancelled; Has at least two Instructors; Has host; Tagged SWC/DC/LC; At least seven days in future ??? abstract "Instructor confirmed for workshop" From bb08f25455d0c9696d24a7d8f73fc5596a4632b0 Mon Sep 17 00:00:00 2001 From: maneesha <829690+maneesha@users.noreply.github.com> Date: Thu, 9 Jan 2025 14:34:36 -0500 Subject: [PATCH 27/46] update docs on Instructor task created --- docs/users_guide/admin_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users_guide/admin_index.md b/docs/users_guide/admin_index.md index 802b3bba9..751553c72 100644 --- a/docs/users_guide/admin_index.md +++ b/docs/users_guide/admin_index.md @@ -549,7 +549,7 @@ From, Reply to, CC, and BCC are all set in the AMY interface. The email subject **Email description:** Emails instructor to confirm they have been assigned to a workshop **Sent to:** Instructor **Date sent:** One hour after conditions are met - **Conditions:** Instructor task created; Tagged SWC/DC/LC; Centrally-Organised; Start date in future + **Conditions:** Instructor task created (either through recruitment process or manual data entry); Tagged SWC/DC/LC; Centrally-Organised; Start date in future ??? abstract "Instructor declined for workshop" [View in AMY](https://amy.carpentries.org/emails/template/be7a8e81-d4fb-47c1-b78f-1e5f809bc128/) From b1d616cfbbdd7d006492802ae06b0e798391de0a Mon Sep 17 00:00:00 2001 From: maneesha <829690+maneesha@users.noreply.github.com> Date: Thu, 9 Jan 2025 14:36:16 -0500 Subject: [PATCH 28/46] docs note that recipients must have valid email address --- docs/users_guide/admin_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users_guide/admin_index.md b/docs/users_guide/admin_index.md index 751553c72..acd3d5293 100644 --- a/docs/users_guide/admin_index.md +++ b/docs/users_guide/admin_index.md @@ -518,7 +518,7 @@ AMY sends automated emails for membership, instructor training, and workshop adm [Email templates](https://amy.carpentries.org/emails/templates/) and [status of scheduled emails](https://amy.carpentries.org/emails/scheduled_emails/) can be viewed directly through the user interface under the "More" menu. Not all templated emails are currently active. -Email recipients, schedule, and conditions are all set in the AMY codebase. +Email recipients, schedule, and conditions are all set in the AMY codebase. All automated emails depend on recipients having a valid email address in AMY. From, Reply to, CC, and BCC are all set in the AMY interface. The email subject and body are also set in the AMY interface. These fields can all be modified by admin users at any time. From e4e41804f66605275179dae0e12e81fc4f07f933 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Thu, 9 Jan 2025 22:09:12 +0100 Subject: [PATCH 29/46] chore: Update aws-actions/amazon-ecs-deploy-task-definition to v2 --- .github/workflows/cicd_develop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd_develop.yml b/.github/workflows/cicd_develop.yml index e4334bb41..d67381136 100644 --- a/.github/workflows/cicd_develop.yml +++ b/.github/workflows/cicd_develop.yml @@ -83,7 +83,7 @@ jobs: image: ${{ needs.build.outputs.image }} - name: Deploy Amazon ECS task definition - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + uses: aws-actions/amazon-ecs-deploy-task-definition@v2 with: task-definition: ${{ steps.task-def.outputs.task-definition }} service: ${{ vars.AMY_ECS_SERVICE_NAME }} From 9d7a8847fcb7633075243cb91d800fd0d2e2840c Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Thu, 9 Jan 2025 22:19:30 +0100 Subject: [PATCH 30/46] chore: Don't run CICD for current branch anymore The tests passed, so it should be good to deploy now. --- .github/workflows/cicd_develop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd_develop.yml b/.github/workflows/cicd_develop.yml index d67381136..afb603c6d 100644 --- a/.github/workflows/cicd_develop.yml +++ b/.github/workflows/cicd_develop.yml @@ -4,7 +4,7 @@ name: CI/CD (develop) on: push: - branches: [ develop, 'chore/Update-cicd-workflow' ] + branches: [ develop ] pull_request: branches: [ develop ] From f99c176aac2df5126033d6b49d0fade99e915c16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jan 2025 19:15:41 +0000 Subject: [PATCH 31/46] chore(deps): Bump jinja2 from 3.1.4 to 3.1.5 Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/pallets/jinja/releases) - [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/jinja/compare/3.1.4...3.1.5) --- updated-dependencies: - dependency-name: jinja2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Pipfile | 2 +- Pipfile.lock | 136 ++++++++++++++++++++++++++------------------------- 2 files changed, 70 insertions(+), 68 deletions(-) diff --git a/Pipfile b/Pipfile index 9c0fec532..25dab29de 100644 --- a/Pipfile +++ b/Pipfile @@ -42,7 +42,7 @@ typing-extensions = "*" django-flags = "~=5.0.13" pydantic = "~=2.5.2" django-rest-knox = "~=4.2.0" -jinja2 = "~=3.1.0" +jinja2 = "~=3.1.5" [dev-packages] django-webtest = "~=1.9.8" diff --git a/Pipfile.lock b/Pipfile.lock index e1075b5f2..4a96dc78a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "693f5cc4464381fc707381458120ff2d84e09b9d05776cc645b8ced0192e80ed" + "sha256": "721e3ab07eaeb4163f305e5338f09bb4d107bb6407cc4765b9fc422c1b8a6c04" }, "pipfile-spec": 6, "requires": { @@ -550,11 +550,12 @@ }, "jinja2": { "hashes": [ - "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", - "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d" + "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", + "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb" ], "index": "pypi", - "version": "==3.1.4" + "markers": "python_version >= '3.7'", + "version": "==3.1.5" }, "markdown": { "hashes": [ @@ -574,69 +575,70 @@ }, "markupsafe": { "hashes": [ - "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", - "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", - "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", - "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", - "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", - "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", - "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", - "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", - "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", - "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", - "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", - "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", - "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", - "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", - "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", - "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", - "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", - "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", - "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", - "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", - "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", - "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", - "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", - "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", - "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", - "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", - "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", - "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", - "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", - "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", - "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", - "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", - "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", - "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", - "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", - "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", - "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", - "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", - "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", - "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", - "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", - "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", - "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", - "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", - "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", - "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", - "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", - "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", - "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", - "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", - "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", - "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", - "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", - "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", - "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", - "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", - "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", - "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", - "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", - "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" - ], - "markers": "python_version >= '3.7'", - "version": "==2.1.5" + "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", + "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", + "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", + "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", + "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", + "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", + "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", + "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", + "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", + "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", + "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", + "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", + "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", + "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", + "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", + "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", + "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", + "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", + "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", + "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", + "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", + "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", + "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", + "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", + "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", + "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", + "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", + "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", + "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", + "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", + "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", + "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", + "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", + "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", + "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", + "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", + "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", + "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", + "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", + "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", + "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", + "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", + "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", + "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", + "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", + "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", + "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", + "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", + "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", + "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", + "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", + "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", + "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", + "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", + "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", + "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", + "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", + "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", + "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", + "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", + "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50" + ], + "markers": "python_version >= '3.9'", + "version": "==3.0.2" }, "mdurl": { "hashes": [ From 67957dff532a07c40b1c5d710062d171a15f3686 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jan 2025 19:25:52 +0000 Subject: [PATCH 32/46] chore(deps-dev): Bump virtualenv from 20.26.2 to 20.26.6 Bumps [virtualenv](https://github.com/pypa/virtualenv) from 20.26.2 to 20.26.6. - [Release notes](https://github.com/pypa/virtualenv/releases) - [Changelog](https://github.com/pypa/virtualenv/blob/main/docs/changelog.rst) - [Commits](https://github.com/pypa/virtualenv/compare/20.26.2...20.26.6) --- updated-dependencies: - dependency-name: virtualenv dependency-type: indirect ... Signed-off-by: dependabot[bot] --- Pipfile.lock | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 4a96dc78a..96c4232f6 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1260,10 +1260,10 @@ }, "distlib": { "hashes": [ - "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", - "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64" + "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", + "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403" ], - "version": "==0.3.8" + "version": "==0.3.9" }, "django": { "hashes": [ @@ -1323,11 +1323,11 @@ }, "filelock": { "hashes": [ - "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f", - "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a" + "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", + "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435" ], "markers": "python_version >= '3.8'", - "version": "==3.14.0" + "version": "==3.16.1" }, "flake8": { "hashes": [ @@ -1549,11 +1549,11 @@ }, "platformdirs": { "hashes": [ - "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", - "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3" + "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", + "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" ], "markers": "python_version >= '3.8'", - "version": "==4.2.2" + "version": "==4.3.6" }, "pre-commit": { "hashes": [ @@ -1781,11 +1781,12 @@ }, "virtualenv": { "hashes": [ - "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c", - "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b" + "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48", + "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2" ], + "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==20.26.2" + "version": "==20.26.6" }, "waitress": { "hashes": [ From 104077b846f536f195fda965be1c95b8a0b1885b Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Tue, 14 Jan 2025 21:52:13 +0100 Subject: [PATCH 33/46] fix(#2449): Remove from templates event.title, event.url - fields that no longer exist --- amy/templates/workshops/person_edit_form.html | 2 -- amy/templates/workshops/task.html | 2 -- 2 files changed, 4 deletions(-) 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 @@ - - From 51930e019ab2090dcf692c9bfd5be2af4abae12c Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Tue, 14 Jan 2025 21:54:01 +0100 Subject: [PATCH 34/46] fix(#2449): Fix rare error when event.administrator doesn't exist during task creation It's rare because it's not possible to create event without administrator. --- amy/workshops/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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( From ebfc965bb4dfbd1bb6f0b8ad92ee1f56556ea5af Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Tue, 14 Jan 2025 21:58:13 +0100 Subject: [PATCH 35/46] feat(#2449): Show message when accepting a signup but task already exists --- amy/recruitment/views.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/amy/recruitment/views.py b/amy/recruitment/views.py index 4fe211e70..5126627d5 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 @@ -428,11 +429,20 @@ 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( + 'Instructor task already exists, ' + "recruitment signup was accepted.", + task.get_absolute_url(), + ), + ) run_instructor_confirmed_for_workshop_strategy( instructor_confirmed_for_workshop_strategy(task), From f401e200583c5bbbfed90dbd15aad8873a6e7a85 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jan 2025 22:26:08 +0000 Subject: [PATCH 36/46] chore(deps): Bump django from 4.2.17 to 4.2.18 Bumps [django](https://github.com/django/django) from 4.2.17 to 4.2.18. - [Commits](https://github.com/django/django/compare/4.2.17...4.2.18) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Pipfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 96c4232f6..56d92723b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -319,12 +319,12 @@ }, "django": { "hashes": [ - "sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0", - "sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc" + "sha256:52ae8eacf635617c0f13b44f749e5ea13dc34262819b2cc8c8636abb08d82c4b", + "sha256:ba52eff7e228f1c775d5b0db2ba53d8c49d2f8bfe6ca0234df6b7dd12fb25b19" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.2.17" + "version": "==4.2.18" }, "django-anymail": { "hashes": [ @@ -1075,11 +1075,11 @@ }, "sqlparse": { "hashes": [ - "sha256:9e37b35e16d1cc652a2545f0997c1deb23ea28fa1f3eefe609eee3063c3b105f", - "sha256:e99bc85c78160918c3e1d9230834ab8d80fc06c59d03f8db2618f65f65dda55e" + "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", + "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca" ], "markers": "python_version >= '3.8'", - "version": "==0.5.2" + "version": "==0.5.3" }, "typing-extensions": { "hashes": [ From d2700f67d478a531b4571c2747f832a41d5e8910 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Sun, 19 Jan 2025 21:27:18 +0100 Subject: [PATCH 37/46] feat(#2449): Remove unused task and role from some Instructor Declined email tests --- ...nstructor_declined_from_workshop_cancel_receiver.py | 6 +----- .../test_instructor_declined_from_workshop_strategy.py | 10 +--------- ...nstructor_declined_from_workshop_update_receiver.py | 6 +----- 3 files changed, 3 insertions(+), 19 deletions(-) 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" ) From 4efc86c0768872e975a9c2be914bcc5772f3e0be Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Sun, 19 Jan 2025 21:30:47 +0100 Subject: [PATCH 38/46] feat(#2449): Change Instructor Confirmed email to only work on Recruitment Signups The Tasks are no longer considered for this email. --- .../instructor_confirmed_for_workshop.py | 104 ++++----- ..._confirmed_for_workshop_cancel_receiver.py | 136 ++++++------ ...tructor_confirmed_for_workshop_receiver.py | 31 +-- ...tructor_confirmed_for_workshop_strategy.py | 129 ++++++------ ..._confirmed_for_workshop_update_receiver.py | 198 +++++++++--------- amy/emails/types.py | 10 +- amy/recruitment/views.py | 42 ++-- 7 files changed, 305 insertions(+), 345 deletions(-) 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/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/types.py b/amy/emails/types.py index bbdbeaf4f..f83572694 100644 --- a/amy/emails/types.py +++ b/amy/emails/types.py @@ -12,7 +12,6 @@ Membership, Organization, Person, - Task, TrainingProgress, TrainingRequirement, ) @@ -34,17 +33,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): diff --git a/amy/recruitment/views.py b/amy/recruitment/views.py index 5126627d5..b8c66de25 100644 --- a/amy/recruitment/views.py +++ b/amy/recruitment/views.py @@ -68,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( @@ -130,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): @@ -215,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) @@ -241,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", @@ -445,12 +445,11 @@ def add_instructor_task( ) 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, ) @@ -477,27 +476,20 @@ def remove_instructor_task( 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, - ), - 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, - ) + 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, From 2a2fa14803c9cfc500ff9cca3db037e35fde5f01 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Sun, 19 Jan 2025 21:32:24 +0100 Subject: [PATCH 39/46] feat(#2449): Add new email Instructor Task Created for Workshop This email action replaces behavior of "Instructor Confirmed for Workshop" in area where it is related to instructor tasks. --- .../instructor_task_created_for_workshop.py | 319 +++++++++++++++ amy/emails/signals.py | 13 + ...sk_created_for_workshop_cancel_receiver.py | 285 ++++++++++++++ ...ctor_task_created_for_workshop_receiver.py | 271 +++++++++++++ ...ctor_task_created_for_workshop_strategy.py | 261 +++++++++++++ ...sk_created_for_workshop_update_receiver.py | 368 ++++++++++++++++++ amy/emails/types.py | 15 + amy/scripts/seed_emails.py | 15 + amy/workshops/views.py | 30 +- 9 files changed, 1558 insertions(+), 19 deletions(-) create mode 100644 amy/emails/actions/instructor_task_created_for_workshop.py create mode 100644 amy/emails/tests/actions/test_instructor_task_created_for_workshop_cancel_receiver.py create mode 100644 amy/emails/tests/actions/test_instructor_task_created_for_workshop_receiver.py create mode 100644 amy/emails/tests/actions/test_instructor_task_created_for_workshop_strategy.py create mode 100644 amy/emails/tests/actions/test_instructor_task_created_for_workshop_update_receiver.py 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_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 f83572694..ec67d94dc 100644 --- a/amy/emails/types.py +++ b/amy/emails/types.py @@ -12,6 +12,7 @@ Membership, Organization, Person, + Task, TrainingProgress, TrainingRequirement, ) @@ -96,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/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/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, ) From 8dcb0993854ddb95e5c41d45abcf86ade01c8473 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Sun, 19 Jan 2025 21:40:53 +0100 Subject: [PATCH 40/46] feat(#2449): Rename handlers of signup accept/decline actions --- .../test_instructor_recruitment_views.py | 26 +++++++++---------- amy/recruitment/views.py | 8 +++--- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/amy/recruitment/tests/test_instructor_recruitment_views.py b/amy/recruitment/tests/test_instructor_recruitment_views.py index a396b84bc..bad139a85 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,10 +675,10 @@ 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: # Arrange @@ -698,7 +698,7 @@ def test_add_instructor_task(self) -> None: recruitment = InstructorRecruitment(event=event) signup = InstructorRecruitmentSignup(recruitment=recruitment, person=person) # Act - task = view.add_instructor_task(request, signup, person, event) + task = view.accept_signup(request, signup, person, event) # Assert self.assertTrue(task.pk) @@ -722,7 +722,7 @@ def test_remove_instructor_task(self) -> None: 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) + view.decline_signup(request, signup, person, event) # Assert with self.assertRaises(Task.DoesNotExist): task.refresh_from_db() @@ -744,7 +744,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 +935,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 b8c66de25..7e6d17ceb 100644 --- a/amy/recruitment/views.py +++ b/amy/recruitment/views.py @@ -401,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: @@ -421,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, @@ -465,7 +465,7 @@ def add_instructor_task( return task - def remove_instructor_task( + def decline_signup( self, request: HttpRequest, signup: InstructorRecruitmentSignup, From e930088c8d773ea1a3663bc4669496f63e5e5580 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Sun, 19 Jan 2025 21:47:48 +0100 Subject: [PATCH 41/46] feat(#2449): Don't remove task when declining instructor signup --- amy/recruitment/views.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/amy/recruitment/views.py b/amy/recruitment/views.py index 7e6d17ceb..b33dbc8d2 100644 --- a/amy/recruitment/views.py +++ b/amy/recruitment/views.py @@ -472,14 +472,18 @@ def decline_signup( 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) + messages.warning( + request, + format_html( + 'Instructor task was found. ' + 'The signup was declined.', + task.get_absolute_url(), + ), + ) except Task.DoesNotExist: pass - else: - task.delete() run_instructor_confirmed_for_workshop_strategy( instructor_confirmed_for_workshop_strategy(signup), From 73aa8669c2e30c6e83e25b077baf13a719d6f93a Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Sun, 19 Jan 2025 21:48:16 +0100 Subject: [PATCH 42/46] feat(#2449): Fine-tune verbiage in the messages shown to user --- amy/recruitment/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/amy/recruitment/views.py b/amy/recruitment/views.py index b33dbc8d2..c01b28199 100644 --- a/amy/recruitment/views.py +++ b/amy/recruitment/views.py @@ -438,8 +438,8 @@ def accept_signup( messages.warning( request, format_html( - 'Instructor task already exists, ' - "recruitment signup was accepted.", + "The signup was accepted, but instructor task already " + 'exists.', task.get_absolute_url(), ), ) @@ -477,8 +477,8 @@ def decline_signup( messages.warning( request, format_html( - 'Instructor task was found. ' - 'The signup was declined.', + "The signup was declined, but instructor task was " + 'found. ', task.get_absolute_url(), ), ) From 6a3bd1d702148ddccd9df270dcaab6548aa6da20 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Sun, 19 Jan 2025 21:58:07 +0100 Subject: [PATCH 43/46] feat(#2449): Fix tests related to accepting/declining signups --- .../test_instructor_recruitment_views.py | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/amy/recruitment/tests/test_instructor_recruitment_views.py b/amy/recruitment/tests/test_instructor_recruitment_views.py index bad139a85..d0840468e 100644 --- a/amy/recruitment/tests/test_instructor_recruitment_views.py +++ b/amy/recruitment/tests/test_instructor_recruitment_views.py @@ -680,7 +680,33 @@ def test_form_valid(self) -> None: ) 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("/") + 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) + role = Role.objects.get(name="instructor") + task = Task.objects.create(person=person, event=event, role=role) + # Act + 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("/") @@ -702,7 +728,8 @@ def test_add_instructor_task(self) -> None: # 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 + # Act & Assert - no error - task is not removed, but a warning is added view.decline_signup(request, signup, person, event) - # Assert - with self.assertRaises(Task.DoesNotExist): - task.refresh_from_db() + 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("/") From 28616aa44c7892fa871976cfb811312459aab778 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Mon, 20 Jan 2025 17:05:29 +0100 Subject: [PATCH 44/46] fix: Invalid model URL for person in Instructor Training Completed not badged --- amy/emails/actions/instructor_training_completed_not_badged.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amy/emails/actions/instructor_training_completed_not_badged.py b/amy/emails/actions/instructor_training_completed_not_badged.py index 9e241e2f9..4924c1366 100644 --- a/amy/emails/actions/instructor_training_completed_not_badged.py +++ b/amy/emails/actions/instructor_training_completed_not_badged.py @@ -197,7 +197,7 @@ def get_context_json( person = context["person"] return ContextModel( { - "person": api_model_url("event", person.pk), + "person": api_model_url("person", person.pk), "passed_requirements": [ api_model_url("trainingprogress", progress.pk) for progress in context["passed_requirements"] From ab573ae82e143b1b6585d060bff1806f52a1e554 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Mon, 20 Jan 2025 17:12:47 +0100 Subject: [PATCH 45/46] feat(CHANGELOG): Add v4.6 changelog entry --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5910e2df..00d231e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,27 @@ $ python docs/generate_changelog.py v4.1 Then paste output from that script here. ----------------------------------------------------------------- + +## v4.6 - 2025-01-20 + +### Bugfixes +* Emails: Improve managing instructor task in recruitments, new email action Instructor Task Created for Workshop - [#2739](https://github.com/carpentries/amy/pull/2739) by @pbanaszkiewicz + +### Features +* chore(deps): Bump django from 4.2.17 to 4.2.18 - [#2740](https://github.com/carpentries/amy/pull/2740) by @dependabot[bot] +* chore(deps-dev): Bump virtualenv from 20.26.2 to 20.26.6 - [#2738](https://github.com/carpentries/amy/pull/2738) by @dependabot[bot] +* chore(deps): Bump jinja2 from 3.1.4 to 3.1.5 - [#2736](https://github.com/carpentries/amy/pull/2736) by @dependabot[bot] +* Update CICD workflow - [#2735](https://github.com/carpentries/amy/pull/2735) by @pbanaszkiewicz +* Allow admins to see upcoming teaching opportunities - [#2734](https://github.com/carpentries/amy/pull/2734) by @pbanaszkiewicz +* Display tasks per role summary in instructor dashboard - [#2733](https://github.com/carpentries/amy/pull/2733) by @pbanaszkiewicz +* Leverage full-text search in Postgres to improve searching for people and training requests - [#2732](https://github.com/carpentries/amy/pull/2732) by @pbanaszkiewicz +* Multiple changes - [#2731](https://github.com/carpentries/amy/pull/2731) by @pbanaszkiewicz +* Updates admin user documentation about automated emails - [#2730](https://github.com/carpentries/amy/pull/2730) by @maneesha +* [Emails] Complex strategy for instructor declined for workshop - [#2729](https://github.com/carpentries/amy/pull/2729) by @pbanaszkiewicz +* Bump django from 4.2.16 to 4.2.17 - [#2727](https://github.com/carpentries/amy/pull/2727) by @dependabot[bot] +* Update upload-artifact version to v4 - [#2716](https://github.com/carpentries/amy/pull/2716) by @froggleston + + ## v4.5 - 2024-11-30 ### Features From 39757b693a7cfe4e3de6e8544527a85b4f298949 Mon Sep 17 00:00:00 2001 From: Piotr Banaszkiewicz Date: Mon, 20 Jan 2025 17:14:45 +0100 Subject: [PATCH 46/46] chore: Bump release version to v4.6 --- amy/__init__.py | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index 29b5d9758..ac7979a26 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -1 +1 @@ -__version__ = "v4.6.0-dev" +__version__ = "v4.6.0" diff --git a/package.json b/package.json index c0376ca79..05ad2f916 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amy", - "version": "v4.6.0-dev", + "version": "v4.6.0", "description": "A web-based workshop administration application for The Carpentries", "main": "index.js", "repository": "git@github.com:carpentries/amy.git",
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 }}