diff --git a/app/common/schemas/notifications_sch.py b/app/common/schemas/notifications_sch.py index 837453a5..5ed12b1a 100644 --- a/app/common/schemas/notifications_sch.py +++ b/app/common/schemas/notifications_sch.py @@ -1,7 +1,8 @@ from enum import StrEnum, auto from typing import Annotated, Literal +from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import AwareDatetime, BaseModel, Field from app.common.schemas.classrooms_sch import ClassroomRole @@ -17,6 +18,14 @@ class NotificationKind(StrEnum): RECIPIENT_INVOICE_CREATED_V1 = auto() STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1 = auto() + SINGLE_CLASSROOM_EVENT_CREATED_V1 = auto() + CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1 = auto() + CLASSROOM_EVENT_INSTANCE_CANCELLED_V1 = auto() + + REPEATING_CLASSROOM_EVENT_CREATED_V1 = auto() + CLASSROOM_EVENT_REPETITION_UPDATED_V1 = auto() + CLASSROOM_EVENT_REPETITION_CANCELLED_V1 = auto() + CUSTOM_V1 = auto() @@ -53,6 +62,28 @@ class RecipientInvoiceNotificationPayloadSchema(BaseModel): recipient_invoice_id: int +class ClassroomEventInstanceNotificationPayloadSchema(BaseModel): + kind: Literal[ + NotificationKind.SINGLE_CLASSROOM_EVENT_CREATED_V1, + NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1, + NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1, + ] + + classroom_id: int + event_instance_id: UUID + + +class ClassroomScheduleFocusNotificationPayloadSchema(BaseModel): + kind: Literal[ + NotificationKind.REPEATING_CLASSROOM_EVENT_CREATED_V1, + NotificationKind.CLASSROOM_EVENT_REPETITION_UPDATED_V1, + NotificationKind.CLASSROOM_EVENT_REPETITION_CANCELLED_V1, + ] + + classroom_id: int + focused_at: AwareDatetime + + class CustomNotificationPayloadSchema(BaseModel): kind: Literal[NotificationKind.CUSTOM_V1] @@ -69,6 +100,8 @@ class CustomNotificationPayloadSchema(BaseModel): | EnrollmentNotificationPayloadSchema | ClassroomNotificationPayloadSchema | RecipientInvoiceNotificationPayloadSchema + | ClassroomEventInstanceNotificationPayloadSchema + | ClassroomScheduleFocusNotificationPayloadSchema | CustomNotificationPayloadSchema, Field(discriminator="kind"), ] @@ -106,3 +139,6 @@ class NotificationInputV2Schema(BaseModel): list[AnyRecipientFilterSchema], Field(min_length=1, max_length=100), ] + + +# TODO (?) add recipient logic to payload instead? diff --git a/app/common/schemas/pochta_sch.py b/app/common/schemas/pochta_sch.py index 1d4d4f90..841c8c26 100644 --- a/app/common/schemas/pochta_sch.py +++ b/app/common/schemas/pochta_sch.py @@ -22,6 +22,8 @@ class EmailMessageKind(StrEnum): RECIPIENT_INVOICE_CREATED_V1 = auto() STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1 = auto() + UNIVERSAL_V3 = auto() + class CustomEmailMessagePayloadSchema(BaseModel): kind: Literal[EmailMessageKind.CUSTOM_V1] @@ -72,11 +74,23 @@ class RecipientInvoiceNotificationEmailMessagePayloadSchema( recipient_invoice_id: int +class UniversalEmailMessagePayloadSchema(BaseModel): + kind: Literal[EmailMessageKind.UNIVERSAL_V3] = EmailMessageKind.UNIVERSAL_V3 + + theme: str + pre_header: str + header: str + content: str + button_text: str + button_link: str + + AnyEmailMessagePayload = Annotated[ CustomEmailMessagePayloadSchema | TokenEmailMessagePayloadSchema | ClassroomNotificationEmailMessagePayloadSchema - | RecipientInvoiceNotificationEmailMessagePayloadSchema, + | RecipientInvoiceNotificationEmailMessagePayloadSchema + | UniversalEmailMessagePayloadSchema, Field(discriminator="kind"), ] diff --git a/app/notifications/services/adapters/base_adapter.py b/app/notifications/services/adapters/base_adapter.py index e4d789ae..900b6a16 100644 --- a/app/notifications/services/adapters/base_adapter.py +++ b/app/notifications/services/adapters/base_adapter.py @@ -1,8 +1,12 @@ from abc import ABC, abstractmethod -from typing import assert_never, cast +from typing import Any, assert_never, cast +from urllib.parse import urlencode +from app.common.config import settings from app.common.schemas.notifications_sch import ( + ClassroomEventInstanceNotificationPayloadSchema, ClassroomNotificationPayloadSchema, + ClassroomScheduleFocusNotificationPayloadSchema, CustomNotificationPayloadSchema, EnrollmentNotificationPayloadSchema, InvitationAcceptanceNotificationPayloadSchema, @@ -16,6 +20,39 @@ class BaseNotificationAdapter[T](ABC): def __init__(self, notification: Notification) -> None: self.notification = notification + def build_url(self, path: str, params: dict[str, Any]) -> str: + query_string = urlencode( + { + **params, + "read_notification_id": self.notification.id, + } + ) + return f"{settings.frontend_app_base_url}{path}?{query_string}" + + def build_student_classroom_event_instance_url( + self, payload: ClassroomEventInstanceNotificationPayloadSchema + ) -> str: + return self.build_url( + path=f"/classrooms/{payload.classroom_id}", + params={ + "tab": "schedule", + "role": "student", + "event_instance_id": payload.event_instance_id, + }, + ) + + def build_student_classroom_schedule_focus_url( + self, payload: ClassroomScheduleFocusNotificationPayloadSchema + ) -> str: + return self.build_url( + path=f"/classrooms/{payload.classroom_id}", + params={ + "tab": "schedule", + "role": "student", + "focused_at": payload.focused_at.isoformat(), + }, + ) + @abstractmethod def adapt_individual_invitation_accepted_v1( self, @@ -58,6 +95,48 @@ def adapt_student_recipient_invoice_payment_confirmed_v1( ) -> T: raise NotImplementedError + @abstractmethod + def adapt_single_classroom_event_created_v1( + self, + payload: ClassroomEventInstanceNotificationPayloadSchema, + ) -> T: + raise NotImplementedError + + @abstractmethod + def adapt_classroom_event_instance_rescheduled_v1( + self, + payload: ClassroomEventInstanceNotificationPayloadSchema, + ) -> T: + raise NotImplementedError + + @abstractmethod + def adapt_classroom_event_instance_cancelled_v1( + self, + payload: ClassroomEventInstanceNotificationPayloadSchema, + ) -> T: + raise NotImplementedError + + @abstractmethod + def adapt_repeating_classroom_event_created_v1( + self, + payload: ClassroomScheduleFocusNotificationPayloadSchema, + ) -> T: + raise NotImplementedError + + @abstractmethod + def adapt_classroom_event_repetition_updated_v1( + self, + payload: ClassroomScheduleFocusNotificationPayloadSchema, + ) -> T: + raise NotImplementedError + + @abstractmethod + def adapt_classroom_event_repetition_cancelled_v1( + self, + payload: ClassroomScheduleFocusNotificationPayloadSchema, + ) -> T: + raise NotImplementedError + @abstractmethod def adapt_custom_v1( self, @@ -93,6 +172,30 @@ def adapt(self) -> T: return self.adapt_student_recipient_invoice_payment_confirmed_v1( cast(RecipientInvoiceNotificationPayloadSchema, payload) ) + case NotificationKind.SINGLE_CLASSROOM_EVENT_CREATED_V1: + return self.adapt_single_classroom_event_created_v1( + cast(ClassroomEventInstanceNotificationPayloadSchema, payload) + ) + case NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1: + return self.adapt_classroom_event_instance_rescheduled_v1( + cast(ClassroomEventInstanceNotificationPayloadSchema, payload) + ) + case NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1: + return self.adapt_classroom_event_instance_cancelled_v1( + cast(ClassroomEventInstanceNotificationPayloadSchema, payload) + ) + case NotificationKind.REPEATING_CLASSROOM_EVENT_CREATED_V1: + return self.adapt_repeating_classroom_event_created_v1( + cast(ClassroomScheduleFocusNotificationPayloadSchema, payload) + ) + case NotificationKind.CLASSROOM_EVENT_REPETITION_UPDATED_V1: + return self.adapt_classroom_event_repetition_updated_v1( + cast(ClassroomScheduleFocusNotificationPayloadSchema, payload) + ) + case NotificationKind.CLASSROOM_EVENT_REPETITION_CANCELLED_V1: + return self.adapt_classroom_event_repetition_cancelled_v1( + cast(ClassroomScheduleFocusNotificationPayloadSchema, payload) + ) case NotificationKind.CUSTOM_V1: return self.adapt_custom_v1( cast(CustomNotificationPayloadSchema, payload) diff --git a/app/notifications/services/adapters/email_message_adapter.py b/app/notifications/services/adapters/email_message_adapter.py index ce6dfff5..56649d45 100644 --- a/app/notifications/services/adapters/email_message_adapter.py +++ b/app/notifications/services/adapters/email_message_adapter.py @@ -1,5 +1,7 @@ from app.common.schemas.notifications_sch import ( + ClassroomEventInstanceNotificationPayloadSchema, ClassroomNotificationPayloadSchema, + ClassroomScheduleFocusNotificationPayloadSchema, CustomNotificationPayloadSchema, EnrollmentNotificationPayloadSchema, InvitationAcceptanceNotificationPayloadSchema, @@ -11,7 +13,9 @@ CustomEmailMessagePayloadSchema, EmailMessageKind, RecipientInvoiceNotificationEmailMessagePayloadSchema, + UniversalEmailMessagePayloadSchema, ) +from app.notifications import texts from app.notifications.services.adapters.base_adapter import BaseNotificationAdapter @@ -78,6 +82,78 @@ def adapt_student_recipient_invoice_payment_confirmed_v1( notification_id=self.notification.id, ) + def adapt_single_classroom_event_created_v1( + self, payload: ClassroomEventInstanceNotificationPayloadSchema + ) -> UniversalEmailMessagePayloadSchema: + return UniversalEmailMessagePayloadSchema( + theme=texts.SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_THEME, + pre_header=texts.SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_PRE_HEADER, + header=texts.SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_HEADER, + content=texts.SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_CONTENT, + button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + button_link=self.build_student_classroom_event_instance_url(payload), + ) + + def adapt_classroom_event_instance_rescheduled_v1( + self, payload: ClassroomEventInstanceNotificationPayloadSchema + ) -> UniversalEmailMessagePayloadSchema: + return UniversalEmailMessagePayloadSchema( + theme=texts.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_THEME, + pre_header=texts.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_PRE_HEADER, + header=texts.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_HEADER, + content=texts.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_CONTENT, + button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + button_link=self.build_student_classroom_event_instance_url(payload), + ) + + def adapt_classroom_event_instance_cancelled_v1( + self, payload: ClassroomEventInstanceNotificationPayloadSchema + ) -> UniversalEmailMessagePayloadSchema: + return UniversalEmailMessagePayloadSchema( + theme=texts.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_THEME, + pre_header=texts.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_PRE_HEADER, + header=texts.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_HEADER, + content=texts.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_CONTENT, + button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + button_link=self.build_student_classroom_event_instance_url(payload), + ) + + def adapt_repeating_classroom_event_created_v1( + self, payload: ClassroomScheduleFocusNotificationPayloadSchema + ) -> UniversalEmailMessagePayloadSchema: + return UniversalEmailMessagePayloadSchema( + theme=texts.REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_THEME, + pre_header=texts.REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_PRE_HEADER, + header=texts.REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_HEADER, + content=texts.REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_CONTENT, + button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + button_link=self.build_student_classroom_schedule_focus_url(payload), + ) + + def adapt_classroom_event_repetition_updated_v1( + self, payload: ClassroomScheduleFocusNotificationPayloadSchema + ) -> UniversalEmailMessagePayloadSchema: + return UniversalEmailMessagePayloadSchema( + theme=texts.CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_THEME, + pre_header=texts.CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_PRE_HEADER, + header=texts.CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_HEADER, + content=texts.CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_CONTENT, + button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + button_link=self.build_student_classroom_schedule_focus_url(payload), + ) + + def adapt_classroom_event_repetition_cancelled_v1( + self, payload: ClassroomScheduleFocusNotificationPayloadSchema + ) -> UniversalEmailMessagePayloadSchema: + return UniversalEmailMessagePayloadSchema( + theme=texts.CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_THEME, + pre_header=texts.CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_PRE_HEADER, + header=texts.CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_HEADER, + content=texts.CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_CONTENT, + button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + button_link=self.build_student_classroom_schedule_focus_url(payload), + ) + def adapt_custom_v1( self, payload: CustomNotificationPayloadSchema ) -> CustomEmailMessagePayloadSchema: diff --git a/app/notifications/services/adapters/telegram_message_adapter.py b/app/notifications/services/adapters/telegram_message_adapter.py index 92314322..3bb1196d 100644 --- a/app/notifications/services/adapters/telegram_message_adapter.py +++ b/app/notifications/services/adapters/telegram_message_adapter.py @@ -1,11 +1,9 @@ -from typing import Any -from urllib.parse import urlencode - from pydantic import BaseModel -from app.common.config import settings from app.common.schemas.notifications_sch import ( + ClassroomEventInstanceNotificationPayloadSchema, ClassroomNotificationPayloadSchema, + ClassroomScheduleFocusNotificationPayloadSchema, CustomNotificationPayloadSchema, EnrollmentNotificationPayloadSchema, InvitationAcceptanceNotificationPayloadSchema, @@ -24,15 +22,6 @@ class TelegramMessagePayloadSchema(BaseModel): class NotificationToTelegramMessageAdapter( BaseNotificationAdapter[TelegramMessagePayloadSchema] ): - def build_url(self, path: str, params: dict[str, Any]) -> str: - query_string = urlencode( - { - **params, - "read_notification_id": self.notification.id, - } - ) - return f"{settings.frontend_app_base_url}{path}?{query_string}" - def adapt_individual_invitation_accepted_v1( self, payload: InvitationAcceptanceNotificationPayloadSchema, @@ -119,6 +108,60 @@ def adapt_student_recipient_invoice_payment_confirmed_v1( ), ) + def adapt_single_classroom_event_created_v1( + self, payload: ClassroomEventInstanceNotificationPayloadSchema + ) -> TelegramMessagePayloadSchema: + return TelegramMessagePayloadSchema( + message_text=texts.SINGLE_CLASSROOM_EVENT_CREATED_V1_MESSAGE, + button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + button_link=self.build_student_classroom_event_instance_url(payload), + ) + + def adapt_classroom_event_instance_rescheduled_v1( + self, payload: ClassroomEventInstanceNotificationPayloadSchema + ) -> TelegramMessagePayloadSchema: + return TelegramMessagePayloadSchema( + message_text=texts.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_MESSAGE, + button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + button_link=self.build_student_classroom_event_instance_url(payload), + ) + + def adapt_classroom_event_instance_cancelled_v1( + self, payload: ClassroomEventInstanceNotificationPayloadSchema + ) -> TelegramMessagePayloadSchema: + return TelegramMessagePayloadSchema( + message_text=texts.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_MESSAGE, + button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + button_link=self.build_student_classroom_event_instance_url(payload), + ) + + def adapt_repeating_classroom_event_created_v1( + self, payload: ClassroomScheduleFocusNotificationPayloadSchema + ) -> TelegramMessagePayloadSchema: + return TelegramMessagePayloadSchema( + message_text=texts.REPEATING_CLASSROOM_EVENT_CREATED_V1_MESSAGE, + button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + button_link=self.build_student_classroom_schedule_focus_url(payload), + ) + + def adapt_classroom_event_repetition_updated_v1( + self, payload: ClassroomScheduleFocusNotificationPayloadSchema + ) -> TelegramMessagePayloadSchema: + return TelegramMessagePayloadSchema( + message_text=texts.CLASSROOM_EVENT_REPETITION_UPDATED_V1_MESSAGE, + button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + button_link=self.build_student_classroom_schedule_focus_url(payload), + ) + + def adapt_classroom_event_repetition_cancelled_v1( + self, payload: ClassroomScheduleFocusNotificationPayloadSchema + ) -> TelegramMessagePayloadSchema: + return TelegramMessagePayloadSchema( + message_text=texts.CLASSROOM_EVENT_REPETITION_CANCELLED_V1_MESSAGE, + button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + button_link=self.build_student_classroom_schedule_focus_url(payload), + ) + def adapt_custom_v1( self, payload: CustomNotificationPayloadSchema, diff --git a/app/notifications/texts.py b/app/notifications/texts.py index 082854c4..850d4746 100644 --- a/app/notifications/texts.py +++ b/app/notifications/texts.py @@ -25,24 +25,153 @@ Для подключения уведомлений через бота посетите настройки на платформе """ -INDIVIDUAL_INVITATION_ACCEPTED_V1_MESSAGE = "У вас появился новый кабинет" -INDIVIDUAL_INVITATION_ACCEPTED_V1_BUTTON_TEXT = "Открыть →" +INDIVIDUAL_INVITATION_ACCEPTED_V1_MESSAGE = """ +У вас появился новый кабинет +""" +INDIVIDUAL_INVITATION_ACCEPTED_V1_BUTTON_TEXT = """ +Открыть → +""" + +GROUP_INVITATION_ACCEPTED_V1_MESSAGE = """ +В группе новый ученик +""" +GROUP_INVITATION_ACCEPTED_V1_BUTTON_TEXT = """ +Открыть группу → +""" + +ENROLLMENT_CREATED_V1_MESSAGE = """ +Вас добавили в группу +""" +ENROLLMENT_CREATED_V1_BUTTON_TEXT = """ +Открыть группу → +""" + +CLASSROOM_CONFERENCE_STARTED_V1_MESSAGE = """ +Занятие началось +""" +CLASSROOM_CONFERENCE_STARTED_V1_BUTTON_TEXT = """ +Присоединиться → +""" + +RECIPIENT_INVOICE_CREATED_V1_MESSAGE = """ +Вы получили новый счёт. Пожалуйста, оплатите его +""" +RECIPIENT_INVOICE_CREATED_V1_BUTTON_TEXT = """ +Посмотреть счёт → +""" + +STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1_MESSAGE = """ +Оплачен новый счёт. Подтвердите, что получили деньги +""" +STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1_BUTTON_TEXT = """ +Посмотреть счёт → +""" + +CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT = """ +Перейти к этому занятию +""" +CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT = """ +Перейти к расписанию +""" + +SINGLE_CLASSROOM_EVENT_CREATED_V1_MESSAGE = """ +В ваше расписание добавлено новое занятие. Перейдите по ссылке, чтобы узнать подробности +""" +SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_THEME = """ +Новое занятие в вашем расписании +""" +SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_PRE_HEADER = """ +Проверьте изменения в расписании +""" +SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_HEADER = """ +В ваше расписание добавлено новое занятие +""" +SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_CONTENT = """ +Перейдите по ссылке, чтобы узнать подробности +""" + +CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_MESSAGE = """ +Занятие перенесено -GROUP_INVITATION_ACCEPTED_V1_MESSAGE = "В группе новый ученик" -GROUP_INVITATION_ACCEPTED_V1_BUTTON_TEXT = "Открыть группу →" +Изменение касается только этого занятия. Перейдите по ссылке, чтобы узнать подробности +""" +CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_THEME = """ +Перенос занятия +""" +CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_PRE_HEADER = """ +Проверьте изменения в расписании +""" +CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_HEADER = """ +Занятие перенесено +""" +CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_CONTENT = """ +Изменение касается только этого занятия. Перейдите по ссылке, чтобы узнать подробности. +""" -ENROLLMENT_CREATED_V1_MESSAGE = "Вас добавили в группу" -ENROLLMENT_CREATED_V1_BUTTON_TEXT = "Открыть группу →" +CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_MESSAGE = """ +Занятие отменено -CLASSROOM_CONFERENCE_STARTED_V1_MESSAGE = "Занятие началось" -CLASSROOM_CONFERENCE_STARTED_V1_BUTTON_TEXT = "Присоединиться →" +Изменение касается только этого занятия. Перейдите по ссылке, чтобы узнать подробности +""" +CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_THEME = """ +Отмена занятия +""" +CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_PRE_HEADER = """ +Проверьте изменения в расписании +""" +CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_HEADER = """ +Занятие отменено +""" +CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_CONTENT = """ +Изменение касается только этого занятия. Перейдите по ссылке, чтобы узнать подробности. +""" -RECIPIENT_INVOICE_CREATED_V1_MESSAGE = ( - "Вы получили новый счёт. Пожалуйста, оплатите его" -) -RECIPIENT_INVOICE_CREATED_V1_BUTTON_TEXT = "Посмотреть счёт →" +REPEATING_CLASSROOM_EVENT_CREATED_V1_MESSAGE = """ +В ваше расписание добавлены новые регулярные занятия. Перейдите по ссылке, чтобы узнать подробности +""" +REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_THEME = """ +Новые регулярные занятия +""" +REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_PRE_HEADER = """ +Проверьте изменения в расписании +""" +REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_HEADER = """ +В ваше расписание добавлены новые регулярные занятия +""" +REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_CONTENT = """ +Перейдите по ссылке, чтобы узнать подробности. +""" + +CLASSROOM_EVENT_REPETITION_UPDATED_V1_MESSAGE = """ +Изменилось расписание регулярных занятий -STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1_MESSAGE = ( - "Оплачен новый счёт. Подтвердите, что получили деньги" -) -STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1_BUTTON_TEXT = "Посмотреть счёт →" +Изменения коснутся всех будущих занятий. Перейдите по ссылке, чтобы узнать подробности +""" +CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_THEME = """ +Изменилось расписание регулярных занятий +""" +CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_PRE_HEADER = """ +Проверьте изменения в расписании +""" +CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_HEADER = """ +Репетитор изменил расписание регулярных занятий +""" +CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_CONTENT = """ +Изменения коснутся всех будущих занятий. Перейдите по ссылке, чтобы узнать подробности. +""" + +CLASSROOM_EVENT_REPETITION_CANCELLED_V1_MESSAGE = """ +Некоторые будущие занятия отменены. Перейдите по ссылке, чтобы узнать подробности и посмотреть обновлённое расписание +""" +CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_THEME = """ +Некоторые будущие занятия отменены +""" +CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_PRE_HEADER = """ +Проверьте изменения в расписании +""" +CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_HEADER = """ +Репетитор отменил некоторые будущие занятия +""" +CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_CONTENT = """ +Перейдите по ссылке, чтобы узнать подробности и посмотреть обновлённое расписание. +""" diff --git a/app/pochta/routes/email_messages_sub.py b/app/pochta/routes/email_messages_sub.py index 0c2acab4..72c08897 100644 --- a/app/pochta/routes/email_messages_sub.py +++ b/app/pochta/routes/email_messages_sub.py @@ -29,6 +29,7 @@ EmailMessageKind.CLASSROOM_CONFERENCE_STARTED_V1: "0aef5510-b800-11f0-ad49-d2544595dc68", EmailMessageKind.RECIPIENT_INVOICE_CREATED_V1: "a466ca48-b800-11f0-80a2-d2544595dc68", EmailMessageKind.STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1: "9c5cd7cc-b7fe-11f0-8d2e-d2544595dc68", + EmailMessageKind.UNIVERSAL_V3: "e9e631ee-576a-11f1-a286-3e473ca83b89", } diff --git a/app/scheduler/routes/classroom_event_instances_rst.py b/app/scheduler/routes/classroom_event_instances_rst.py index 0742def7..505c35ea 100644 --- a/app/scheduler/routes/classroom_event_instances_rst.py +++ b/app/scheduler/routes/classroom_event_instances_rst.py @@ -3,7 +3,15 @@ from pydantic import AwareDatetime, BaseModel, Field from starlette import status +from app.common.config_bdg import notifications_bridge from app.common.fastapi_ext import APIRouterExt, Responses +from app.common.schemas.classrooms_sch import ClassroomRole +from app.common.schemas.notifications_sch import ( + ClassroomEventInstanceNotificationPayloadSchema, + ClassroomParticipantRecipientFilterSchema, + NotificationInputV2Schema, + NotificationKind, +) from app.common.utils.datetime import datetime_utc_now from app.scheduler.dependencies.event_instances_dep import ( ClassroomEventByInstanceID, @@ -97,7 +105,7 @@ class VirtualRepeatedEventInstanceDetailedResponseSchema( ), summary="Retrieve detailed data for any classroom event instance by id", ) -async def retrieve_detailed_classroom_event_instance( +async def retrieve_detailed_persisted_classroom_event_instance( classroom_event: ClassroomEventByInstanceID, event_instance: MyClassroomEventInstanceByIDs, ) -> EventInstanceDetailedResponseSchema: @@ -221,6 +229,7 @@ async def retrieve_detailed_repeated_classroom_event_instance( summary="Reschedule any classroom event instance by id", ) async def reschedule_persisted_classroom_event_instance( + classroom_event: ClassroomEventByInstanceID, event_instance: MyClassroomEventInstanceByIDs, data: EventInstanceTimeSlotInputSchema, ) -> None: @@ -229,6 +238,22 @@ async def reschedule_persisted_classroom_event_instance( new_ends_at=data.ends_at, ) + await notifications_bridge.send_notification( + NotificationInputV2Schema( + payload=ClassroomEventInstanceNotificationPayloadSchema( + kind=NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1, + classroom_id=classroom_event.classroom_id, + event_instance_id=event_instance.id, + ), + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=classroom_event.classroom_id, + role=ClassroomRole.STUDENT, + ) + ], + ) + ) + @router.put( path=( @@ -241,6 +266,7 @@ async def reschedule_persisted_classroom_event_instance( summary="Reschedule any classroom event instance in a repetition mode by id and index", ) async def reschedule_repeated_classroom_event_instance( + classroom_event: ClassroomEventByRepetitionModeID, repetition_mode: MyClassroomRepetitionModeByIDs, instance_index: EventInstanceIndex, data: EventInstanceTimeSlotInputSchema, @@ -253,21 +279,33 @@ async def reschedule_repeated_classroom_event_instance( if event_instance is None: # TODO (170) generate the actual event instance and check it's not outside of the range # TODO (170) check new time-slot is not equal to the generated one - await RepeatedEventInstance.create( + event_instance = await RepeatedEventInstance.create( event_id=repetition_mode.event_id, repetition_mode_id=repetition_mode.id, instance_index=instance_index, - starts_at_override=data.starts_at, - ends_at_override=data.ends_at, ) - # TODO(?) - # `event_instance = await create(...)` - # `event_instance.reschedule(...)` - else: - event_instance.reschedule( - new_starts_at=data.starts_at, - new_ends_at=data.ends_at, + + # TODO (170) check new time-slot is not equal to the previous one (in else) + event_instance.reschedule( + new_starts_at=data.starts_at, + new_ends_at=data.ends_at, + ) + + await notifications_bridge.send_notification( + NotificationInputV2Schema( + payload=ClassroomEventInstanceNotificationPayloadSchema( + kind=NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1, + classroom_id=classroom_event.classroom_id, + event_instance_id=event_instance.id, + ), + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=classroom_event.classroom_id, + role=ClassroomRole.STUDENT, + ) + ], ) + ) class EventInstanceCancellationResponses(Responses): @@ -288,12 +326,29 @@ class EventInstanceCancellationResponses(Responses): summary="Cancel any classroom event instance by id", ) async def cancel_persisted_classroom_event_instance( + classroom_event: ClassroomEventByInstanceID, event_instance: MyClassroomEventInstanceByIDs, ) -> None: if event_instance.cancelled_at is not None: raise EventInstanceCancellationResponses.EVENT_INSTANCE_ALREADY_CANCELLED event_instance.cancelled_at = datetime_utc_now() + await notifications_bridge.send_notification( + NotificationInputV2Schema( + payload=ClassroomEventInstanceNotificationPayloadSchema( + kind=NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1, + classroom_id=classroom_event.classroom_id, + event_instance_id=event_instance.id, + ), + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=classroom_event.classroom_id, + role=ClassroomRole.STUDENT, + ) + ], + ) + ) + @router.post( path=( @@ -307,6 +362,7 @@ async def cancel_persisted_classroom_event_instance( summary="Cancel any classroom event instance in a repetition mode by id and index", ) async def cancel_repeated_classroom_event_instance( + classroom_event: ClassroomEventByRepetitionModeID, repetition_mode: MyClassroomRepetitionModeByIDs, instance_index: EventInstanceIndex, ) -> None: @@ -316,7 +372,7 @@ async def cancel_repeated_classroom_event_instance( ) if event_instance is None: # TODO (170) generate the actual event instance and check it's not outside of the range - await RepeatedEventInstance.create( + event_instance = await RepeatedEventInstance.create( event_id=repetition_mode.event_id, repetition_mode_id=repetition_mode.id, instance_index=instance_index, @@ -327,6 +383,22 @@ async def cancel_repeated_classroom_event_instance( else: event_instance.cancelled_at = datetime_utc_now() + await notifications_bridge.send_notification( + NotificationInputV2Schema( + payload=ClassroomEventInstanceNotificationPayloadSchema( + kind=NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1, + classroom_id=classroom_event.classroom_id, + event_instance_id=event_instance.id, + ), + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=classroom_event.classroom_id, + role=ClassroomRole.STUDENT, + ) + ], + ) + ) + class EventInstanceUncancellationResponses(Responses): EVENT_INSTANCE_IS_NOT_CANCELLED = ( diff --git a/app/scheduler/routes/classroom_events_tutor_rst.py b/app/scheduler/routes/classroom_events_tutor_rst.py index 9ac9854e..132eff7d 100644 --- a/app/scheduler/routes/classroom_events_tutor_rst.py +++ b/app/scheduler/routes/classroom_events_tutor_rst.py @@ -6,7 +6,16 @@ from pydantic import AwareDatetime, BaseModel, Field from starlette import status +from app.common.config_bdg import notifications_bridge from app.common.fastapi_ext import APIRouterExt +from app.common.schemas.classrooms_sch import ClassroomRole +from app.common.schemas.notifications_sch import ( + ClassroomEventInstanceNotificationPayloadSchema, + ClassroomParticipantRecipientFilterSchema, + ClassroomScheduleFocusNotificationPayloadSchema, + NotificationInputV2Schema, + NotificationKind, +) from app.scheduler.dependencies.classroom_events_dep import MyClassroomEventByIDs from app.scheduler.models.event_instances_db import ( RepeatedEventInstance, @@ -89,6 +98,21 @@ async def create_classroom_event( **data.sole_instance.model_dump(), event_id=classroom_event.id, ) + await notifications_bridge.send_notification( + NotificationInputV2Schema( + payload=ClassroomEventInstanceNotificationPayloadSchema( + kind=NotificationKind.SINGLE_CLASSROOM_EVENT_CREATED_V1, + classroom_id=classroom_event.classroom_id, + event_instance_id=sole_instance.id, + ), + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=classroom_event.classroom_id, + role=ClassroomRole.STUDENT, + ) + ], + ) + ) return SingleEventResponseSchema( event=ClassroomEvent.ResponseSchema.model_validate( classroom_event, from_attributes=True @@ -99,9 +123,26 @@ async def create_classroom_event( ), ) case RepeatingEventInputSchema(): - repetition_mode = await data.repetition_mode.db_class.create( - **data.repetition_mode.model_dump(), - event_id=classroom_event.id, + repetition_mode: RepetitionMode = ( + await data.repetition_mode.db_class.create( + **data.repetition_mode.model_dump(), + event_id=classroom_event.id, + ) + ) + await notifications_bridge.send_notification( + NotificationInputV2Schema( + payload=ClassroomScheduleFocusNotificationPayloadSchema( + kind=NotificationKind.REPEATING_CLASSROOM_EVENT_CREATED_V1, + classroom_id=classroom_event.classroom_id, + focused_at=repetition_mode.starts_at, + ), + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=classroom_event.classroom_id, + role=ClassroomRole.STUDENT, + ) + ], + ) ) return RepeatingEventResponseSchema( event=ClassroomEvent.ResponseSchema.model_validate( @@ -188,11 +229,29 @@ async def create_last_repetition_mode( timestamp=data.starts_at, ) - return await data.db_class.create( + repetition_mode = await data.db_class.create( **data.model_dump(), event_id=classroom_event.id, ) + await notifications_bridge.send_notification( + NotificationInputV2Schema( + payload=ClassroomScheduleFocusNotificationPayloadSchema( + kind=NotificationKind.CLASSROOM_EVENT_REPETITION_UPDATED_V1, + classroom_id=classroom_event.classroom_id, + focused_at=repetition_mode.starts_at, + ), + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=classroom_event.classroom_id, + role=ClassroomRole.STUDENT, + ) + ], + ) + ) + + return repetition_mode + @router.post( path="/roles/tutor/classrooms/{classroom_id}/events/{event_id}/cancellations/", @@ -208,6 +267,22 @@ async def cancel_repeating_event_after_timestamp( timestamp=starts_at, ) + await notifications_bridge.send_notification( + NotificationInputV2Schema( + payload=ClassroomScheduleFocusNotificationPayloadSchema( + kind=NotificationKind.CLASSROOM_EVENT_REPETITION_CANCELLED_V1, + classroom_id=classroom_event.classroom_id, + focused_at=starts_at, + ), + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=classroom_event.classroom_id, + role=ClassroomRole.STUDENT, + ) + ], + ) + ) + @router.delete( path="/roles/tutor/classrooms/{classroom_id}/events/{event_id}/", diff --git a/tests/notifications/factories.py b/tests/notifications/factories.py index 0501c4a8..43e01d4b 100644 --- a/tests/notifications/factories.py +++ b/tests/notifications/factories.py @@ -32,6 +32,18 @@ class RecipientInvoiceNotificationPayloadFactory( __model__ = notifications_sch.RecipientInvoiceNotificationPayloadSchema +class ClassroomEventInstanceNotificationPayloadFactory( + BaseModelFactory[notifications_sch.ClassroomEventInstanceNotificationPayloadSchema] +): + __model__ = notifications_sch.ClassroomEventInstanceNotificationPayloadSchema + + +class ClassroomScheduleFocusNotificationPayloadFactory( + BaseModelFactory[notifications_sch.ClassroomScheduleFocusNotificationPayloadSchema] +): + __model__ = notifications_sch.ClassroomScheduleFocusNotificationPayloadSchema + + class CustomNotificationPayloadFactory( BaseModelFactory[notifications_sch.CustomNotificationPayloadSchema] ): diff --git a/tests/notifications/service/adapters/test_email_message_adapter.py b/tests/notifications/service/adapters/test_email_message_adapter.py index 21705493..2b155e82 100644 --- a/tests/notifications/service/adapters/test_email_message_adapter.py +++ b/tests/notifications/service/adapters/test_email_message_adapter.py @@ -1,9 +1,15 @@ +from typing import Any from unittest.mock import Mock +from urllib.parse import parse_qs, urlparse +from uuid import UUID import pytest +from pydantic import HttpUrl from pydantic_marshals.contains import assert_contains +from app.common.config import settings from app.common.schemas import notifications_sch, pochta_sch +from app.notifications import texts from app.notifications.services.adapters.email_message_adapter import ( NotificationToEmailMessageAdapter, ) @@ -156,6 +162,252 @@ async def test_student_recipient_invoice_payment_confirmed_v1_notification_adapt ) +def assert_universal_email_message_payload( + universal_email_message_payload: pochta_sch.UniversalEmailMessagePayloadSchema, + expected_notification_id: UUID, + expected_theme: str, + expected_pre_header: str, + expected_header: str, + expected_content: str, + expected_button_text: str, + expected_button_link_path: str, + expected_button_link_query: dict[str, list[Any]], +) -> None: + assert_contains( + universal_email_message_payload, + { + "theme": expected_theme, + "pre_header": expected_pre_header, + "header": expected_header, + "content": expected_content, + "button_text": expected_button_text, + "button_link": HttpUrl, + }, + ) + + assert universal_email_message_payload.button_link.startswith( + settings.frontend_app_base_url + ) + parsed_button_link = urlparse(universal_email_message_payload.button_link) + assert_contains( + { + "path": parsed_button_link.path, + "query": parse_qs(parsed_button_link.query), + }, + { + "path": expected_button_link_path, + "query": { + **expected_button_link_query, + "read_notification_id": [expected_notification_id], + }, + }, + ) + + +async def test_single_classroom_event_created_v1_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomEventInstanceNotificationPayloadSchema + ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.SINGLE_CLASSROOM_EVENT_CREATED_V1 + ) + notification_mock.payload = notification_payload + + email_notification_adapter = NotificationToEmailMessageAdapter( + notification=notification_mock + ) + + result = email_notification_adapter.adapt() + assert isinstance(result, pochta_sch.UniversalEmailMessagePayloadSchema) + + assert_universal_email_message_payload( + result, + expected_notification_id=notification_mock.id, + expected_theme=texts.SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_THEME, + expected_pre_header=texts.SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_PRE_HEADER, + expected_header=texts.SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_HEADER, + expected_content=texts.SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_CONTENT, + expected_button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "event_instance_id": [str(notification_payload.event_instance_id)], + }, + ) + + +async def test_classroom_event_instance_rescheduled_v1_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomEventInstanceNotificationPayloadSchema + ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1 + ) + notification_mock.payload = notification_payload + + email_notification_adapter = NotificationToEmailMessageAdapter( + notification=notification_mock + ) + + result = email_notification_adapter.adapt() + assert isinstance(result, pochta_sch.UniversalEmailMessagePayloadSchema) + + assert_universal_email_message_payload( + result, + expected_notification_id=notification_mock.id, + expected_theme=texts.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_THEME, + expected_pre_header=texts.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_PRE_HEADER, + expected_header=texts.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_HEADER, + expected_content=texts.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_CONTENT, + expected_button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "event_instance_id": [str(notification_payload.event_instance_id)], + }, + ) + + +async def test_classroom_event_instance_cancelled_v1_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomEventInstanceNotificationPayloadSchema + ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1 + ) + notification_mock.payload = notification_payload + + email_notification_adapter = NotificationToEmailMessageAdapter( + notification=notification_mock + ) + + result = email_notification_adapter.adapt() + assert isinstance(result, pochta_sch.UniversalEmailMessagePayloadSchema) + + assert_universal_email_message_payload( + result, + expected_notification_id=notification_mock.id, + expected_theme=texts.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_THEME, + expected_pre_header=texts.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_PRE_HEADER, + expected_header=texts.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_HEADER, + expected_content=texts.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_CONTENT, + expected_button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "event_instance_id": [str(notification_payload.event_instance_id)], + }, + ) + + +async def test_repeating_classroom_event_created_v1_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomScheduleFocusNotificationPayloadSchema + ) = factories.ClassroomScheduleFocusNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.REPEATING_CLASSROOM_EVENT_CREATED_V1 + ) + notification_mock.payload = notification_payload + + email_notification_adapter = NotificationToEmailMessageAdapter( + notification=notification_mock + ) + + result = email_notification_adapter.adapt() + assert isinstance(result, pochta_sch.UniversalEmailMessagePayloadSchema) + + assert_universal_email_message_payload( + result, + expected_notification_id=notification_mock.id, + expected_theme=texts.REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_THEME, + expected_pre_header=texts.REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_PRE_HEADER, + expected_header=texts.REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_HEADER, + expected_content=texts.REPEATING_CLASSROOM_EVENT_CREATED_V1_EMAIL_CONTENT, + expected_button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "focused_at": [notification_payload.focused_at.isoformat()], + }, + ) + + +async def test_classroom_event_repetition_updated_v1_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomScheduleFocusNotificationPayloadSchema + ) = factories.ClassroomScheduleFocusNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_REPETITION_UPDATED_V1 + ) + notification_mock.payload = notification_payload + + email_notification_adapter = NotificationToEmailMessageAdapter( + notification=notification_mock + ) + + result = email_notification_adapter.adapt() + assert isinstance(result, pochta_sch.UniversalEmailMessagePayloadSchema) + + assert_universal_email_message_payload( + result, + expected_notification_id=notification_mock.id, + expected_theme=texts.CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_THEME, + expected_pre_header=texts.CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_PRE_HEADER, + expected_header=texts.CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_HEADER, + expected_content=texts.CLASSROOM_EVENT_REPETITION_UPDATED_V1_EMAIL_CONTENT, + expected_button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "focused_at": [notification_payload.focused_at.isoformat()], + }, + ) + + +async def test_classroom_event_repetition_cancelled_v1_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomScheduleFocusNotificationPayloadSchema + ) = factories.ClassroomScheduleFocusNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_REPETITION_CANCELLED_V1 + ) + notification_mock.payload = notification_payload + + email_notification_adapter = NotificationToEmailMessageAdapter( + notification=notification_mock + ) + + result = email_notification_adapter.adapt() + assert isinstance(result, pochta_sch.UniversalEmailMessagePayloadSchema) + + assert_universal_email_message_payload( + result, + expected_notification_id=notification_mock.id, + expected_theme=texts.CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_THEME, + expected_pre_header=texts.CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_PRE_HEADER, + expected_header=texts.CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_HEADER, + expected_content=texts.CLASSROOM_EVENT_REPETITION_CANCELLED_V1_EMAIL_CONTENT, + expected_button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "focused_at": [notification_payload.focused_at.isoformat()], + }, + ) + + async def test_custom_v1_notification_adapting( notification_mock: Mock, ) -> None: diff --git a/tests/notifications/service/adapters/test_telegram_message_adapter.py b/tests/notifications/service/adapters/test_telegram_message_adapter.py index 6843648c..d01fc35b 100644 --- a/tests/notifications/service/adapters/test_telegram_message_adapter.py +++ b/tests/notifications/service/adapters/test_telegram_message_adapter.py @@ -220,6 +220,174 @@ async def test_student_recipient_invoice_payment_confirmed_v1_notification_adapt ) +async def test_single_classroom_event_created_v1_notification_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomEventInstanceNotificationPayloadSchema + ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.SINGLE_CLASSROOM_EVENT_CREATED_V1 + ) + notification_mock.payload = notification_payload + + telegram_notification_adapter = NotificationToTelegramMessageAdapter( + notification=notification_mock + ) + + assert_telegram_message_payload( + telegram_notification_adapter.adapt(), + expected_notification_id=notification_mock.id, + expected_message_text=texts.SINGLE_CLASSROOM_EVENT_CREATED_V1_MESSAGE, + expected_button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "event_instance_id": [str(notification_payload.event_instance_id)], + }, + ) + + +async def test_classroom_event_instance_rescheduled_v1_notification_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomEventInstanceNotificationPayloadSchema + ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1 + ) + notification_mock.payload = notification_payload + + telegram_notification_adapter = NotificationToTelegramMessageAdapter( + notification=notification_mock + ) + + assert_telegram_message_payload( + telegram_notification_adapter.adapt(), + expected_notification_id=notification_mock.id, + expected_message_text=texts.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_MESSAGE, + expected_button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "event_instance_id": [str(notification_payload.event_instance_id)], + }, + ) + + +async def test_classroom_event_instance_cancelled_v1_notification_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomEventInstanceNotificationPayloadSchema + ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1 + ) + notification_mock.payload = notification_payload + + telegram_notification_adapter = NotificationToTelegramMessageAdapter( + notification=notification_mock + ) + + assert_telegram_message_payload( + telegram_notification_adapter.adapt(), + expected_notification_id=notification_mock.id, + expected_message_text=texts.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_MESSAGE, + expected_button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "event_instance_id": [str(notification_payload.event_instance_id)], + }, + ) + + +async def test_repeating_classroom_event_created_v1_notification_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomScheduleFocusNotificationPayloadSchema + ) = factories.ClassroomScheduleFocusNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.REPEATING_CLASSROOM_EVENT_CREATED_V1 + ) + notification_mock.payload = notification_payload + + telegram_notification_adapter = NotificationToTelegramMessageAdapter( + notification=notification_mock + ) + + assert_telegram_message_payload( + telegram_notification_adapter.adapt(), + expected_notification_id=notification_mock.id, + expected_message_text=texts.REPEATING_CLASSROOM_EVENT_CREATED_V1_MESSAGE, + expected_button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "focused_at": [notification_payload.focused_at.isoformat()], + }, + ) + + +async def test_classroom_event_repetition_updated_v1_notification_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomScheduleFocusNotificationPayloadSchema + ) = factories.ClassroomScheduleFocusNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_REPETITION_UPDATED_V1 + ) + notification_mock.payload = notification_payload + + telegram_notification_adapter = NotificationToTelegramMessageAdapter( + notification=notification_mock + ) + + assert_telegram_message_payload( + telegram_notification_adapter.adapt(), + expected_notification_id=notification_mock.id, + expected_message_text=texts.CLASSROOM_EVENT_REPETITION_UPDATED_V1_MESSAGE, + expected_button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "focused_at": [notification_payload.focused_at.isoformat()], + }, + ) + + +async def test_classroom_event_repetition_cancelled_v1_notification_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.ClassroomScheduleFocusNotificationPayloadSchema + ) = factories.ClassroomScheduleFocusNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_REPETITION_CANCELLED_V1 + ) + notification_mock.payload = notification_payload + + telegram_notification_adapter = NotificationToTelegramMessageAdapter( + notification=notification_mock + ) + + assert_telegram_message_payload( + telegram_notification_adapter.adapt(), + expected_notification_id=notification_mock.id, + expected_message_text=texts.CLASSROOM_EVENT_REPETITION_CANCELLED_V1_MESSAGE, + expected_button_text=texts.CLASSROOM_SCHEDULE_FOCUS_BUTTON_TEXT, + expected_button_link_path=f"/classrooms/{notification_payload.classroom_id}", + expected_button_link_query={ + "tab": ["schedule"], + "role": ["student"], + "focused_at": [notification_payload.focused_at.isoformat()], + }, + ) + + async def test_custom_v1_notification_adapting( notification_mock: Mock, ) -> None: diff --git a/tests/pochta/factories.py b/tests/pochta/factories.py index 22423d55..dbf32335 100644 --- a/tests/pochta/factories.py +++ b/tests/pochta/factories.py @@ -1,13 +1,7 @@ from polyfactory import Use from pydantic import BaseModel -from app.common.schemas.pochta_sch import ( - ClassroomNotificationEmailMessagePayloadSchema, - CustomEmailMessagePayloadSchema, - EmailMessageInputSchema, - RecipientInvoiceNotificationEmailMessagePayloadSchema, - TokenEmailMessagePayloadSchema, -) +from app.common.schemas import pochta_sch from app.pochta.schemas.unisender_go_sch import ( UnisenderGoSendEmailSuccessfulResponseSchema, ) @@ -27,29 +21,37 @@ class EmailFormDataFactory(BaseModelFactory[EmailFormDataSchema]): class CustomEmailMessagePayloadFactory( - BaseModelFactory[CustomEmailMessagePayloadSchema] + BaseModelFactory[pochta_sch.CustomEmailMessagePayloadSchema] ): - __model__ = CustomEmailMessagePayloadSchema + __model__ = pochta_sch.CustomEmailMessagePayloadSchema -class TokenEmailMessagePayloadFactory(BaseModelFactory[TokenEmailMessagePayloadSchema]): - __model__ = TokenEmailMessagePayloadSchema +class TokenEmailMessagePayloadFactory( + BaseModelFactory[pochta_sch.TokenEmailMessagePayloadSchema] +): + __model__ = pochta_sch.TokenEmailMessagePayloadSchema class ClassroomNotificationEmailMessagePayloadFactory( - BaseModelFactory[ClassroomNotificationEmailMessagePayloadSchema] + BaseModelFactory[pochta_sch.ClassroomNotificationEmailMessagePayloadSchema] ): - __model__ = ClassroomNotificationEmailMessagePayloadSchema + __model__ = pochta_sch.ClassroomNotificationEmailMessagePayloadSchema class RecipientInvoiceNotificationEmailMessagePayloadFactory( - BaseModelFactory[RecipientInvoiceNotificationEmailMessagePayloadSchema] + BaseModelFactory[pochta_sch.RecipientInvoiceNotificationEmailMessagePayloadSchema] +): + __model__ = pochta_sch.RecipientInvoiceNotificationEmailMessagePayloadSchema + + +class UniversalEmailMessagePayloadFactory( + BaseModelFactory[pochta_sch.UniversalEmailMessagePayloadSchema] ): - __model__ = RecipientInvoiceNotificationEmailMessagePayloadSchema + __model__ = pochta_sch.UniversalEmailMessagePayloadSchema -class EmailMessageInputFactory(BaseModelFactory[EmailMessageInputSchema]): - __model__ = EmailMessageInputSchema +class EmailMessageInputFactory(BaseModelFactory[pochta_sch.EmailMessageInputSchema]): + __model__ = pochta_sch.EmailMessageInputSchema class UnisenderGoSendEmailSuccessfulResponseFactory( diff --git a/tests/pochta/functional/test_email_messages_sub.py b/tests/pochta/functional/test_email_messages_sub.py index 63099c19..052a51a0 100644 --- a/tests/pochta/functional/test_email_messages_sub.py +++ b/tests/pochta/functional/test_email_messages_sub.py @@ -79,6 +79,11 @@ factories.RecipientInvoiceNotificationEmailMessagePayloadFactory, id="student_recipient_invoice_payment_confirmed_v1", ), + pytest.param( + EmailMessageKind.UNIVERSAL_V3, + factories.UniversalEmailMessagePayloadFactory, + id="universal_v3", + ), ], ) async def test_email_message_sending(