From 8fa34ef9a606258897f781e14ddf5d07d2c60089 Mon Sep 17 00:00:00 2001 From: niqzart Date: Mon, 18 May 2026 23:32:10 +0300 Subject: [PATCH 1/4] feat: upgraded internal method for classroom participants --- app/classrooms/routes/classrooms_int.py | 32 ++++-- app/common/bridges/classrooms_bdg.py | 10 +- app/common/schemas/classrooms_sch.py | 6 ++ app/invoices/routes/invoices_tutor_rst.py | 6 +- .../functional/test_classrooms_int.py | 102 ++++++++++++++---- .../functional/test_invoice_creation.py | 7 +- 6 files changed, 128 insertions(+), 35 deletions(-) create mode 100644 app/common/schemas/classrooms_sch.py diff --git a/app/classrooms/routes/classrooms_int.py b/app/classrooms/routes/classrooms_int.py index 71695c31..cdc51ae0 100644 --- a/app/classrooms/routes/classrooms_int.py +++ b/app/classrooms/routes/classrooms_int.py @@ -1,27 +1,25 @@ from collections.abc import Sequence from typing import Annotated, assert_never -from fastapi import Path +from fastapi import Path, Query from sqlalchemy import or_, select from app.classrooms.dependencies.classrooms_dep import ClassroomByID from app.classrooms.models.classrooms_db import ( + AnyClassroom, Classroom, GroupClassroom, IndividualClassroom, ) from app.classrooms.models.enrollments_db import Enrollment from app.common.fastapi_ext import APIRouterExt +from app.common.schemas.classrooms_sch import ClassroomRole from app.common.sqlalchemy_ext import db router = APIRouterExt(tags=["classrooms internal"]) -@router.get( - path="/classrooms/{classroom_id}/students/", - summary="List all student ids in a classroom by id", -) -async def list_classroom_student_ids(classroom: ClassroomByID) -> Sequence[int]: +async def list_classroom_student_ids(classroom: AnyClassroom) -> Sequence[int]: match classroom: case IndividualClassroom(): return [classroom.student_id] @@ -33,6 +31,28 @@ async def list_classroom_student_ids(classroom: ClassroomByID) -> Sequence[int]: assert_never(classroom) +@router.get( + path="/classrooms/{classroom_id}/participant-ids/", + summary="List participant ids in a classroom by id filtered by role", +) +async def list_classroom_participant_ids( + classroom: ClassroomByID, + role: Annotated[ClassroomRole | None, Query()] = None, +) -> Sequence[int]: + match role: + case ClassroomRole.TUTOR: + return [classroom.tutor_id] + case ClassroomRole.STUDENT: + return await list_classroom_student_ids(classroom=classroom) + case None: + return [ + classroom.tutor_id, + *await list_classroom_student_ids(classroom=classroom), + ] + case _: + assert_never(role) + + @router.get( path="/tutors/{tutor_id}/classroom-ids/", summary="List all classroom ids for a tutor by id", diff --git a/app/common/bridges/classrooms_bdg.py b/app/common/bridges/classrooms_bdg.py index 8aad2740..69d062f0 100644 --- a/app/common/bridges/classrooms_bdg.py +++ b/app/common/bridges/classrooms_bdg.py @@ -4,6 +4,7 @@ from app.common.bridges.base_bdg import BaseBridge from app.common.bridges.utils import validate_external_json_response from app.common.config import settings +from app.common.schemas.classrooms_sch import ClassroomRole class ClassroomsBridge(BaseBridge): @@ -14,9 +15,14 @@ def __init__(self) -> None: ) @validate_external_json_response(TypeAdapter(list[int])) - async def list_classroom_student_ids(self, classroom_id: int) -> Response: + async def list_classroom_participant_ids( + self, + classroom_id: int, + role: ClassroomRole | None = None, + ) -> Response: return await self.client.get( - f"/classrooms/{classroom_id}/students/", + f"/classrooms/{classroom_id}/participant-ids/", + params=None if role is None else {"role": role}, ) @validate_external_json_response(TypeAdapter(list[int])) diff --git a/app/common/schemas/classrooms_sch.py b/app/common/schemas/classrooms_sch.py new file mode 100644 index 00000000..17fed2e7 --- /dev/null +++ b/app/common/schemas/classrooms_sch.py @@ -0,0 +1,6 @@ +from enum import StrEnum, auto + + +class ClassroomRole(StrEnum): + TUTOR = auto() + STUDENT = auto() diff --git a/app/invoices/routes/invoices_tutor_rst.py b/app/invoices/routes/invoices_tutor_rst.py index f264da3c..d2bedd9b 100644 --- a/app/invoices/routes/invoices_tutor_rst.py +++ b/app/invoices/routes/invoices_tutor_rst.py @@ -7,6 +7,7 @@ from app.common.config_bdg import classrooms_bridge, notifications_bridge from app.common.dependencies.authorization_dep import AuthorizationData from app.common.fastapi_ext import APIRouterExt, Responses +from app.common.schemas.classrooms_sch import ClassroomRole from app.common.schemas.notifications_sch import ( NotificationInputSchema, NotificationKind, @@ -82,8 +83,9 @@ async def create_invoice( auth_data: AuthorizationData, classroom_id: int, ) -> Invoice: - classroom_student_ids = await classrooms_bridge.list_classroom_student_ids( - classroom_id=classroom_id + classroom_student_ids = await classrooms_bridge.list_classroom_participant_ids( + classroom_id=classroom_id, + role=ClassroomRole.STUDENT, ) included_student_ids: list[int] if data.student_ids is None: diff --git a/tests/classrooms/functional/test_classrooms_int.py b/tests/classrooms/functional/test_classrooms_int.py index 14b896f5..8c37ea1a 100644 --- a/tests/classrooms/functional/test_classrooms_int.py +++ b/tests/classrooms/functional/test_classrooms_int.py @@ -1,58 +1,114 @@ import pytest from pydantic_marshals.contains import UnorderedLiteralCollection +from pytest_lazy_fixtures import lf, lfc from starlette import status from starlette.testclient import TestClient -from app.classrooms.models.classrooms_db import GroupClassroom, IndividualClassroom +from app.classrooms.models.classrooms_db import ( + AnyClassroom, + GroupClassroom, + IndividualClassroom, +) from app.classrooms.models.enrollments_db import Enrollment +from app.common.schemas.classrooms_sch import ClassroomRole from tests.common.assert_contains_ext import assert_response +from tests.common.utils import remove_none_values pytestmark = pytest.mark.anyio -async def test_listing_individual_classroom_students( - internal_client: TestClient, - individual_classroom: IndividualClassroom, -) -> None: - assert_response( - internal_client.get( - "/internal/classroom-service" - f"/classrooms/{individual_classroom.id}/students/", +@pytest.mark.parametrize( + ("classroom", "role", "expected_participant_ids"), + [ + pytest.param( + lf("individual_classroom"), + ClassroomRole.TUTOR, + lfc(lambda individual_classroom: [individual_classroom.tutor_id]), + id="individual_classroom-tutor", ), - expected_json=[individual_classroom.student_id], - ) - - -async def test_listing_group_classroom_students( + pytest.param( + lf("individual_classroom"), + ClassroomRole.STUDENT, + lfc(lambda individual_classroom: [individual_classroom.student_id]), + id="individual_classroom-student", + ), + pytest.param( + lf("individual_classroom"), + None, + lfc( + lambda individual_classroom: [ + individual_classroom.tutor_id, + individual_classroom.student_id, + ] + ), + id="individual_classroom-no_filter", + ), + pytest.param( + lf("group_classroom"), + ClassroomRole.TUTOR, + lfc(lambda group_classroom: [group_classroom.tutor_id]), + id="group_classroom-tutor", + ), + pytest.param( + lf("group_classroom"), + ClassroomRole.STUDENT, + [], + id="group_classroom-student-no_enrollment", + ), + pytest.param( + lf("group_classroom"), + ClassroomRole.STUDENT, + lfc(lambda enrollment: [enrollment.student_id]), + id="group_classroom-student-with_enrollment", + ), + pytest.param( + lf("group_classroom"), + None, + lfc(lambda group_classroom: [group_classroom.tutor_id]), + id="group_classroom-no_filter-no_enrollment", + ), + pytest.param( + lf("group_classroom"), + None, + lfc( + lambda group_classroom, enrollment: [ + group_classroom.tutor_id, + enrollment.student_id, + ] + ), + id="group_classroom-no_filter-with_enrollment", + ), + ], +) +async def test_listing_classroom_participant_ids( internal_client: TestClient, - group_classroom: GroupClassroom, - enrollment: Enrollment, + classroom: AnyClassroom, + role: ClassroomRole | None, + expected_participant_ids: list[int], ) -> None: assert_response( internal_client.get( - f"/internal/classroom-service/classrooms/{group_classroom.id}/students/", + f"/internal/classroom-service/classrooms/{classroom.id}/participant-ids/", + params=remove_none_values({"role": role}), ), - expected_json=[enrollment.student_id], + expected_json=expected_participant_ids, ) -async def test_listing_classroom_students_classroom_not_found( +async def test_listing_classroom_participant_ids_classroom_not_found( internal_client: TestClient, deleted_group_classroom_id: int, ) -> None: assert_response( internal_client.get( "/internal/classroom-service" - f"/classrooms/{deleted_group_classroom_id}/students/", + f"/classrooms/{deleted_group_classroom_id}/participant-ids/", ), expected_code=status.HTTP_404_NOT_FOUND, expected_json={"detail": "Classroom not found"}, ) -# TODO maybe expand - - async def test_listing_tutor_classroom_ids( internal_client: TestClient, tutor_user_id: int, diff --git a/tests/invoices/functional/test_invoice_creation.py b/tests/invoices/functional/test_invoice_creation.py index 0dd66f25..3fa587b2 100644 --- a/tests/invoices/functional/test_invoice_creation.py +++ b/tests/invoices/functional/test_invoice_creation.py @@ -9,6 +9,7 @@ from starlette.testclient import TestClient from app.common.config import settings +from app.common.schemas.classrooms_sch import ClassroomRole from app.common.schemas.notifications_sch import ( NotificationInputSchema, NotificationKind, @@ -63,7 +64,8 @@ async def test_invoice_creation( ) classroom_bridge_mock = classrooms_respx_mock.get( - path=f"/classrooms/{classroom_id}/students/" + path=f"/classrooms/{classroom_id}/participant-ids/", + params={"role": ClassroomRole.STUDENT}, ).respond(json=[student_id, other_student_id]) invoice_id: int = assert_response( @@ -148,7 +150,8 @@ async def test_invoice_creation_student_not_found( classroom_id: int, ) -> None: classroom_bridge_mock = classrooms_respx_mock.get( - path=f"/classrooms/{classroom_id}/students/" + path=f"/classrooms/{classroom_id}/participant-ids/", + params={"role": ClassroomRole.STUDENT}, ).respond(json=[]) assert_response( From 6d0d24ab12819e2db5b03530cde4623a4793c06f Mon Sep 17 00:00:00 2001 From: niqzart Date: Tue, 19 May 2026 01:27:45 +0300 Subject: [PATCH 2/4] feat: recipient filters system --- app/common/bridges/notifications_bdg.py | 10 +- app/common/schemas/notifications_sch.py | 37 +++++ app/notifications/routes/notifications_mub.py | 4 +- app/notifications/routes/notifications_sub.py | 17 ++- app/notifications/services/recipients_svc.py | 49 ++++++ tests/notifications/conftest.py | 5 + tests/notifications/factories.py | 18 +++ .../functional/test_notifications_mub.py | 7 +- .../functional/test_notifications_sub.py | 85 +++++++++-- .../service/test_recipient_svc.py | 140 ++++++++++++++++++ 10 files changed, 347 insertions(+), 25 deletions(-) create mode 100644 app/notifications/services/recipients_svc.py create mode 100644 tests/notifications/service/test_recipient_svc.py diff --git a/app/common/bridges/notifications_bdg.py b/app/common/bridges/notifications_bdg.py index edb76504..b10ef27a 100644 --- a/app/common/bridges/notifications_bdg.py +++ b/app/common/bridges/notifications_bdg.py @@ -4,7 +4,10 @@ from app.common.bridges.base_bdg import BaseBridge from app.common.bridges.utils import validate_external_json_response from app.common.config import settings -from app.common.schemas.notifications_sch import NotificationInputSchema +from app.common.schemas.notifications_sch import ( + NotificationInputSchema, + NotificationInputV2Schema, +) from app.common.schemas.user_contacts_sch import UserContactSchema @@ -37,7 +40,10 @@ async def create_or_update_email_connection( json={"email": email}, ) - async def send_notification(self, data: NotificationInputSchema) -> None: + async def send_notification( + self, + data: NotificationInputSchema | NotificationInputV2Schema, + ) -> None: await self.broker.publish( message=data.model_dump(mode="json"), stream=settings.notifications_send_stream_name, diff --git a/app/common/schemas/notifications_sch.py b/app/common/schemas/notifications_sch.py index 8c928ab5..9ddbe6a2 100644 --- a/app/common/schemas/notifications_sch.py +++ b/app/common/schemas/notifications_sch.py @@ -3,6 +3,8 @@ from pydantic import BaseModel, Field +from app.common.schemas.classrooms_sch import ClassroomRole + class NotificationKind(StrEnum): INDIVIDUAL_INVITATION_ACCEPTED_V1 = auto() @@ -73,5 +75,40 @@ class CustomNotificationPayloadSchema(BaseModel): class NotificationInputSchema(BaseModel): + # TODO remove after switching everything to V2 payload: AnyNotificationPayloadSchema recipient_user_ids: Annotated[list[int], Field(min_length=1, max_length=100)] + + +class RecipientKind(StrEnum): + SINGLE_USER = auto() + CLASSROOM_PARTICIPANT = auto() + + +class SingleUserRecipientFilterSchema(BaseModel): + kind: Literal[RecipientKind.SINGLE_USER] = RecipientKind.SINGLE_USER + + user_id: int + + +class ClassroomParticipantRecipientFilterSchema(BaseModel): + kind: Literal[RecipientKind.CLASSROOM_PARTICIPANT] = ( + RecipientKind.CLASSROOM_PARTICIPANT + ) + + classroom_id: int + role: ClassroomRole | None + + +AnyRecipientFilterSchema = Annotated[ + SingleUserRecipientFilterSchema | ClassroomParticipantRecipientFilterSchema, + Field(discriminator="kind"), +] + + +class NotificationInputV2Schema(BaseModel): + payload: AnyNotificationPayloadSchema + recipient_filters: Annotated[ + list[AnyRecipientFilterSchema], + Field(min_length=1, max_length=100), + ] diff --git a/app/notifications/routes/notifications_mub.py b/app/notifications/routes/notifications_mub.py index ed7656dd..7fa3c5a1 100644 --- a/app/notifications/routes/notifications_mub.py +++ b/app/notifications/routes/notifications_mub.py @@ -2,7 +2,7 @@ from app.common.config_bdg import notifications_bridge from app.common.fastapi_ext import APIRouterExt -from app.common.schemas.notifications_sch import NotificationInputSchema +from app.common.schemas.notifications_sch import NotificationInputV2Schema router = APIRouterExt(tags=["notifications mub"]) @@ -12,5 +12,5 @@ status_code=status.HTTP_204_NO_CONTENT, summary="Queue sending a new notification to multiple users by ids", ) -async def queue_notification_sending(data: NotificationInputSchema) -> None: +async def queue_notification_sending(data: NotificationInputV2Schema) -> None: await notifications_bridge.send_notification(data) diff --git a/app/notifications/routes/notifications_sub.py b/app/notifications/routes/notifications_sub.py index 261e109d..63b63c63 100644 --- a/app/notifications/routes/notifications_sub.py +++ b/app/notifications/routes/notifications_sub.py @@ -4,10 +4,14 @@ from app.common.config import settings from app.common.faststream_ext import build_stream_sub -from app.common.schemas.notifications_sch import NotificationInputSchema +from app.common.schemas.notifications_sch import ( + NotificationInputSchema, + NotificationInputV2Schema, +) 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 +from app.notifications.services import recipients_svc from app.notifications.services.senders import ( email_notification_sender, platform_notification_sender, @@ -26,9 +30,16 @@ ) async def send_notification( emitter: NewNotificationEmitter, - data: NotificationInputSchema, + data: NotificationInputSchema | NotificationInputV2Schema, ) -> None: - recipient_user_ids = list(set(data.recipient_user_ids)) + recipient_user_ids = ( + await recipients_svc.generate_recipient_user_ids_for_notification( + notification_data=data, + ) + ) + + if len(recipient_user_ids) == 0: + return notification = await Notification.create(payload=data.payload) diff --git a/app/notifications/services/recipients_svc.py b/app/notifications/services/recipients_svc.py new file mode 100644 index 00000000..628cd4c7 --- /dev/null +++ b/app/notifications/services/recipients_svc.py @@ -0,0 +1,49 @@ +from collections.abc import AsyncIterator +from typing import assert_never + +from app.common.config_bdg import classrooms_bridge +from app.common.schemas.notifications_sch import ( + AnyRecipientFilterSchema, + ClassroomParticipantRecipientFilterSchema, + NotificationInputSchema, + NotificationInputV2Schema, + SingleUserRecipientFilterSchema, +) + + +async def iter_recipient_user_ids_from_filter( + recipient_filter: AnyRecipientFilterSchema, +) -> AsyncIterator[int]: + match recipient_filter: + case SingleUserRecipientFilterSchema(): + yield recipient_filter.user_id + case ClassroomParticipantRecipientFilterSchema(): + for ( + recipient_user_id + ) in await classrooms_bridge.list_classroom_participant_ids( + classroom_id=recipient_filter.classroom_id, + role=recipient_filter.role, + ): + yield recipient_user_id + case _: + assert_never(recipient_filter) + + +async def generate_recipient_user_ids_for_notification( + notification_data: NotificationInputSchema | NotificationInputV2Schema, +) -> list[int]: + match notification_data: + case NotificationInputSchema(): + return list(set(notification_data.recipient_user_ids)) + case NotificationInputV2Schema(): + return list( + { + user_id + for recipient_filter in notification_data.recipient_filters + async for user_id in iter_recipient_user_ids_from_filter( + recipient_filter=recipient_filter, + ) + } + ) + case _: + assert_never(notification_data) diff --git a/tests/notifications/conftest.py b/tests/notifications/conftest.py index 659a2709..d2e546c6 100644 --- a/tests/notifications/conftest.py +++ b/tests/notifications/conftest.py @@ -31,6 +31,11 @@ from tests.notifications import factories +@pytest.fixture() +async def classroom_id(faker: Faker) -> int: + return faker.random_int() + + @pytest.fixture(scope="session") def notifications_bot_webhook_url() -> str: return "/api/public/notification-service/telegram-updates/" diff --git a/tests/notifications/factories.py b/tests/notifications/factories.py index e0624cf6..0501c4a8 100644 --- a/tests/notifications/factories.py +++ b/tests/notifications/factories.py @@ -46,6 +46,24 @@ class NotificationSimpleInputFactory(BaseModelFactory[NotificationSimpleInputSch __model__ = NotificationSimpleInputSchema +class SingleUserRecipientFilterFactory( + BaseModelFactory[notifications_sch.SingleUserRecipientFilterSchema] +): + __model__ = notifications_sch.SingleUserRecipientFilterSchema + + +class ClassroomParticipantRecipientFilterFactory( + BaseModelFactory[notifications_sch.ClassroomParticipantRecipientFilterSchema] +): + __model__ = notifications_sch.ClassroomParticipantRecipientFilterSchema + + +class NotificationInputV2Factory( + BaseModelFactory[notifications_sch.NotificationInputV2Schema] +): + __model__ = notifications_sch.NotificationInputV2Schema + + class EmailConnectionInputFactory(BaseModelFactory[EmailConnection.InputSchema]): __model__ = EmailConnection.InputSchema diff --git a/tests/notifications/functional/test_notifications_mub.py b/tests/notifications/functional/test_notifications_mub.py index b889a220..3a1d89d3 100644 --- a/tests/notifications/functional/test_notifications_mub.py +++ b/tests/notifications/functional/test_notifications_mub.py @@ -3,7 +3,7 @@ import pytest from starlette.testclient import TestClient -from app.common.schemas.notifications_sch import NotificationInputSchema +from app.common.schemas.notifications_sch import NotificationInputV2Schema from tests.common.assert_contains_ext import assert_nodata_response from tests.common.mock_stack import MockStack from tests.notifications import factories @@ -17,10 +17,7 @@ async def test_queueing_notification_sending( authorized_user_id: int, send_notification_mock: AsyncMock, ) -> None: - input_data = NotificationInputSchema( - payload=factories.NotificationSimpleInputFactory.build().payload, - recipient_user_ids=[authorized_user_id], - ) + input_data: NotificationInputV2Schema = factories.NotificationInputV2Factory.build() assert_nodata_response( mub_client.post( diff --git a/tests/notifications/functional/test_notifications_sub.py b/tests/notifications/functional/test_notifications_sub.py index 6c8330be..d2b6e865 100644 --- a/tests/notifications/functional/test_notifications_sub.py +++ b/tests/notifications/functional/test_notifications_sub.py @@ -9,18 +9,19 @@ from pydantic_marshals.contains import assert_contains from app.common.config_bdg import notifications_bridge -from app.common.schemas.notifications_sch import ( - AnyNotificationPayloadSchema, - NotificationInputSchema, -) +from app.common.schemas.notifications_sch import NotificationInputV2Schema from app.common.utils.datetime import datetime_utc_now from app.communities.rooms import user_room from app.notifications.models.notifications_db import Notification from app.notifications.models.recipient_notifications_db import RecipientNotification from app.notifications.routes.notifications_sub import send_notification +from app.notifications.services import recipients_svc from app.notifications.services.senders.email_notification_sender import ( EmailNotificationSender, ) +from app.notifications.services.senders.platform_notification_sender import ( + PlatformNotificationSender, +) from app.notifications.services.senders.telegram_notification_sender import ( TelegramNotificationSender, ) @@ -40,14 +41,16 @@ async def test_notification_send( faststream_broker: RedisBroker, tmexio_listener_factory: TMEXIOListenerFactory, ) -> None: - recipient_user_ids = random.choices(list(range(1000)), k=faker.random_int(2, 5)) + recipient_user_ids = random.choices(list(range(100)), k=faker.random_int(2, 5)) - notification_payload: AnyNotificationPayloadSchema = ( - factories.NotificationSimpleInputFactory.build().payload + generate_recipient_user_ids_for_notification_mock = mock_stack.enter_async_mock( + recipients_svc, + "generate_recipient_user_ids_for_notification", + return_value=recipient_user_ids, ) - input_data = NotificationInputSchema( - payload=notification_payload, - recipient_user_ids=recipient_user_ids * 2, + + notification_data: NotificationInputV2Schema = ( + factories.NotificationInputV2Factory.build() ) user_room_listeners = [ @@ -64,7 +67,7 @@ async def test_notification_send( send_notification.mock.reset_mock() - await notifications_bridge.send_notification(data=input_data) + await notifications_bridge.send_notification(data=notification_data) notification_ids: set[UUID] = { user_room_listener.assert_next_event( @@ -72,7 +75,7 @@ async def test_notification_send( expected_data={ "id": UUID, "created_at": datetime_utc_now(), - "payload": notification_payload.model_dump(mode="json"), + "payload": notification_data.payload.model_dump(mode="json"), }, ).data["id"] for user_room_listener in user_room_listeners @@ -83,6 +86,10 @@ async def test_notification_send( for user_room_listener in user_room_listeners: user_room_listener.assert_no_more_events() + generate_recipient_user_ids_for_notification_mock.assert_awaited_once_with( + notification_data=notification_data, + ) + sender_calls = [ call(recipient_user_id=recipient_user_id) for recipient_user_id in recipient_user_ids @@ -90,7 +97,9 @@ async def test_notification_send( email_notification_sender_mock.assert_has_calls(sender_calls, any_order=True) telegram_notification_sender_mock.assert_has_calls(sender_calls, any_order=True) - send_notification.mock.assert_called_once_with(input_data.model_dump(mode="json")) + send_notification.mock.assert_called_once_with( + notification_data.model_dump(mode="json") + ) async with active_session(): recipient_user_id_to_recipient_notification = { @@ -112,3 +121,53 @@ async def test_notification_send( notification = await Notification.find_first_by_id(notification_id) assert notification is not None await notification.delete() + + +@freeze_time() +async def test_notification_send_no_recipients_found( + faker: Faker, + active_session: ActiveSession, + mock_stack: MockStack, + faststream_broker: RedisBroker, +) -> None: + generate_recipient_user_ids_for_notification_mock = mock_stack.enter_async_mock( + recipients_svc, + "generate_recipient_user_ids_for_notification", + return_value=[], + ) + + notification_data: NotificationInputV2Schema = ( + factories.NotificationInputV2Factory.build() + ) + + platform_notification_sender_mock = mock_stack.enter_async_mock( + PlatformNotificationSender, "send_notification" + ) + email_notification_sender_mock = mock_stack.enter_async_mock( + EmailNotificationSender, "send_notification" + ) + telegram_notification_sender_mock = mock_stack.enter_async_mock( + TelegramNotificationSender, "send_notification" + ) + + send_notification.mock.reset_mock() + + await notifications_bridge.send_notification(data=notification_data) + + send_notification.mock.assert_called_once_with( + notification_data.model_dump(mode="json") + ) + + platform_notification_sender_mock.assert_not_called() + email_notification_sender_mock.assert_not_called() + telegram_notification_sender_mock.assert_not_called() + + generate_recipient_user_ids_for_notification_mock.assert_awaited_once_with( + notification_data=notification_data, + ) + + async with active_session(): + assert_contains( + list(await Notification.find_all_by_kwargs(created_at=datetime_utc_now())), + [], + ) diff --git a/tests/notifications/service/test_recipient_svc.py b/tests/notifications/service/test_recipient_svc.py new file mode 100644 index 00000000..416ddad5 --- /dev/null +++ b/tests/notifications/service/test_recipient_svc.py @@ -0,0 +1,140 @@ +import random +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import Mock, call + +import pytest +from faker import Faker +from pydantic_marshals.contains import UnorderedLiteralCollection, assert_contains +from respx import MockRouter + +from app.common.config import settings +from app.common.schemas.classrooms_sch import ClassroomRole +from app.common.schemas.notifications_sch import ( + ClassroomParticipantRecipientFilterSchema, + NotificationInputSchema, + NotificationInputV2Schema, + SingleUserRecipientFilterSchema, +) +from app.notifications.services import recipients_svc +from tests.common.mock_stack import MockStack +from tests.common.respx_ext import assert_last_httpx_request +from tests.common.utils import remove_none_values +from tests.notifications import factories + +pytestmark = pytest.mark.anyio + + +async def test_generate_recipient_user_ids_for_old_notification( + faker: Faker, +) -> None: + recipient_user_ids = random.choices(list(range(100)), k=faker.random_int(2, 5)) + + notification_data = NotificationInputSchema( + payload=factories.NotificationSimpleInputFactory.build().payload, + recipient_user_ids=recipient_user_ids * 2, + ) + + assert_contains( + await recipients_svc.generate_recipient_user_ids_for_notification( + notification_data=notification_data + ), + UnorderedLiteralCollection(recipient_user_ids), + ) + + +async def test_iter_recipient_user_ids_from_single_user_filter() -> None: + recipient_filter: SingleUserRecipientFilterSchema = ( + factories.SingleUserRecipientFilterFactory.build() + ) + + assert_contains( + [ + recipient_user_id + async for recipient_user_id in recipients_svc.iter_recipient_user_ids_from_filter( + recipient_filter=recipient_filter + ) + ], + UnorderedLiteralCollection([recipient_filter.user_id]), + ) + + +@pytest.mark.parametrize( + "role", + [ + pytest.param(ClassroomRole.TUTOR, id="tutor"), + pytest.param(ClassroomRole.STUDENT, id="student"), + pytest.param(None, id="all"), + ], +) +async def test_iter_recipient_user_ids_from_classroom_participant_filter( + faker: Faker, + classrooms_respx_mock: MockRouter, + classroom_id: int, + role: ClassroomRole | None, +) -> None: + recipient_user_ids = random.choices(list(range(100)), k=faker.random_int(2, 5)) + + classroom_bridge_mock = classrooms_respx_mock.get( + path=f"/classrooms/{classroom_id}/participant-ids/", + params=remove_none_values({"role": role}), + ).respond(json=recipient_user_ids) + + recipient_filter = ClassroomParticipantRecipientFilterSchema( + classroom_id=classroom_id, + role=role, + ) + + assert_contains( + [ + recipient_user_id + async for recipient_user_id in recipients_svc.iter_recipient_user_ids_from_filter( + recipient_filter=recipient_filter + ) + ], + UnorderedLiteralCollection(recipient_user_ids), + ) + + assert_last_httpx_request( + classroom_bridge_mock, + expected_headers={"X-Api-Key": settings.api_key}, + ) + + +async def test_generate_recipient_user_ids_for_v2_notification( + faker: Faker, + mock_stack: MockStack, +) -> None: + recipient_user_ids = random.choices(list(range(100)), k=faker.random_int(2, 5)) + + async def iter_recipient_user_ids(**_: Any) -> AsyncIterator[int]: + for recipient_user_id in recipient_user_ids: + yield recipient_user_id + + iter_recipient_user_ids_from_filter_mock = mock_stack.enter_mock( + recipients_svc, + "iter_recipient_user_ids_from_filter", + mock=Mock(side_effect=iter_recipient_user_ids), + ) + + notification_data = NotificationInputV2Schema( + payload=factories.NotificationSimpleInputFactory.build().payload, + recipient_filters=[ + factories.SingleUserRecipientFilterFactory.build(), + factories.ClassroomParticipantRecipientFilterFactory.build(), + ], + ) + + assert_contains( + await recipients_svc.generate_recipient_user_ids_for_notification( + notification_data=notification_data + ), + UnorderedLiteralCollection(recipient_user_ids), + ) + + iter_recipient_user_ids_from_filter_mock.assert_has_calls( + [ + call(recipient_filter=recipient_filter) + for recipient_filter in notification_data.recipient_filters + ] + ) From bba57fb4ce9240dce5470c5ad0716d263ea44573 Mon Sep 17 00:00:00 2001 From: niqzart Date: Tue, 19 May 2026 01:57:19 +0300 Subject: [PATCH 3/4] refactor: switch to notifications v2 everywhere --- .../routes/enrollments_tutor_rst.py | 9 ++- .../routes/invitations_student_rst.py | 17 +++-- .../routes/classroom_conferences_rst.py | 22 +++---- app/invoices/routes/invoices_student_rst.py | 9 ++- app/invoices/routes/invoices_tutor_rst.py | 9 ++- .../functional/test_enrollments_tutor_rst.py | 9 ++- .../test_invitations_student_rst.py | 15 +++-- .../router/test_classroom_conferences_rst.py | 63 +++---------------- .../functional/test_invoice_creation.py | 11 +++- .../functional/test_invoices_student_rst.py | 9 ++- 10 files changed, 81 insertions(+), 92 deletions(-) diff --git a/app/classrooms/routes/enrollments_tutor_rst.py b/app/classrooms/routes/enrollments_tutor_rst.py index d8886ee2..58566965 100644 --- a/app/classrooms/routes/enrollments_tutor_rst.py +++ b/app/classrooms/routes/enrollments_tutor_rst.py @@ -12,8 +12,9 @@ from app.common.responses import LimitedListResponses from app.common.schemas.notifications_sch import ( EnrollmentNotificationPayloadSchema, - NotificationInputSchema, + NotificationInputV2Schema, NotificationKind, + SingleUserRecipientFilterSchema, ) from app.common.schemas.users_sch import UserProfileWithIDSchema @@ -85,13 +86,15 @@ async def add_classroom_student( ) await notifications_bridge.send_notification( - NotificationInputSchema( + NotificationInputV2Schema( payload=EnrollmentNotificationPayloadSchema( kind=NotificationKind.ENROLLMENT_CREATED_V1, classroom_id=group_classroom.id, student_id=tutorship.student_id, ), - recipient_user_ids=[tutorship.student_id], + recipient_filters=[ + SingleUserRecipientFilterSchema(user_id=tutorship.student_id), + ], ) ) diff --git a/app/classrooms/routes/invitations_student_rst.py b/app/classrooms/routes/invitations_student_rst.py index 08c5566f..3fd57d6e 100644 --- a/app/classrooms/routes/invitations_student_rst.py +++ b/app/classrooms/routes/invitations_student_rst.py @@ -21,8 +21,9 @@ from app.common.responses import LimitedListResponses from app.common.schemas.notifications_sch import ( InvitationAcceptanceNotificationPayloadSchema, - NotificationInputSchema, + NotificationInputV2Schema, NotificationKind, + SingleUserRecipientFilterSchema, ) from app.common.schemas.users_sch import UserProfileWithIDSchema @@ -146,14 +147,16 @@ async def accept_individual_invitation( ) await notifications_bridge.send_notification( - NotificationInputSchema( + NotificationInputV2Schema( payload=InvitationAcceptanceNotificationPayloadSchema( kind=NotificationKind.INDIVIDUAL_INVITATION_ACCEPTED_V1, invitation_id=individual_invitation.id, classroom_id=individual_classroom.id, student_id=student_id, ), - recipient_user_ids=[individual_invitation.tutor_id], + recipient_filters=[ + SingleUserRecipientFilterSchema(user_id=individual_invitation.tutor_id), + ], ) ) @@ -185,14 +188,18 @@ async def accept_group_invitation( ) await notifications_bridge.send_notification( - NotificationInputSchema( + NotificationInputV2Schema( payload=InvitationAcceptanceNotificationPayloadSchema( kind=NotificationKind.GROUP_INVITATION_ACCEPTED_V1, invitation_id=group_invitation.id, classroom_id=group_invitation.group_classroom_id, student_id=student_id, ), - recipient_user_ids=[group_invitation.group_classroom.tutor_id], + recipient_filters=[ + SingleUserRecipientFilterSchema( + user_id=group_invitation.group_classroom.tutor_id + ), + ], ) ) diff --git a/app/conferences/routes/classroom_conferences_rst.py b/app/conferences/routes/classroom_conferences_rst.py index 173d03c2..0a5bd00d 100644 --- a/app/conferences/routes/classroom_conferences_rst.py +++ b/app/conferences/routes/classroom_conferences_rst.py @@ -3,12 +3,14 @@ from fastapi import Path from starlette import status -from app.common.config_bdg import classrooms_bridge, notifications_bridge +from app.common.config_bdg import notifications_bridge from app.common.dependencies.authorization_dep import AuthorizationData from app.common.fastapi_ext import APIRouterExt, Responses +from app.common.schemas.classrooms_sch import ClassroomRole from app.common.schemas.notifications_sch import ( ClassroomNotificationPayloadSchema, - NotificationInputSchema, + ClassroomParticipantRecipientFilterSchema, + NotificationInputV2Schema, NotificationKind, ) from app.conferences.dependencies.conferences_dep import ( @@ -36,20 +38,18 @@ async def reactivate_classroom_conference( ) -> None: await conferences_svc.reactivate_room(livekit_room_name=livekit_room_name) - # TODO: make notifications service know classrooms instead - classroom_student_ids = await classrooms_bridge.list_classroom_student_ids( - classroom_id=classroom_id - ) - if len(classroom_student_ids) == 0: - return - await notifications_bridge.send_notification( - NotificationInputSchema( + NotificationInputV2Schema( payload=ClassroomNotificationPayloadSchema( kind=NotificationKind.CLASSROOM_CONFERENCE_STARTED_V1, classroom_id=classroom_id, ), - recipient_user_ids=classroom_student_ids, + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=classroom_id, + role=ClassroomRole.STUDENT, + ) + ], ) ) diff --git a/app/invoices/routes/invoices_student_rst.py b/app/invoices/routes/invoices_student_rst.py index 2bdcac8b..f53ac119 100644 --- a/app/invoices/routes/invoices_student_rst.py +++ b/app/invoices/routes/invoices_student_rst.py @@ -6,9 +6,10 @@ from app.common.dependencies.authorization_dep import AuthorizationData from app.common.fastapi_ext import APIRouterExt from app.common.schemas.notifications_sch import ( - NotificationInputSchema, + NotificationInputV2Schema, NotificationKind, RecipientInvoiceNotificationPayloadSchema, + SingleUserRecipientFilterSchema, ) from app.invoices.dependencies.recipient_invoices_dep import ( PaymentStatusResponses, @@ -91,11 +92,13 @@ async def confirm_student_recipient_invoice_payment_with_payment_type( recipient_invoice.status = PaymentStatus.WF_RECEIVER_CONFIRMATION await notifications_bridge.send_notification( - NotificationInputSchema( + NotificationInputV2Schema( payload=RecipientInvoiceNotificationPayloadSchema( kind=NotificationKind.STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1, recipient_invoice_id=recipient_invoice.id, ), - recipient_user_ids=[recipient_invoice.tutor_id], + recipient_filters=[ + SingleUserRecipientFilterSchema(user_id=recipient_invoice.tutor_id), + ], ) ) diff --git a/app/invoices/routes/invoices_tutor_rst.py b/app/invoices/routes/invoices_tutor_rst.py index d2bedd9b..7b945b1d 100644 --- a/app/invoices/routes/invoices_tutor_rst.py +++ b/app/invoices/routes/invoices_tutor_rst.py @@ -9,9 +9,10 @@ from app.common.fastapi_ext import APIRouterExt, Responses from app.common.schemas.classrooms_sch import ClassroomRole from app.common.schemas.notifications_sch import ( - NotificationInputSchema, + NotificationInputV2Schema, NotificationKind, RecipientInvoiceNotificationPayloadSchema, + SingleUserRecipientFilterSchema, ) from app.invoices.dependencies.recipient_invoices_dep import ( PaymentStatusResponses, @@ -123,12 +124,14 @@ async def create_invoice( # TODO: batch sending or use invoice_ids instead await notifications_bridge.send_notification( - NotificationInputSchema( + NotificationInputV2Schema( payload=RecipientInvoiceNotificationPayloadSchema( kind=NotificationKind.RECIPIENT_INVOICE_CREATED_V1, recipient_invoice_id=recipient_invoice.id, ), - recipient_user_ids=[student_id], + recipient_filters=[ + SingleUserRecipientFilterSchema(user_id=student_id), + ], ) ) diff --git a/tests/classrooms/functional/test_enrollments_tutor_rst.py b/tests/classrooms/functional/test_enrollments_tutor_rst.py index b1dc6a2b..bf88e0f8 100644 --- a/tests/classrooms/functional/test_enrollments_tutor_rst.py +++ b/tests/classrooms/functional/test_enrollments_tutor_rst.py @@ -13,8 +13,9 @@ from app.common.config import settings from app.common.schemas.notifications_sch import ( EnrollmentNotificationPayloadSchema, - NotificationInputSchema, + NotificationInputV2Schema, NotificationKind, + SingleUserRecipientFilterSchema, ) from app.common.utils.datetime import datetime_utc_now from tests.common.active_session import ActiveSession @@ -110,13 +111,15 @@ async def test_adding_classroom_student( ) send_notification_mock.assert_awaited_once_with( - NotificationInputSchema( + NotificationInputV2Schema( payload=EnrollmentNotificationPayloadSchema( kind=NotificationKind.ENROLLMENT_CREATED_V1, classroom_id=group_classroom.id, student_id=tutorship.student_id, ), - recipient_user_ids=[tutorship.student_id], + recipient_filters=[ + SingleUserRecipientFilterSchema(user_id=tutorship.student_id), + ], ) ) diff --git a/tests/classrooms/functional/test_invitations_student_rst.py b/tests/classrooms/functional/test_invitations_student_rst.py index 6b8b442f..d143f211 100644 --- a/tests/classrooms/functional/test_invitations_student_rst.py +++ b/tests/classrooms/functional/test_invitations_student_rst.py @@ -24,8 +24,9 @@ from app.common.config import settings from app.common.schemas.notifications_sch import ( InvitationAcceptanceNotificationPayloadSchema, - NotificationInputSchema, + NotificationInputV2Schema, NotificationKind, + SingleUserRecipientFilterSchema, ) from app.common.schemas.users_sch import UserProfileSchema from app.common.utils.datetime import datetime_utc_now @@ -184,14 +185,16 @@ async def test_individual_invitation_accepting( await classroom.delete() send_notification_mock.assert_awaited_once_with( - NotificationInputSchema( + NotificationInputV2Schema( payload=InvitationAcceptanceNotificationPayloadSchema( kind=NotificationKind.INDIVIDUAL_INVITATION_ACCEPTED_V1, invitation_id=individual_invitation.id, classroom_id=classroom_id, student_id=student_user_id, ), - recipient_user_ids=[individual_invitation.tutor_id], + recipient_filters=[ + SingleUserRecipientFilterSchema(user_id=individual_invitation.tutor_id), + ], ) ) @@ -350,14 +353,16 @@ async def test_group_invitation_accepting( await enrollment.delete() send_notification_mock.assert_awaited_once_with( - NotificationInputSchema( + NotificationInputV2Schema( payload=InvitationAcceptanceNotificationPayloadSchema( kind=NotificationKind.GROUP_INVITATION_ACCEPTED_V1, invitation_id=group_invitation.id, classroom_id=group_classroom.id, student_id=student_user_id, ), - recipient_user_ids=[group_invitation.tutor_id], + recipient_filters=[ + SingleUserRecipientFilterSchema(user_id=group_invitation.tutor_id), + ], ) ) diff --git a/tests/conferences/router/test_classroom_conferences_rst.py b/tests/conferences/router/test_classroom_conferences_rst.py index 415efaec..84f56dc7 100644 --- a/tests/conferences/router/test_classroom_conferences_rst.py +++ b/tests/conferences/router/test_classroom_conferences_rst.py @@ -1,18 +1,17 @@ -import random from unittest.mock import AsyncMock import pytest from faker import Faker from livekit.protocol.models import ParticipantInfo, Room from pytest_lazy_fixtures import lf, lfc -from respx import MockRouter from starlette import status from starlette.testclient import TestClient -from app.common.config import settings +from app.common.schemas.classrooms_sch import ClassroomRole from app.common.schemas.notifications_sch import ( ClassroomNotificationPayloadSchema, - NotificationInputSchema, + ClassroomParticipantRecipientFilterSchema, + NotificationInputV2Schema, NotificationKind, ) from app.conferences.schemas.conferences_sch import ( @@ -21,7 +20,6 @@ ) from tests.common.assert_contains_ext import assert_nodata_response, assert_response from tests.common.mock_stack import MockStack -from tests.common.respx_ext import assert_last_httpx_request from tests.conferences.conftest import ClassroomRoleType from tests.conferences.factories import ( ConferenceParticipantFactory, @@ -34,7 +32,6 @@ async def test_classroom_conference_reactivation( mock_stack: MockStack, - classrooms_respx_mock: MockRouter, send_notification_mock: AsyncMock, outsider_client: TestClient, classroom_id: int, @@ -44,11 +41,6 @@ async def test_classroom_conference_reactivation( "app.conferences.services.conferences_svc.reactivate_room" ) - recipient_user_ids = random.choices(list(range(100)), k=random.randint(2, 10)) - classroom_bridge_mock = classrooms_respx_mock.get( - path=f"/classrooms/{classroom_id}/students/" - ).respond(json=recipient_user_ids) - assert_nodata_response( outsider_client.post( "/api/protected/conference-service/roles/tutor" @@ -57,55 +49,20 @@ async def test_classroom_conference_reactivation( ) send_notification_mock.assert_awaited_once_with( - NotificationInputSchema( + NotificationInputV2Schema( payload=ClassroomNotificationPayloadSchema( kind=NotificationKind.CLASSROOM_CONFERENCE_STARTED_V1, classroom_id=classroom_id, ), - recipient_user_ids=recipient_user_ids, + recipient_filters=[ + ClassroomParticipantRecipientFilterSchema( + classroom_id=classroom_id, + role=ClassroomRole.STUDENT, + ) + ], ) ) - assert_last_httpx_request( - classroom_bridge_mock, - expected_headers={"X-Api-Key": settings.api_key}, - ) - - conferences_svc_mock.assert_awaited_once_with( - livekit_room_name=classroom_conference_room_name - ) - - -async def test_classroom_conference_reactivation_no_students( - mock_stack: MockStack, - classrooms_respx_mock: MockRouter, - send_notification_mock: AsyncMock, - outsider_client: TestClient, - classroom_id: int, - classroom_conference_room_name: str, -) -> None: - conferences_svc_mock = mock_stack.enter_async_mock( - "app.conferences.services.conferences_svc.reactivate_room" - ) - - classroom_bridge_mock = classrooms_respx_mock.get( - path=f"/classrooms/{classroom_id}/students/" - ).respond(json=[]) - - assert_nodata_response( - outsider_client.post( - "/api/protected/conference-service/roles/tutor" - f"/classrooms/{classroom_id}/conference/", - ), - ) - - send_notification_mock.assert_not_called() - - assert_last_httpx_request( - classroom_bridge_mock, - expected_headers={"X-Api-Key": settings.api_key}, - ) - conferences_svc_mock.assert_awaited_once_with( livekit_room_name=classroom_conference_room_name ) diff --git a/tests/invoices/functional/test_invoice_creation.py b/tests/invoices/functional/test_invoice_creation.py index 3fa587b2..fe582ae2 100644 --- a/tests/invoices/functional/test_invoice_creation.py +++ b/tests/invoices/functional/test_invoice_creation.py @@ -11,9 +11,10 @@ from app.common.config import settings from app.common.schemas.classrooms_sch import ClassroomRole from app.common.schemas.notifications_sch import ( - NotificationInputSchema, + NotificationInputV2Schema, NotificationKind, RecipientInvoiceNotificationPayloadSchema, + SingleUserRecipientFilterSchema, ) from app.common.utils.datetime import datetime_utc_now from app.invoices.models.invoice_items_db import InvoiceItem @@ -125,12 +126,16 @@ async def test_invoice_creation( send_notification_mock.assert_has_awaits( [ call( - NotificationInputSchema( + NotificationInputV2Schema( payload=RecipientInvoiceNotificationPayloadSchema( kind=NotificationKind.RECIPIENT_INVOICE_CREATED_V1, recipient_invoice_id=recipient_invoice.id, ), - recipient_user_ids=[recipient_invoice.student_id], + recipient_filters=[ + SingleUserRecipientFilterSchema( + user_id=recipient_invoice.student_id + ), + ], ) ) for recipient_invoice in student_id_to_recipient_invoice.values() diff --git a/tests/invoices/functional/test_invoices_student_rst.py b/tests/invoices/functional/test_invoices_student_rst.py index 76c6af8e..c4af1a96 100644 --- a/tests/invoices/functional/test_invoices_student_rst.py +++ b/tests/invoices/functional/test_invoices_student_rst.py @@ -7,9 +7,10 @@ from starlette.testclient import TestClient from app.common.schemas.notifications_sch import ( - NotificationInputSchema, + NotificationInputV2Schema, NotificationKind, RecipientInvoiceNotificationPayloadSchema, + SingleUserRecipientFilterSchema, ) from app.invoices.models.recipient_invoices_db import ( PaymentStatus, @@ -71,12 +72,14 @@ async def test_student_recipient_invoice_payment_confirmation( ) send_notification_mock.assert_awaited_once_with( - NotificationInputSchema( + NotificationInputV2Schema( payload=RecipientInvoiceNotificationPayloadSchema( kind=NotificationKind.STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1, recipient_invoice_id=recipient_invoice.id, ), - recipient_user_ids=[recipient_invoice.tutor_id], + recipient_filters=[ + SingleUserRecipientFilterSchema(user_id=recipient_invoice.tutor_id), + ], ) ) From 59f4853e6cbf36f9f7377c75ba369cedd322d1e5 Mon Sep 17 00:00:00 2001 From: niqzart Date: Tue, 19 May 2026 01:58:28 +0300 Subject: [PATCH 4/4] refactor: remove the old notification input schema --- app/common/bridges/notifications_bdg.py | 7 ++---- app/common/schemas/notifications_sch.py | 6 ----- app/notifications/routes/notifications_sub.py | 7 ++---- app/notifications/services/recipients_svc.py | 25 +++++++------------ .../service/test_recipient_svc.py | 19 -------------- 5 files changed, 13 insertions(+), 51 deletions(-) diff --git a/app/common/bridges/notifications_bdg.py b/app/common/bridges/notifications_bdg.py index b10ef27a..056d250f 100644 --- a/app/common/bridges/notifications_bdg.py +++ b/app/common/bridges/notifications_bdg.py @@ -4,10 +4,7 @@ from app.common.bridges.base_bdg import BaseBridge from app.common.bridges.utils import validate_external_json_response from app.common.config import settings -from app.common.schemas.notifications_sch import ( - NotificationInputSchema, - NotificationInputV2Schema, -) +from app.common.schemas.notifications_sch import NotificationInputV2Schema from app.common.schemas.user_contacts_sch import UserContactSchema @@ -42,7 +39,7 @@ async def create_or_update_email_connection( async def send_notification( self, - data: NotificationInputSchema | NotificationInputV2Schema, + data: NotificationInputV2Schema, ) -> None: await self.broker.publish( message=data.model_dump(mode="json"), diff --git a/app/common/schemas/notifications_sch.py b/app/common/schemas/notifications_sch.py index 9ddbe6a2..837453a5 100644 --- a/app/common/schemas/notifications_sch.py +++ b/app/common/schemas/notifications_sch.py @@ -74,12 +74,6 @@ class CustomNotificationPayloadSchema(BaseModel): ] -class NotificationInputSchema(BaseModel): - # TODO remove after switching everything to V2 - payload: AnyNotificationPayloadSchema - recipient_user_ids: Annotated[list[int], Field(min_length=1, max_length=100)] - - class RecipientKind(StrEnum): SINGLE_USER = auto() CLASSROOM_PARTICIPANT = auto() diff --git a/app/notifications/routes/notifications_sub.py b/app/notifications/routes/notifications_sub.py index 63b63c63..91286334 100644 --- a/app/notifications/routes/notifications_sub.py +++ b/app/notifications/routes/notifications_sub.py @@ -4,10 +4,7 @@ from app.common.config import settings from app.common.faststream_ext import build_stream_sub -from app.common.schemas.notifications_sch import ( - NotificationInputSchema, - NotificationInputV2Schema, -) +from app.common.schemas.notifications_sch import NotificationInputV2Schema 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 @@ -30,7 +27,7 @@ ) async def send_notification( emitter: NewNotificationEmitter, - data: NotificationInputSchema | NotificationInputV2Schema, + data: NotificationInputV2Schema, ) -> None: recipient_user_ids = ( await recipients_svc.generate_recipient_user_ids_for_notification( diff --git a/app/notifications/services/recipients_svc.py b/app/notifications/services/recipients_svc.py index 628cd4c7..11fbcd67 100644 --- a/app/notifications/services/recipients_svc.py +++ b/app/notifications/services/recipients_svc.py @@ -5,7 +5,6 @@ from app.common.schemas.notifications_sch import ( AnyRecipientFilterSchema, ClassroomParticipantRecipientFilterSchema, - NotificationInputSchema, NotificationInputV2Schema, SingleUserRecipientFilterSchema, ) @@ -30,20 +29,14 @@ async def iter_recipient_user_ids_from_filter( async def generate_recipient_user_ids_for_notification( - notification_data: NotificationInputSchema | NotificationInputV2Schema, + notification_data: NotificationInputV2Schema, ) -> list[int]: - match notification_data: - case NotificationInputSchema(): - return list(set(notification_data.recipient_user_ids)) - case NotificationInputV2Schema(): - return list( - { - user_id - for recipient_filter in notification_data.recipient_filters - async for user_id in iter_recipient_user_ids_from_filter( - recipient_filter=recipient_filter, - ) - } + return list( + { + user_id + for recipient_filter in notification_data.recipient_filters + async for user_id in iter_recipient_user_ids_from_filter( + recipient_filter=recipient_filter, ) - case _: - assert_never(notification_data) + } + ) diff --git a/tests/notifications/service/test_recipient_svc.py b/tests/notifications/service/test_recipient_svc.py index 416ddad5..ba7752cb 100644 --- a/tests/notifications/service/test_recipient_svc.py +++ b/tests/notifications/service/test_recipient_svc.py @@ -12,7 +12,6 @@ from app.common.schemas.classrooms_sch import ClassroomRole from app.common.schemas.notifications_sch import ( ClassroomParticipantRecipientFilterSchema, - NotificationInputSchema, NotificationInputV2Schema, SingleUserRecipientFilterSchema, ) @@ -25,24 +24,6 @@ pytestmark = pytest.mark.anyio -async def test_generate_recipient_user_ids_for_old_notification( - faker: Faker, -) -> None: - recipient_user_ids = random.choices(list(range(100)), k=faker.random_int(2, 5)) - - notification_data = NotificationInputSchema( - payload=factories.NotificationSimpleInputFactory.build().payload, - recipient_user_ids=recipient_user_ids * 2, - ) - - assert_contains( - await recipients_svc.generate_recipient_user_ids_for_notification( - notification_data=notification_data - ), - UnorderedLiteralCollection(recipient_user_ids), - ) - - async def test_iter_recipient_user_ids_from_single_user_filter() -> None: recipient_filter: SingleUserRecipientFilterSchema = ( factories.SingleUserRecipientFilterFactory.build()