diff --git a/.flake8 b/.flake8 index 2d2c1e88..d50af65a 100644 --- a/.flake8 +++ b/.flake8 @@ -28,7 +28,7 @@ extend-ignore = # # weird PIE803 C101 FNE007 FNE008 N812 ANN101 ANN102 PT004 WPS110 WPS111 WPS114 WPS338 WPS407 WPS414 WPS440 VNE001 VNE002 CM001 # too many - WPS200 WPS201 WPS202 WPS203 WPS204 WPS210 WPS211 WPS212 WPS213 WPS214 WPS217 WPS218 WPS221 WPS222 WPS224 WPS230 WPS231 WPS234 WPS235 WPS238 + WPS200 WPS201 WPS202 WPS203 WPS204 WPS210 WPS211 WPS212 WPS213 WPS214 WPS217 WPS218 WPS221 WPS222 WPS224 WPS229 WPS230 WPS231 WPS234 WPS235 WPS238 # "vague" imports WPS347 diff --git a/alembic/versions/058_notification_idempotency.py b/alembic/versions/058_notification_idempotency.py new file mode 100644 index 00000000..ef926c5d --- /dev/null +++ b/alembic/versions/058_notification_idempotency.py @@ -0,0 +1,55 @@ +"""notification-idempotency + +Revision ID: 058 +Revises: 057 +Create Date: 2026-06-15 00:57:00.751009 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "058" +down_revision: Union[str, None] = "057" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "notifications", + sa.Column("idempotency_key", sa.String(length=100), nullable=True), + schema="xi_back_2", + ) + op.add_column( + "notifications", + sa.Column("idempotency_expires_at", sa.DateTime(timezone=True), nullable=True), + schema="xi_back_2", + ) + op.create_index( + "unique_index_notifications_idempotency", + "notifications", + ["idempotency_key"], + unique=True, + schema="xi_back_2", + postgresql_where=sa.text("idempotency_key IS NOT NULL"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "unique_index_notifications_idempotency", + table_name="notifications", + schema="xi_back_2", + postgresql_where=sa.text("idempotency_key IS NOT NULL"), + ) + op.drop_column("notifications", "idempotency_expires_at", schema="xi_back_2") + op.drop_column("notifications", "idempotency_key", schema="xi_back_2") + # ### end Alembic commands ### diff --git a/app/common/schemas/notifications_sch.py b/app/common/schemas/notifications_sch.py index 5ed12b1a..0974c686 100644 --- a/app/common/schemas/notifications_sch.py +++ b/app/common/schemas/notifications_sch.py @@ -19,13 +19,17 @@ class NotificationKind(StrEnum): STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1 = auto() SINGLE_CLASSROOM_EVENT_CREATED_V1 = auto() - CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1 = auto() + CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1 = auto() # TODO add `PERSISTED_` for V2 CLASSROOM_EVENT_INSTANCE_CANCELLED_V1 = auto() + CLASSROOM_EVENT_INSTANCE_REMINDER_V1 = auto() REPEATING_CLASSROOM_EVENT_CREATED_V1 = auto() CLASSROOM_EVENT_REPETITION_UPDATED_V1 = auto() CLASSROOM_EVENT_REPETITION_CANCELLED_V1 = auto() + PERSISTED_CLASSROOM_EVENT_INSTANCE_REMINDER_V1 = auto() + REPEATED_CLASSROOM_EVENT_INSTANCE_REMINDER_V1 = auto() + CUSTOM_V1 = auto() @@ -62,17 +66,26 @@ class RecipientInvoiceNotificationPayloadSchema(BaseModel): recipient_invoice_id: int -class ClassroomEventInstanceNotificationPayloadSchema(BaseModel): +class PersistedClassroomEventInstanceNotificationPayloadSchema(BaseModel): kind: Literal[ NotificationKind.SINGLE_CLASSROOM_EVENT_CREATED_V1, NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1, NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1, + NotificationKind.PERSISTED_CLASSROOM_EVENT_INSTANCE_REMINDER_V1, ] classroom_id: int event_instance_id: UUID +class RepeatedClassroomEventInstanceNotificationPayloadSchema(BaseModel): + kind: Literal[NotificationKind.REPEATED_CLASSROOM_EVENT_INSTANCE_REMINDER_V1,] + + classroom_id: int + repetition_mode_id: UUID + instance_index: int + + class ClassroomScheduleFocusNotificationPayloadSchema(BaseModel): kind: Literal[ NotificationKind.REPEATING_CLASSROOM_EVENT_CREATED_V1, @@ -100,7 +113,8 @@ class CustomNotificationPayloadSchema(BaseModel): | EnrollmentNotificationPayloadSchema | ClassroomNotificationPayloadSchema | RecipientInvoiceNotificationPayloadSchema - | ClassroomEventInstanceNotificationPayloadSchema + | PersistedClassroomEventInstanceNotificationPayloadSchema + | RepeatedClassroomEventInstanceNotificationPayloadSchema | ClassroomScheduleFocusNotificationPayloadSchema | CustomNotificationPayloadSchema, Field(discriminator="kind"), @@ -133,12 +147,17 @@ class ClassroomParticipantRecipientFilterSchema(BaseModel): ] +IdempotencyKeyType = Annotated[str | None, Field(min_length=1, max_length=100)] + + class NotificationInputV2Schema(BaseModel): payload: AnyNotificationPayloadSchema recipient_filters: Annotated[ list[AnyRecipientFilterSchema], Field(min_length=1, max_length=100), ] + idempotency_key: IdempotencyKeyType = None + idempotency_expires_at: AwareDatetime | None = None # TODO (?) add recipient logic to payload instead? diff --git a/app/common/sqlalchemy_ext.py b/app/common/sqlalchemy_ext.py index 42b5e969..07d67f91 100644 --- a/app/common/sqlalchemy_ext.py +++ b/app/common/sqlalchemy_ext.py @@ -63,7 +63,7 @@ async def get_all_with_assumed_limit( result = list(await self.get_all(stmt.limit(limit))) if len(result) == limit: - logging.warning( + logging.error( f"Reached the limit of {limit} in one query", extra={"stmt": str(stmt)}, ) diff --git a/app/main.py b/app/main.py index 7440418c..23b8441d 100644 --- a/app/main.py +++ b/app/main.py @@ -102,10 +102,18 @@ async def consume_scope( self, call_next: Callable[[Any], Awaitable[Any]], msg: StreamMessage[Any], - ) -> Any: - async with sessionmaker.begin() as session: + ) -> Any: # pragma: no cover + async with sessionmaker() as session: session_context.set(session) - return await call_next(msg) + try: + result = await call_next(msg) + if session.in_transaction(): + await session.commit() + return result + except Exception: + if session.in_transaction(): + await session.rollback() + raise faststream = RedisRouter( diff --git a/app/notifications/models/notifications_db.py b/app/notifications/models/notifications_db.py index 87e1a2ea..b0dd4c38 100644 --- a/app/notifications/models/notifications_db.py +++ b/app/notifications/models/notifications_db.py @@ -4,7 +4,7 @@ from pydantic import AwareDatetime, BaseModel, Field, TypeAdapter from pydantic_marshals.sqlalchemy import MappedModel -from sqlalchemy import DateTime +from sqlalchemy import DateTime, Index, String from sqlalchemy.orm import Mapped, mapped_column from app.common.config import Base @@ -36,6 +36,21 @@ class Notification(Base): PydanticJSONType(TypeAdapter(AnyNotificationPayloadSchema)) ) + idempotency_key: Mapped[str | None] = mapped_column(String(100)) + idempotency_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + default=None, + ) + + __table_args__ = ( + Index( + "unique_index_notifications_idempotency", + idempotency_key, + unique=True, + postgresql_where=idempotency_key.is_not(None), + ), + ) + ResponseSchema = MappedModel.create( columns=[ id, @@ -43,3 +58,24 @@ class Notification(Base): (payload, AnyNotificationPayloadSchema), ] ) + + @classmethod + async def is_idempotency_violated( + cls, idempotency_key: str | None + ) -> bool: # pragma: no cover + if idempotency_key is None: + return False + + result = await cls.find_first_by_kwargs(idempotency_key=idempotency_key) + if result is None: + return False + + if ( + result.idempotency_expires_at is not None + and result.idempotency_expires_at < datetime_utc_now() + ): + result.idempotency_key = None + result.idempotency_expires_at = None + return False + + return True diff --git a/app/notifications/routes/notifications_sub.py b/app/notifications/routes/notifications_sub.py index 91286334..335518cc 100644 --- a/app/notifications/routes/notifications_sub.py +++ b/app/notifications/routes/notifications_sub.py @@ -1,10 +1,12 @@ import asyncio +import sentry_sdk from faststream.redis import RedisRouter from app.common.config import settings from app.common.faststream_ext import build_stream_sub from app.common.schemas.notifications_sch import NotificationInputV2Schema +from app.common.sqlalchemy_ext import db from app.notifications.models.notifications_db import Notification from app.notifications.models.recipient_notifications_db import RecipientNotification from app.notifications.routes.notifications_sio import NewNotificationEmitter @@ -29,6 +31,10 @@ async def send_notification( emitter: NewNotificationEmitter, data: NotificationInputV2Schema, ) -> None: + if await Notification.is_idempotency_violated(idempotency_key=data.idempotency_key): + # TODO (?) catch the integrity error instead + return # pragma: no cover + recipient_user_ids = ( await recipients_svc.generate_recipient_user_ids_for_notification( notification_data=data, @@ -38,7 +44,11 @@ async def send_notification( if len(recipient_user_ids) == 0: return - notification = await Notification.create(payload=data.payload) + notification = await Notification.create( + payload=data.payload, + idempotency_key=data.idempotency_key, + idempotency_expires_at=data.idempotency_expires_at, + ) await RecipientNotification.create_batch( { @@ -48,7 +58,11 @@ async def send_notification( for recipient_user_id in recipient_user_ids ) - await asyncio.gather( + await db.session.commit() + # TODO The commit is here to ensure idempotency, but that's not reliable + # in future split this into multiple events (first save to db, then send) + + results = await asyncio.gather( *platform_notification_sender.PlatformNotificationSender( notification=notification, emitter=emitter, @@ -59,5 +73,10 @@ async def send_notification( *telegram_notification_sender.TelegramNotificationSender( notification=notification, ).generate_tasks(recipient_user_ids=recipient_user_ids), - # TODO handle partial failure with `return_exceptions=True` + return_exceptions=True, ) + + for result in results: + if result is None: + continue + sentry_sdk.capture_exception(result) diff --git a/app/notifications/services/adapters/base_adapter.py b/app/notifications/services/adapters/base_adapter.py index 900b6a16..c8f0ed76 100644 --- a/app/notifications/services/adapters/base_adapter.py +++ b/app/notifications/services/adapters/base_adapter.py @@ -4,14 +4,15 @@ from app.common.config import settings from app.common.schemas.notifications_sch import ( - ClassroomEventInstanceNotificationPayloadSchema, ClassroomNotificationPayloadSchema, ClassroomScheduleFocusNotificationPayloadSchema, CustomNotificationPayloadSchema, EnrollmentNotificationPayloadSchema, InvitationAcceptanceNotificationPayloadSchema, NotificationKind, + PersistedClassroomEventInstanceNotificationPayloadSchema, RecipientInvoiceNotificationPayloadSchema, + RepeatedClassroomEventInstanceNotificationPayloadSchema, ) from app.notifications.models.notifications_db import Notification @@ -29,26 +30,36 @@ def build_url(self, path: str, params: dict[str, Any]) -> str: ) return f"{settings.frontend_app_base_url}{path}?{query_string}" - def build_student_classroom_event_instance_url( - self, payload: ClassroomEventInstanceNotificationPayloadSchema + def build_persisted_classroom_event_instance_url( + self, payload: PersistedClassroomEventInstanceNotificationPayloadSchema ) -> 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( + def build_repeated_classroom_event_instance_url( + self, payload: RepeatedClassroomEventInstanceNotificationPayloadSchema + ) -> str: + return self.build_url( + path=f"/classrooms/{payload.classroom_id}", + params={ + "tab": "schedule", + "repetition_mode_id": payload.repetition_mode_id, + "instance_index": payload.instance_index, + }, + ) + + def build_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(), }, ) @@ -98,21 +109,35 @@ def adapt_student_recipient_invoice_payment_confirmed_v1( @abstractmethod def adapt_single_classroom_event_created_v1( self, - payload: ClassroomEventInstanceNotificationPayloadSchema, + payload: PersistedClassroomEventInstanceNotificationPayloadSchema, ) -> T: raise NotImplementedError @abstractmethod def adapt_classroom_event_instance_rescheduled_v1( self, - payload: ClassroomEventInstanceNotificationPayloadSchema, + payload: PersistedClassroomEventInstanceNotificationPayloadSchema, ) -> T: raise NotImplementedError @abstractmethod def adapt_classroom_event_instance_cancelled_v1( self, - payload: ClassroomEventInstanceNotificationPayloadSchema, + payload: PersistedClassroomEventInstanceNotificationPayloadSchema, + ) -> T: + raise NotImplementedError + + @abstractmethod + def adapt_persisted_classroom_event_instance_reminder_v1( + self, + payload: PersistedClassroomEventInstanceNotificationPayloadSchema, + ) -> T: + raise NotImplementedError + + @abstractmethod + def adapt_repeated_classroom_event_instance_reminder_v1( + self, + payload: RepeatedClassroomEventInstanceNotificationPayloadSchema, ) -> T: raise NotImplementedError @@ -174,15 +199,37 @@ def adapt(self) -> T: ) case NotificationKind.SINGLE_CLASSROOM_EVENT_CREATED_V1: return self.adapt_single_classroom_event_created_v1( - cast(ClassroomEventInstanceNotificationPayloadSchema, payload) + cast( + PersistedClassroomEventInstanceNotificationPayloadSchema, + payload, + ) ) case NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1: return self.adapt_classroom_event_instance_rescheduled_v1( - cast(ClassroomEventInstanceNotificationPayloadSchema, payload) + cast( + PersistedClassroomEventInstanceNotificationPayloadSchema, + payload, + ) ) case NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1: return self.adapt_classroom_event_instance_cancelled_v1( - cast(ClassroomEventInstanceNotificationPayloadSchema, payload) + cast( + PersistedClassroomEventInstanceNotificationPayloadSchema, + payload, + ) + ) + case NotificationKind.PERSISTED_CLASSROOM_EVENT_INSTANCE_REMINDER_V1: + return self.adapt_persisted_classroom_event_instance_reminder_v1( + cast( + PersistedClassroomEventInstanceNotificationPayloadSchema, + payload, + ) + ) + case NotificationKind.REPEATED_CLASSROOM_EVENT_INSTANCE_REMINDER_V1: + return self.adapt_repeated_classroom_event_instance_reminder_v1( + cast( + RepeatedClassroomEventInstanceNotificationPayloadSchema, payload + ) ) case NotificationKind.REPEATING_CLASSROOM_EVENT_CREATED_V1: return self.adapt_repeating_classroom_event_created_v1( diff --git a/app/notifications/services/adapters/email_message_adapter.py b/app/notifications/services/adapters/email_message_adapter.py index 56649d45..8c8c5a1d 100644 --- a/app/notifications/services/adapters/email_message_adapter.py +++ b/app/notifications/services/adapters/email_message_adapter.py @@ -1,11 +1,12 @@ from app.common.schemas.notifications_sch import ( - ClassroomEventInstanceNotificationPayloadSchema, ClassroomNotificationPayloadSchema, ClassroomScheduleFocusNotificationPayloadSchema, CustomNotificationPayloadSchema, EnrollmentNotificationPayloadSchema, InvitationAcceptanceNotificationPayloadSchema, + PersistedClassroomEventInstanceNotificationPayloadSchema, RecipientInvoiceNotificationPayloadSchema, + RepeatedClassroomEventInstanceNotificationPayloadSchema, ) from app.common.schemas.pochta_sch import ( AnyEmailMessagePayload, @@ -83,7 +84,7 @@ def adapt_student_recipient_invoice_payment_confirmed_v1( ) def adapt_single_classroom_event_created_v1( - self, payload: ClassroomEventInstanceNotificationPayloadSchema + self, payload: PersistedClassroomEventInstanceNotificationPayloadSchema ) -> UniversalEmailMessagePayloadSchema: return UniversalEmailMessagePayloadSchema( theme=texts.SINGLE_CLASSROOM_EVENT_CREATED_V1_EMAIL_THEME, @@ -91,11 +92,11 @@ def adapt_single_classroom_event_created_v1( 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), + button_link=self.build_persisted_classroom_event_instance_url(payload), ) def adapt_classroom_event_instance_rescheduled_v1( - self, payload: ClassroomEventInstanceNotificationPayloadSchema + self, payload: PersistedClassroomEventInstanceNotificationPayloadSchema ) -> UniversalEmailMessagePayloadSchema: return UniversalEmailMessagePayloadSchema( theme=texts.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1_EMAIL_THEME, @@ -103,11 +104,11 @@ def adapt_classroom_event_instance_rescheduled_v1( 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), + button_link=self.build_persisted_classroom_event_instance_url(payload), ) def adapt_classroom_event_instance_cancelled_v1( - self, payload: ClassroomEventInstanceNotificationPayloadSchema + self, payload: PersistedClassroomEventInstanceNotificationPayloadSchema ) -> UniversalEmailMessagePayloadSchema: return UniversalEmailMessagePayloadSchema( theme=texts.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1_EMAIL_THEME, @@ -115,7 +116,33 @@ def adapt_classroom_event_instance_cancelled_v1( 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), + button_link=self.build_persisted_classroom_event_instance_url(payload), + ) + + def adapt_persisted_classroom_event_instance_reminder_v1( + self, + payload: PersistedClassroomEventInstanceNotificationPayloadSchema, + ) -> UniversalEmailMessagePayloadSchema: + return UniversalEmailMessagePayloadSchema( + theme=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_THEME, + pre_header=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_PRE_HEADER, + header=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_HEADER, + content=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_CONTENT, + button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + button_link=self.build_persisted_classroom_event_instance_url(payload), + ) + + def adapt_repeated_classroom_event_instance_reminder_v1( + self, + payload: RepeatedClassroomEventInstanceNotificationPayloadSchema, + ) -> UniversalEmailMessagePayloadSchema: + return UniversalEmailMessagePayloadSchema( + theme=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_THEME, + pre_header=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_PRE_HEADER, + header=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_HEADER, + content=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_CONTENT, + button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + button_link=self.build_repeated_classroom_event_instance_url(payload), ) def adapt_repeating_classroom_event_created_v1( @@ -127,7 +154,7 @@ def adapt_repeating_classroom_event_created_v1( 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), + button_link=self.build_classroom_schedule_focus_url(payload), ) def adapt_classroom_event_repetition_updated_v1( @@ -139,7 +166,7 @@ def adapt_classroom_event_repetition_updated_v1( 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), + button_link=self.build_classroom_schedule_focus_url(payload), ) def adapt_classroom_event_repetition_cancelled_v1( @@ -151,7 +178,7 @@ def adapt_classroom_event_repetition_cancelled_v1( 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), + button_link=self.build_classroom_schedule_focus_url(payload), ) def adapt_custom_v1( diff --git a/app/notifications/services/adapters/telegram_message_adapter.py b/app/notifications/services/adapters/telegram_message_adapter.py index 3bb1196d..33d59f83 100644 --- a/app/notifications/services/adapters/telegram_message_adapter.py +++ b/app/notifications/services/adapters/telegram_message_adapter.py @@ -1,13 +1,14 @@ from pydantic import BaseModel from app.common.schemas.notifications_sch import ( - ClassroomEventInstanceNotificationPayloadSchema, ClassroomNotificationPayloadSchema, ClassroomScheduleFocusNotificationPayloadSchema, CustomNotificationPayloadSchema, EnrollmentNotificationPayloadSchema, InvitationAcceptanceNotificationPayloadSchema, + PersistedClassroomEventInstanceNotificationPayloadSchema, RecipientInvoiceNotificationPayloadSchema, + RepeatedClassroomEventInstanceNotificationPayloadSchema, ) from app.notifications import texts from app.notifications.services.adapters.base_adapter import BaseNotificationAdapter @@ -109,30 +110,50 @@ def adapt_student_recipient_invoice_payment_confirmed_v1( ) def adapt_single_classroom_event_created_v1( - self, payload: ClassroomEventInstanceNotificationPayloadSchema + self, payload: PersistedClassroomEventInstanceNotificationPayloadSchema ) -> 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), + button_link=self.build_persisted_classroom_event_instance_url(payload), ) def adapt_classroom_event_instance_rescheduled_v1( - self, payload: ClassroomEventInstanceNotificationPayloadSchema + self, payload: PersistedClassroomEventInstanceNotificationPayloadSchema ) -> 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), + button_link=self.build_persisted_classroom_event_instance_url(payload), ) def adapt_classroom_event_instance_cancelled_v1( - self, payload: ClassroomEventInstanceNotificationPayloadSchema + self, payload: PersistedClassroomEventInstanceNotificationPayloadSchema ) -> 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), + button_link=self.build_persisted_classroom_event_instance_url(payload), + ) + + def adapt_persisted_classroom_event_instance_reminder_v1( + self, + payload: PersistedClassroomEventInstanceNotificationPayloadSchema, + ) -> TelegramMessagePayloadSchema: + return TelegramMessagePayloadSchema( + message_text=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_MESSAGE, + button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + button_link=self.build_persisted_classroom_event_instance_url(payload), + ) + + def adapt_repeated_classroom_event_instance_reminder_v1( + self, + payload: RepeatedClassroomEventInstanceNotificationPayloadSchema, + ) -> TelegramMessagePayloadSchema: + return TelegramMessagePayloadSchema( + message_text=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_MESSAGE, + button_text=texts.CLASSROOM_EVENT_INSTANCE_BUTTON_TEXT, + button_link=self.build_repeated_classroom_event_instance_url(payload), ) def adapt_repeating_classroom_event_created_v1( @@ -141,7 +162,7 @@ def adapt_repeating_classroom_event_created_v1( 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), + button_link=self.build_classroom_schedule_focus_url(payload), ) def adapt_classroom_event_repetition_updated_v1( @@ -150,7 +171,7 @@ def adapt_classroom_event_repetition_updated_v1( 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), + button_link=self.build_classroom_schedule_focus_url(payload), ) def adapt_classroom_event_repetition_cancelled_v1( @@ -159,7 +180,7 @@ def adapt_classroom_event_repetition_cancelled_v1( 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), + button_link=self.build_classroom_schedule_focus_url(payload), ) def adapt_custom_v1( diff --git a/app/notifications/texts.py b/app/notifications/texts.py index 850d4746..2df41eef 100644 --- a/app/notifications/texts.py +++ b/app/notifications/texts.py @@ -126,6 +126,22 @@ Изменение касается только этого занятия. Перейдите по ссылке, чтобы узнать подробности. """ +CLASSROOM_EVENT_INSTANCE_REMINDER_V1_MESSAGE = """ +Занятие скоро начнётся. Перейдите по ссылке, чтобы узнать подробности +""" +CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_THEME = """ +Напоминание о занятии +""" +CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_PRE_HEADER = """ +Не забудьте присоединиться +""" +CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_HEADER = """ +Занятие скоро начнётся +""" +CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_CONTENT = """ +Перейдите по ссылке, чтобы узнать подробности. +""" + REPEATING_CLASSROOM_EVENT_CREATED_V1_MESSAGE = """ В ваше расписание добавлены новые регулярные занятия. Перейдите по ссылке, чтобы узнать подробности """ diff --git a/app/scheduler/main.py b/app/scheduler/main.py index 6ec072a4..e323ad63 100644 --- a/app/scheduler/main.py +++ b/app/scheduler/main.py @@ -10,6 +10,7 @@ classroom_event_instances_rst, classroom_events_tutor_rst, classroom_schedules_rst, + event_reminders_int, ) outside_router = APIRouterExt(prefix="/api/public/scheduler-service") @@ -31,6 +32,7 @@ dependencies=[APIKeyProtection], prefix="/internal/scheduler-service", ) +internal_router.include_router(event_reminders_int.router) @asynccontextmanager diff --git a/app/scheduler/models/repetition_modes_db.py b/app/scheduler/models/repetition_modes_db.py index ef84cc4f..db34688b 100644 --- a/app/scheduler/models/repetition_modes_db.py +++ b/app/scheduler/models/repetition_modes_db.py @@ -19,7 +19,10 @@ ForeignKey, Index, SQLColumnExpression, + Time, + and_, delete, + func, or_, select, ) @@ -76,8 +79,11 @@ class RepetitionMode(Base): "with_polymorphic": "*", # `polymorphic_load: inline` doesn't work in complex queries for some reason } + starts_at_utc_time = func.cast(func.timezone("UTC", starts_at), Time) + __table_args__ = ( Index("index_repetition_modes_kind_and_interval", kind, starts_at, ends_at), + Index("index_repetition_modes_starts_at_utc_time", starts_at_utc_time), ) @property @@ -134,12 +140,28 @@ async def find_last_bordering_on_a_timestamp( @classmethod def iter_in_range_conditions( cls, - happens_after: datetime, - happens_before: datetime, + happens_after_utc: datetime, + happens_before_utc: datetime, ) -> Iterator[SQLColumnExpression[bool]]: yield cls.kind == cls.__mapper__.polymorphic_identity - yield cls.starts_at <= happens_before - yield or_(cls.is_finite.is_(False), cls.ends_at > happens_after) + yield cls.starts_at <= happens_before_utc + yield or_(cls.is_finite.is_(False), cls.ends_at > happens_after_utc) + + @classmethod + def iter_start_time_conditions( + cls, + happens_after_utc: datetime, + happens_before_utc: datetime, + ) -> Iterator[SQLColumnExpression[bool]]: + if happens_before_utc - happens_after_utc >= timedelta(days=1): + return + + happens_after_utc_time = happens_after_utc.time() + happens_before_utc_time = happens_before_utc.time() + yield (and_ if happens_after_utc_time <= happens_before_utc_time else or_)( + cls.starts_at_utc_time >= happens_after_utc_time, + cls.starts_at_utc_time < happens_before_utc_time, + ) def calculate_event_instance_starts_at_for_index( self, @@ -171,33 +193,41 @@ def calculate_event_instance_index_for_starts_at( def get_starts_at_bounds_in_range( self, - happens_after: datetime, - happens_before: datetime, + happens_after_utc: datetime, + happens_before_utc: datetime, + include_already_started_instances: bool = True, ) -> tuple[datetime, datetime]: - if self.starts_at > happens_after - self.event_instance_duration: + if include_already_started_instances: + happens_after_utc = happens_after_utc - self.event_instance_duration + + if self.starts_at > happens_after_utc: starts_at_lower_bound = self.starts_at else: starts_at_lower_bound = datetime.combine( - happens_after.astimezone(timezone.utc).date(), + happens_after_utc.date(), self.starts_at.time(), self.starts_at.tzinfo, ) - if starts_at_lower_bound + self.event_instance_duration <= happens_after: + if starts_at_lower_bound <= happens_after_utc: # TODO use bitmask's unit instead of `days=1` # or just implement "skipping" the first starts at + # TODO recheck if `happens_after_utc.date()` works after + # using the bitmask's unit here (it does for units >= `days=1`), + # because `include_already_started_instances` can change the date starts_at_lower_bound += timedelta(days=1) - if self.is_finite and self.ends_at < happens_before: + if self.is_finite and self.ends_at < happens_before_utc: starts_at_upper_bound = self.ends_at else: - starts_at_upper_bound = happens_before + starts_at_upper_bound = happens_before_utc return starts_at_lower_bound, starts_at_upper_bound def iter_event_instances_in_range( self, - happens_after: datetime, - happens_before: datetime, + happens_after_utc: datetime, + happens_before_utc: datetime, + include_already_started_instances: bool, ) -> Iterator[tuple[int, datetime]]: """This method assumes, that the repetition mode is inside the range (checked on query level)""" raise NotImplementedError @@ -229,12 +259,14 @@ def calculate_event_instance_index_for_starts_at( def iter_event_instances_in_range( self, - happens_after: datetime, - happens_before: datetime, + happens_after_utc: datetime, + happens_before_utc: datetime, + include_already_started_instances: bool, ) -> Iterator[tuple[int, datetime]]: current_starts_at, starts_at_upper_bound = self.get_starts_at_bounds_in_range( - happens_after=happens_after, - happens_before=happens_before, + happens_after_utc=happens_after_utc, + happens_before_utc=happens_before_utc, + include_already_started_instances=include_already_started_instances, ) current_event_instance_index: int = ( self.calculate_event_instance_index_for_starts_at( @@ -266,21 +298,21 @@ def starting_bitmask(self) -> TimestampRelativeBitmask: @classmethod def iter_in_range_conditions( cls, - happens_after: datetime, - happens_before: datetime, + happens_after_utc: datetime, + happens_before_utc: datetime, ) -> Iterator[SQLColumnExpression[bool]]: yield from super().iter_in_range_conditions( - happens_after=happens_after, - happens_before=happens_before, + happens_after_utc=happens_after_utc, + happens_before_utc=happens_before_utc, ) if ( - happens_before - happens_after + happens_before_utc - happens_after_utc < (cls.bitmask_type.size - 1) * cls.bitmask_type.unit_duration ): interval_bitmask = cls.bitmask_type.build_continuous( - start_timestamp=happens_after.astimezone(timezone.utc), - end_timestamp=happens_before.astimezone(timezone.utc), + start_timestamp=happens_after_utc, + end_timestamp=happens_before_utc, ) yield cls.get_combined_bitmask_field().bitwise_and( interval_bitmask.value @@ -358,12 +390,14 @@ def calculate_event_instance_index_for_starts_at( def iter_event_instances_in_range( self, - happens_after: datetime, - happens_before: datetime, + happens_after_utc: datetime, + happens_before_utc: datetime, + include_already_started_instances: bool, ) -> Iterator[tuple[int, datetime]]: current_starts_at, starts_at_upper_bound = self.get_starts_at_bounds_in_range( - happens_after=happens_after, - happens_before=happens_before, + happens_after_utc=happens_after_utc, + happens_before_utc=happens_before_utc, + include_already_started_instances=include_already_started_instances, ) current_event_instance_index: int | None = None diff --git a/app/scheduler/routes/classroom_event_instances_rst.py b/app/scheduler/routes/classroom_event_instances_rst.py index 505c35ea..f27475d2 100644 --- a/app/scheduler/routes/classroom_event_instances_rst.py +++ b/app/scheduler/routes/classroom_event_instances_rst.py @@ -7,10 +7,10 @@ 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, + PersistedClassroomEventInstanceNotificationPayloadSchema, ) from app.common.utils.datetime import datetime_utc_now from app.scheduler.dependencies.event_instances_dep import ( @@ -240,7 +240,7 @@ async def reschedule_persisted_classroom_event_instance( await notifications_bridge.send_notification( NotificationInputV2Schema( - payload=ClassroomEventInstanceNotificationPayloadSchema( + payload=PersistedClassroomEventInstanceNotificationPayloadSchema( kind=NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1, classroom_id=classroom_event.classroom_id, event_instance_id=event_instance.id, @@ -293,7 +293,7 @@ async def reschedule_repeated_classroom_event_instance( await notifications_bridge.send_notification( NotificationInputV2Schema( - payload=ClassroomEventInstanceNotificationPayloadSchema( + payload=PersistedClassroomEventInstanceNotificationPayloadSchema( kind=NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1, classroom_id=classroom_event.classroom_id, event_instance_id=event_instance.id, @@ -335,7 +335,7 @@ async def cancel_persisted_classroom_event_instance( await notifications_bridge.send_notification( NotificationInputV2Schema( - payload=ClassroomEventInstanceNotificationPayloadSchema( + payload=PersistedClassroomEventInstanceNotificationPayloadSchema( kind=NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1, classroom_id=classroom_event.classroom_id, event_instance_id=event_instance.id, @@ -385,7 +385,7 @@ async def cancel_repeated_classroom_event_instance( await notifications_bridge.send_notification( NotificationInputV2Schema( - payload=ClassroomEventInstanceNotificationPayloadSchema( + payload=PersistedClassroomEventInstanceNotificationPayloadSchema( kind=NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1, classroom_id=classroom_event.classroom_id, event_instance_id=event_instance.id, diff --git a/app/scheduler/routes/classroom_events_tutor_rst.py b/app/scheduler/routes/classroom_events_tutor_rst.py index 132eff7d..7b7d01e6 100644 --- a/app/scheduler/routes/classroom_events_tutor_rst.py +++ b/app/scheduler/routes/classroom_events_tutor_rst.py @@ -10,11 +10,11 @@ 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, + PersistedClassroomEventInstanceNotificationPayloadSchema, ) from app.scheduler.dependencies.classroom_events_dep import MyClassroomEventByIDs from app.scheduler.models.event_instances_db import ( @@ -100,7 +100,7 @@ async def create_classroom_event( ) await notifications_bridge.send_notification( NotificationInputV2Schema( - payload=ClassroomEventInstanceNotificationPayloadSchema( + payload=PersistedClassroomEventInstanceNotificationPayloadSchema( kind=NotificationKind.SINGLE_CLASSROOM_EVENT_CREATED_V1, classroom_id=classroom_event.classroom_id, event_instance_id=sole_instance.id, diff --git a/app/scheduler/routes/classroom_schedules_rst.py b/app/scheduler/routes/classroom_schedules_rst.py index 354acdf1..bbfb3385 100644 --- a/app/scheduler/routes/classroom_schedules_rst.py +++ b/app/scheduler/routes/classroom_schedules_rst.py @@ -1,22 +1,19 @@ from collections.abc import Iterator from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from typing import Annotated, assert_never, cast from uuid import UUID from fastapi import Path from pydantic import AwareDatetime -from sqlalchemy import and_, or_, select, tuple_ +from sqlalchemy import SQLColumnExpression, and_, or_, select, tuple_ from sqlalchemy.orm import raiseload from app.common.config_bdg import classrooms_bridge from app.common.dependencies.authorization_dep import AuthorizationData from app.common.fastapi_ext import APIRouterExt from app.common.sqlalchemy_ext import db -from app.scheduler.dependencies.events_dep import ( - EventTimeFrameQuery, - EventTimeFrameSchema, -) +from app.scheduler.dependencies.events_dep import EventTimeFrameQuery from app.scheduler.models.event_instances_db import ( AnyEventInstance, EventInstance, @@ -41,28 +38,39 @@ async def get_repetition_modes_in_range( - classroom_ids: list[int], - happens_after: datetime, - happens_before: datetime, + classroom_ids: list[int] | None, + happens_after_utc: datetime, + happens_before_utc: datetime, + use_start_time_conditions: bool, ) -> list[RepetitionMode]: + filters_and: list[SQLColumnExpression[bool]] = [ + or_( + *( + and_( + *klass.iter_in_range_conditions( + happens_after_utc=happens_after_utc, + happens_before_utc=happens_before_utc, + ) + ) + for klass in ConcreteRepetitionModeClasses + ) + ), + ] + if use_start_time_conditions: + filters_and.extend( + RepetitionMode.iter_start_time_conditions( + happens_after_utc=happens_after_utc, + happens_before_utc=happens_before_utc, + ) + ) + if classroom_ids is not None: + filters_and.append(ClassroomEvent.classroom_id.in_(classroom_ids)) + return await db.get_all_with_assumed_limit( select(RepetitionMode) .options(raiseload(RepetitionMode.event)) .join(ClassroomEvent) - .filter( - ClassroomEvent.classroom_id.in_(classroom_ids), - or_( - *( - and_( - *klass.iter_in_range_conditions( - happens_after=happens_after, - happens_before=happens_before, - ) - ) - for klass in ConcreteRepetitionModeClasses - ) - ), - ), + .filter(*filters_and), limit=1000, ) @@ -82,8 +90,9 @@ class VirtualRepeatedEventInstanceValueData: def iter_virtual_repeated_event_instances_in_range( repetition_modes: list[RepetitionMode], - happens_after: datetime, - happens_before: datetime, + happens_after_utc: datetime, + happens_before_utc: datetime, + include_already_started_instances: bool, ) -> Iterator[ tuple[ VirtualRepeatedEventInstanceKeyData, @@ -108,30 +117,40 @@ def iter_virtual_repeated_event_instances_in_range( instance_index, starts_at, ) in repetition_mode.iter_event_instances_in_range( - happens_after=happens_after, - happens_before=happens_before, + happens_after_utc=happens_after_utc, + happens_before_utc=happens_before_utc, + include_already_started_instances=include_already_started_instances, ) ) async def get_event_instances_in_range( - classroom_ids: list[int], - happens_after: datetime, - happens_before: datetime, + classroom_ids: list[int] | None, + happens_after_utc: datetime, + happens_before_utc: datetime, virtual_repeated_instance_keys: list[VirtualRepeatedEventInstanceKeyData], + include_already_started_instances: bool, ) -> list[AnyEventInstance]: filters_or = [ and_( RepeatedEventInstance.kind == EventInstanceKind.SOLE, - SoleEventInstance.starts_at <= happens_before, - SoleEventInstance.ends_at > happens_after, + SoleEventInstance.starts_at <= happens_before_utc, + ( + SoleEventInstance.ends_at > happens_after_utc + if include_already_started_instances + else SoleEventInstance.starts_at > happens_after_utc + ), ), and_( RepeatedEventInstance.kind == EventInstanceKind.REPEATED, RepeatedEventInstance.starts_at_override.is_not(None), RepeatedEventInstance.ends_at_override.is_not(None), - RepeatedEventInstance.starts_at_override <= happens_before, - RepeatedEventInstance.ends_at_override > happens_after, + RepeatedEventInstance.starts_at_override <= happens_before_utc, + ( + RepeatedEventInstance.ends_at_override > happens_after_utc + if include_already_started_instances + else RepeatedEventInstance.starts_at_override > happens_after_utc + ), ), ] if len(virtual_repeated_instance_keys) > 0: @@ -150,6 +169,10 @@ async def get_event_instances_in_range( ) ) + filters_and = [or_(*filters_or)] + if classroom_ids is not None: + filters_and.append(ClassroomEvent.classroom_id.in_(classroom_ids)) + return cast( # no good way to type this in SQLAlchemy list[AnyEventInstance], await db.get_all_with_assumed_limit( @@ -160,16 +183,15 @@ async def get_event_instances_in_range( # Currently disabled for generating virtual event in `iter_persisted_repeated_event_instances` ) .join(ClassroomEvent) - .filter( - ClassroomEvent.classroom_id.in_(classroom_ids), - or_(*filters_or), - ), + .filter(*filters_and), limit=1000, ), ) -class ScheduleResponseSchemaAdapter: +class BaseEventInstanceListAdapter: + # TODO (170) this is named badly, redo it + def __init__( self, events_by_id: dict[int, ClassroomEvent], @@ -191,6 +213,8 @@ def __init__( persisted_repeated_event_instance_keys ) + +class ScheduleResponseSchemaAdapter(BaseEventInstanceListAdapter): def iter_sole_event_instances(self) -> Iterator[SoleEventInstanceResponseSchema]: for sole_event_instance in self.sole_event_instances: event = self.events_by_id[sole_event_instance.event_id] @@ -288,14 +312,22 @@ def adapt(self) -> list[EventInstanceResponseSchema]: return list(self.iter_event_instances()) -async def list_classroom_event_instances( - classroom_ids: list[int], - time_frame: EventTimeFrameSchema, -) -> list[EventInstanceResponseSchema]: +async def build_classroom_schedule_adapter[T: BaseEventInstanceListAdapter]( + adapter_type: type[T], + classroom_ids: list[int] | None, + happens_after: AwareDatetime, + happens_before: AwareDatetime, + use_start_time_conditions: bool = False, + include_already_started_instances: bool = True, +) -> T: + happens_after_utc = happens_after.astimezone(tz=timezone.utc) + happens_before_utc = happens_before.astimezone(tz=timezone.utc) + repetition_modes = await get_repetition_modes_in_range( classroom_ids=classroom_ids, - happens_after=time_frame.happens_after, - happens_before=time_frame.happens_before, + happens_after_utc=happens_after_utc, + happens_before_utc=happens_before_utc, + use_start_time_conditions=use_start_time_conditions, ) virtual_repeated_instances_by_id: dict[ @@ -304,16 +336,18 @@ async def list_classroom_event_instances( ] = dict( iter_virtual_repeated_event_instances_in_range( repetition_modes=repetition_modes, - happens_after=time_frame.happens_after, - happens_before=time_frame.happens_before, + happens_after_utc=happens_after_utc, + happens_before_utc=happens_before_utc, + include_already_started_instances=include_already_started_instances, ) ) persisted_event_instances = await get_event_instances_in_range( classroom_ids=classroom_ids, - happens_after=time_frame.happens_after, - happens_before=time_frame.happens_before, + happens_after_utc=happens_after_utc, + happens_before_utc=happens_before_utc, virtual_repeated_instance_keys=list(virtual_repeated_instances_by_id.keys()), + include_already_started_instances=include_already_started_instances, ) sole_event_instances: list[SoleEventInstance] = [] @@ -324,8 +358,8 @@ async def list_classroom_event_instances( case SoleEventInstance(): if ( persisted_event_instance.cancelled_at is not None - or persisted_event_instance.starts_at > time_frame.happens_before - or persisted_event_instance.ends_at <= time_frame.happens_after + or persisted_event_instance.starts_at > happens_before_utc + or persisted_event_instance.ends_at <= happens_after_utc ): continue sole_event_instances.append(persisted_event_instance) @@ -335,12 +369,12 @@ async def list_classroom_event_instances( or ( persisted_event_instance.starts_at_override is not None and persisted_event_instance.starts_at_override - > time_frame.happens_before + > happens_before_utc ) or ( persisted_event_instance.ends_at_override is not None and persisted_event_instance.ends_at_override - <= time_frame.happens_after + <= happens_after_utc ) ): virtual_repeated_instances_by_id.pop( @@ -395,13 +429,13 @@ async def list_classroom_event_instances( ) } - return ScheduleResponseSchemaAdapter( + return adapter_type( events_by_id=events_by_id, virtual_repeated_instances_by_id=virtual_repeated_instances_by_id, sole_event_instances=sole_event_instances, persisted_repeated_event_instances=persisted_repeated_event_instances, persisted_repeated_event_instance_keys=persisted_repeated_event_instance_keys, - ).adapt() + ) @router.get( @@ -416,10 +450,15 @@ async def retrieve_classroom_schedule( classroom_id: Annotated[int, Path()], time_frame: EventTimeFrameQuery, ) -> list[EventInstanceResponseSchema]: - return await list_classroom_event_instances( - classroom_ids=[classroom_id], - time_frame=time_frame, + schedule_adapter: ScheduleResponseSchemaAdapter = ( + await build_classroom_schedule_adapter( + classroom_ids=[classroom_id], + happens_after=time_frame.happens_after, + happens_before=time_frame.happens_before, + adapter_type=ScheduleResponseSchemaAdapter, + ) ) + return schedule_adapter.adapt() @router.get( @@ -430,12 +469,17 @@ async def retrieve_tutor_schedule( auth_data: AuthorizationData, time_frame: EventTimeFrameQuery, ) -> list[EventInstanceResponseSchema]: - return await list_classroom_event_instances( - classroom_ids=await classrooms_bridge.list_tutor_classroom_ids( - tutor_id=auth_data.user_id - ), - time_frame=time_frame, + schedule_adapter: ScheduleResponseSchemaAdapter = ( + await build_classroom_schedule_adapter( + classroom_ids=await classrooms_bridge.list_tutor_classroom_ids( + tutor_id=auth_data.user_id + ), + happens_after=time_frame.happens_after, + happens_before=time_frame.happens_before, + adapter_type=ScheduleResponseSchemaAdapter, + ) ) + return schedule_adapter.adapt() @router.get( @@ -446,9 +490,14 @@ async def retrieve_student_schedule( auth_data: AuthorizationData, time_frame: EventTimeFrameQuery, ) -> list[EventInstanceResponseSchema]: - return await list_classroom_event_instances( - classroom_ids=await classrooms_bridge.list_student_classroom_ids( - student_id=auth_data.user_id - ), - time_frame=time_frame, + schedule_adapter: ScheduleResponseSchemaAdapter = ( + await build_classroom_schedule_adapter( + classroom_ids=await classrooms_bridge.list_student_classroom_ids( + student_id=auth_data.user_id + ), + happens_after=time_frame.happens_after, + happens_before=time_frame.happens_before, + adapter_type=ScheduleResponseSchemaAdapter, + ) ) + return schedule_adapter.adapt() diff --git a/app/scheduler/routes/event_reminders_int.py b/app/scheduler/routes/event_reminders_int.py new file mode 100644 index 00000000..40c40db3 --- /dev/null +++ b/app/scheduler/routes/event_reminders_int.py @@ -0,0 +1,154 @@ +import logging +from collections.abc import Iterator +from datetime import timedelta +from typing import ClassVar, Final + +from pydantic import AwareDatetime, BaseModel + +from app.common.config_bdg import notifications_bridge +from app.common.fastapi_ext import APIRouterExt +from app.common.schemas.notifications_sch import ( + ClassroomParticipantRecipientFilterSchema, + NotificationInputV2Schema, + NotificationKind, + PersistedClassroomEventInstanceNotificationPayloadSchema, + RepeatedClassroomEventInstanceNotificationPayloadSchema, +) +from app.common.utils.datetime import datetime_utc_now +from app.scheduler.routes.classroom_schedules_rst import ( + BaseEventInstanceListAdapter, + build_classroom_schedule_adapter, +) + +router = APIRouterExt(tags=["event reminders"]) + + +class EventReminderSearchRequestSchema(BaseModel): + happens_after: AwareDatetime + happens_before: AwareDatetime + + +class EventInstanceReminderListAdapter(BaseEventInstanceListAdapter): + default_idempotency_ttl: ClassVar[timedelta] = timedelta(hours=1) + + def generate_idempotency_expires_at(self) -> AwareDatetime: + return datetime_utc_now() + self.default_idempotency_ttl + + def iter_sole_event_instance_notifications( + self, + ) -> Iterator[NotificationInputV2Schema]: + for sole_event_instance in self.sole_event_instances: + event = self.events_by_id[sole_event_instance.event_id] + yield NotificationInputV2Schema( + payload=PersistedClassroomEventInstanceNotificationPayloadSchema( + kind=NotificationKind.PERSISTED_CLASSROOM_EVENT_INSTANCE_REMINDER_V1, + classroom_id=event.classroom_id, + event_instance_id=sole_event_instance.id, + ), + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=event.classroom_id, + role=None, + ) + ], + idempotency_key=str(sole_event_instance.id), + idempotency_expires_at=self.generate_idempotency_expires_at(), + ) + + def iter_persisted_repeated_event_instance_notifications( + self, + ) -> Iterator[NotificationInputV2Schema]: + for ( + persisted_repeated_event_instance + ) in self.persisted_repeated_event_instances: + event = self.events_by_id[persisted_repeated_event_instance.event_id] + yield NotificationInputV2Schema( + payload=PersistedClassroomEventInstanceNotificationPayloadSchema( + kind=NotificationKind.PERSISTED_CLASSROOM_EVENT_INSTANCE_REMINDER_V1, + classroom_id=event.classroom_id, + event_instance_id=persisted_repeated_event_instance.id, + ), + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=event.classroom_id, + role=None, + ) + ], + idempotency_key=str(persisted_repeated_event_instance.id), + idempotency_expires_at=self.generate_idempotency_expires_at(), + ) + + def iter_virtual_repeated_event_instance_notifications( + self, + ) -> Iterator[NotificationInputV2Schema]: + for ( + virtual_repeated_event_instance_key, + virtual_repeated_event_instance_value, + ) in self.virtual_repeated_instances_by_id.items(): + if ( + virtual_repeated_event_instance_key + in self.persisted_repeated_event_instance_keys + ): + continue + + event = self.events_by_id[virtual_repeated_event_instance_value.event_id] + yield NotificationInputV2Schema( + payload=RepeatedClassroomEventInstanceNotificationPayloadSchema( + kind=NotificationKind.REPEATED_CLASSROOM_EVENT_INSTANCE_REMINDER_V1, + classroom_id=event.classroom_id, + repetition_mode_id=virtual_repeated_event_instance_key.repetition_mode_id, + instance_index=virtual_repeated_event_instance_key.instance_index, + ), + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=event.classroom_id, + role=None, + ) + ], + idempotency_key=( + f"{virtual_repeated_event_instance_key.repetition_mode_id}:" + f"{virtual_repeated_event_instance_key.instance_index}" + ), + idempotency_expires_at=self.generate_idempotency_expires_at(), + ) + + def iter_notifications(self) -> Iterator[NotificationInputV2Schema]: + yield from self.iter_sole_event_instance_notifications() + yield from self.iter_persisted_repeated_event_instance_notifications() + yield from self.iter_virtual_repeated_event_instance_notifications() + + +REMINDERS_PER_REQUEST_SOFT_LIMIT: Final[int] = 20 +REMINDERS_PER_REQUEST_HARD_LIMIT: Final[int] = 100 + + +@router.post( + path="/event-reminders/", + summary="Queue sending all event reminders in a specific time period", +) +async def queue_sending_event_reminders(data: EventReminderSearchRequestSchema) -> None: + # TODO currently this is called by a cron, but should be migrated to a better system + # TODO make this more generic via the notification service itself + + schedule_adapter = ( + await build_classroom_schedule_adapter( # TODO (170) proper service + classroom_ids=None, + happens_after=data.happens_after, + happens_before=data.happens_before, + adapter_type=EventInstanceReminderListAdapter, + include_already_started_instances=False, + ) + ) + for i, notification in enumerate(schedule_adapter.iter_notifications()): + await notifications_bridge.send_notification(data=notification) + if i == REMINDERS_PER_REQUEST_SOFT_LIMIT: + logging.error( + "Reached the soft limit of reminders in one request", + extra={"request_data": data}, + ) + if i == REMINDERS_PER_REQUEST_HARD_LIMIT: + logging.error( + "Reached the hard limit of reminders in one request", + extra={"request_data": data}, + ) + break diff --git a/tests/notifications/factories.py b/tests/notifications/factories.py index 43e01d4b..309d9bbd 100644 --- a/tests/notifications/factories.py +++ b/tests/notifications/factories.py @@ -32,10 +32,24 @@ class RecipientInvoiceNotificationPayloadFactory( __model__ = notifications_sch.RecipientInvoiceNotificationPayloadSchema -class ClassroomEventInstanceNotificationPayloadFactory( - BaseModelFactory[notifications_sch.ClassroomEventInstanceNotificationPayloadSchema] +class PersistedClassroomEventInstanceNotificationPayloadFactory( + BaseModelFactory[ + notifications_sch.PersistedClassroomEventInstanceNotificationPayloadSchema + ] ): - __model__ = notifications_sch.ClassroomEventInstanceNotificationPayloadSchema + __model__ = ( + notifications_sch.PersistedClassroomEventInstanceNotificationPayloadSchema + ) + + +class RepeatedClassroomEventInstanceNotificationPayloadFactory( + BaseModelFactory[ + notifications_sch.RepeatedClassroomEventInstanceNotificationPayloadSchema + ] +): + __model__ = ( + notifications_sch.RepeatedClassroomEventInstanceNotificationPayloadSchema + ) class ClassroomScheduleFocusNotificationPayloadFactory( diff --git a/tests/notifications/functional/test_notifications_sub.py b/tests/notifications/functional/test_notifications_sub.py index d2b6e865..7a7c8e96 100644 --- a/tests/notifications/functional/test_notifications_sub.py +++ b/tests/notifications/functional/test_notifications_sub.py @@ -122,6 +122,8 @@ async def test_notification_send( assert notification is not None await notification.delete() + # TODO check nothing is captured by sentry_sdk + @freeze_time() async def test_notification_send_no_recipients_found( @@ -171,3 +173,8 @@ async def test_notification_send_no_recipients_found( list(await Notification.find_all_by_kwargs(created_at=datetime_utc_now())), [], ) + + +# TODO idempotency check failed + +# TODO (?) some operations failed diff --git a/tests/notifications/service/adapters/test_email_message_adapter.py b/tests/notifications/service/adapters/test_email_message_adapter.py index 2b155e82..3dc6ccb2 100644 --- a/tests/notifications/service/adapters/test_email_message_adapter.py +++ b/tests/notifications/service/adapters/test_email_message_adapter.py @@ -208,8 +208,8 @@ async def test_single_classroom_event_created_v1_adapting( notification_mock: Mock, ) -> None: notification_payload: ( - notifications_sch.ClassroomEventInstanceNotificationPayloadSchema - ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + notifications_sch.PersistedClassroomEventInstanceNotificationPayloadSchema + ) = factories.PersistedClassroomEventInstanceNotificationPayloadFactory.build( kind=notifications_sch.NotificationKind.SINGLE_CLASSROOM_EVENT_CREATED_V1 ) notification_mock.payload = notification_payload @@ -232,7 +232,6 @@ async def test_single_classroom_event_created_v1_adapting( 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)], }, ) @@ -242,8 +241,8 @@ async def test_classroom_event_instance_rescheduled_v1_adapting( notification_mock: Mock, ) -> None: notification_payload: ( - notifications_sch.ClassroomEventInstanceNotificationPayloadSchema - ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + notifications_sch.PersistedClassroomEventInstanceNotificationPayloadSchema + ) = factories.PersistedClassroomEventInstanceNotificationPayloadFactory.build( kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1 ) notification_mock.payload = notification_payload @@ -266,7 +265,6 @@ async def test_classroom_event_instance_rescheduled_v1_adapting( 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)], }, ) @@ -276,8 +274,8 @@ async def test_classroom_event_instance_cancelled_v1_adapting( notification_mock: Mock, ) -> None: notification_payload: ( - notifications_sch.ClassroomEventInstanceNotificationPayloadSchema - ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + notifications_sch.PersistedClassroomEventInstanceNotificationPayloadSchema + ) = factories.PersistedClassroomEventInstanceNotificationPayloadFactory.build( kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1 ) notification_mock.payload = notification_payload @@ -300,12 +298,78 @@ async def test_classroom_event_instance_cancelled_v1_adapting( 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_persisted_classroom_event_instance_reminder_v1_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.PersistedClassroomEventInstanceNotificationPayloadSchema + ) = factories.PersistedClassroomEventInstanceNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.PERSISTED_CLASSROOM_EVENT_INSTANCE_REMINDER_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_REMINDER_V1_EMAIL_THEME, + expected_pre_header=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_PRE_HEADER, + expected_header=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_HEADER, + expected_content=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_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"], + "event_instance_id": [str(notification_payload.event_instance_id)], + }, + ) + + +async def test_repeated_classroom_event_instance_reminder_v1_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.RepeatedClassroomEventInstanceNotificationPayloadSchema + ) = factories.RepeatedClassroomEventInstanceNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.REPEATED_CLASSROOM_EVENT_INSTANCE_REMINDER_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_REMINDER_V1_EMAIL_THEME, + expected_pre_header=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_PRE_HEADER, + expected_header=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_V1_EMAIL_HEADER, + expected_content=texts.CLASSROOM_EVENT_INSTANCE_REMINDER_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"], + "repetition_mode_id": [str(notification_payload.repetition_mode_id)], + "instance_index": [str(notification_payload.instance_index)], + }, + ) + + async def test_repeating_classroom_event_created_v1_adapting( notification_mock: Mock, ) -> None: @@ -334,7 +398,6 @@ async def test_repeating_classroom_event_created_v1_adapting( 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()], }, ) @@ -368,7 +431,6 @@ async def test_classroom_event_repetition_updated_v1_adapting( 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()], }, ) @@ -402,7 +464,6 @@ async def test_classroom_event_repetition_cancelled_v1_adapting( 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()], }, ) diff --git a/tests/notifications/service/adapters/test_telegram_message_adapter.py b/tests/notifications/service/adapters/test_telegram_message_adapter.py index d01fc35b..f633a7cc 100644 --- a/tests/notifications/service/adapters/test_telegram_message_adapter.py +++ b/tests/notifications/service/adapters/test_telegram_message_adapter.py @@ -224,8 +224,8 @@ async def test_single_classroom_event_created_v1_notification_adapting( notification_mock: Mock, ) -> None: notification_payload: ( - notifications_sch.ClassroomEventInstanceNotificationPayloadSchema - ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + notifications_sch.PersistedClassroomEventInstanceNotificationPayloadSchema + ) = factories.PersistedClassroomEventInstanceNotificationPayloadFactory.build( kind=notifications_sch.NotificationKind.SINGLE_CLASSROOM_EVENT_CREATED_V1 ) notification_mock.payload = notification_payload @@ -242,7 +242,6 @@ async def test_single_classroom_event_created_v1_notification_adapting( 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)], }, ) @@ -252,8 +251,8 @@ async def test_classroom_event_instance_rescheduled_v1_notification_adapting( notification_mock: Mock, ) -> None: notification_payload: ( - notifications_sch.ClassroomEventInstanceNotificationPayloadSchema - ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + notifications_sch.PersistedClassroomEventInstanceNotificationPayloadSchema + ) = factories.PersistedClassroomEventInstanceNotificationPayloadFactory.build( kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_INSTANCE_RESCHEDULED_V1 ) notification_mock.payload = notification_payload @@ -270,7 +269,6 @@ async def test_classroom_event_instance_rescheduled_v1_notification_adapting( 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)], }, ) @@ -280,8 +278,8 @@ async def test_classroom_event_instance_cancelled_v1_notification_adapting( notification_mock: Mock, ) -> None: notification_payload: ( - notifications_sch.ClassroomEventInstanceNotificationPayloadSchema - ) = factories.ClassroomEventInstanceNotificationPayloadFactory.build( + notifications_sch.PersistedClassroomEventInstanceNotificationPayloadSchema + ) = factories.PersistedClassroomEventInstanceNotificationPayloadFactory.build( kind=notifications_sch.NotificationKind.CLASSROOM_EVENT_INSTANCE_CANCELLED_V1 ) notification_mock.payload = notification_payload @@ -298,12 +296,66 @@ async def test_classroom_event_instance_cancelled_v1_notification_adapting( 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_persisted_classroom_event_instance_reminder_v1_notification_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.PersistedClassroomEventInstanceNotificationPayloadSchema + ) = factories.PersistedClassroomEventInstanceNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.PERSISTED_CLASSROOM_EVENT_INSTANCE_REMINDER_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_REMINDER_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"], + "event_instance_id": [str(notification_payload.event_instance_id)], + }, + ) + + +async def test_repeated_classroom_event_instance_reminder_v1_notification_adapting( + notification_mock: Mock, +) -> None: + notification_payload: ( + notifications_sch.RepeatedClassroomEventInstanceNotificationPayloadSchema + ) = factories.RepeatedClassroomEventInstanceNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.REPEATED_CLASSROOM_EVENT_INSTANCE_REMINDER_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_REMINDER_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"], + "repetition_mode_id": [str(notification_payload.repetition_mode_id)], + "instance_index": [str(notification_payload.instance_index)], + }, + ) + + async def test_repeating_classroom_event_created_v1_notification_adapting( notification_mock: Mock, ) -> None: @@ -326,7 +378,6 @@ async def test_repeating_classroom_event_created_v1_notification_adapting( 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()], }, ) @@ -354,7 +405,6 @@ async def test_classroom_event_repetition_updated_v1_notification_adapting( 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()], }, ) @@ -382,7 +432,6 @@ async def test_classroom_event_repetition_cancelled_v1_notification_adapting( 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()], }, )