diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 6d18089c..3dfbf9cb 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -61,7 +61,7 @@ jobs: && contains(github.event.pull_request.labels.*.name, 'ci:deployable') runs-on: ubuntu-latest - environment: ${{ needs.namer.outputs.branch == 'staging' && github.actor == github.triggering_actor && 'staging' || 'manual-staging' }} + environment: manual-staging env: pull_image: ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_USERNAME }}:${{ needs.namer.outputs.tag }} diff --git a/alembic/versions/055_classroom_events.py b/alembic/versions/055_classroom_events.py new file mode 100644 index 00000000..1c7f10c8 --- /dev/null +++ b/alembic/versions/055_classroom_events.py @@ -0,0 +1,46 @@ +"""classroom_events + +Revision ID: 055 +Revises: 054 +Create Date: 2025-11-21 00:41:26.686639 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "055" +down_revision: Union[str, None] = "054" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +EventKind = sa.Enum("CLASSROOM", name="eventkind") + + +def upgrade() -> None: + op.execute("TRUNCATE TABLE xi_back_2.scheduler_events") + EventKind.create(bind=op.get_bind()) + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "scheduler_events", + sa.Column("kind", EventKind, nullable=False), + schema="xi_back_2", + ) + op.add_column( + "scheduler_events", + sa.Column("classroom_id", sa.Integer(), nullable=True), + schema="xi_back_2", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("scheduler_events", "classroom_id", schema="xi_back_2") + op.drop_column("scheduler_events", "kind", schema="xi_back_2") + # ### end Alembic commands ### + EventKind.drop(bind=op.get_bind()) diff --git a/app/common/config.py b/app/common/config.py index 983bdbe7..f22122a7 100644 --- a/app/common/config.py +++ b/app/common/config.py @@ -92,6 +92,7 @@ def is_testing_mode(self) -> bool: cookie_domain: str = "localhost" frontend_app_base_url: str = "https://app.sovlium.ru" + frontend_vacancies_base_url: str = "https://vacancy.sovlium.ru/vacancy" password_reset_keys: FernetSettings = FernetSettings(encryption_ttl=60 * 60) email_confirmation_keys: FernetSettings = FernetSettings( @@ -208,9 +209,13 @@ def redis_supbot_dsn(self) -> str: FaststreamIntegration(), # other integrations are automatic ], + before_breadcrumb=before_breadcrumb, traces_sample_rate=0, profiles_sample_rate=0, - before_breadcrumb=before_breadcrumb, + send_default_pii=True, + max_request_body_size="always", + send_client_reports=False, + auto_session_tracking=False, ) diff --git a/app/common/schemas/notifications_sch.py b/app/common/schemas/notifications_sch.py index a62bf5c3..8c928ab5 100644 --- a/app/common/schemas/notifications_sch.py +++ b/app/common/schemas/notifications_sch.py @@ -15,6 +15,8 @@ class NotificationKind(StrEnum): RECIPIENT_INVOICE_CREATED_V1 = auto() STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1 = auto() + CUSTOM_V1 = auto() + class InvitationAcceptanceNotificationPayloadSchema(BaseModel): kind: Literal[ @@ -49,11 +51,23 @@ class RecipientInvoiceNotificationPayloadSchema(BaseModel): recipient_invoice_id: int +class CustomNotificationPayloadSchema(BaseModel): + kind: Literal[NotificationKind.CUSTOM_V1] + + theme: str + pre_header: str + header: str + content: str + button_text: str + button_link: str + + AnyNotificationPayloadSchema = Annotated[ InvitationAcceptanceNotificationPayloadSchema | EnrollmentNotificationPayloadSchema | ClassroomNotificationPayloadSchema - | RecipientInvoiceNotificationPayloadSchema, + | RecipientInvoiceNotificationPayloadSchema + | CustomNotificationPayloadSchema, Field(discriminator="kind"), ] diff --git a/app/common/schemas/pochta_sch.py b/app/common/schemas/pochta_sch.py index aeddde25..1d4d4f90 100644 --- a/app/common/schemas/pochta_sch.py +++ b/app/common/schemas/pochta_sch.py @@ -6,6 +6,8 @@ class EmailMessageKind(StrEnum): + CUSTOM_V1 = auto() + EMAIL_CONFIRMATION_V2 = auto() EMAIL_CHANGE_V2 = auto() PASSWORD_RESET_V2 = auto() @@ -21,6 +23,17 @@ class EmailMessageKind(StrEnum): STUDENT_RECIPIENT_INVOICE_PAYMENT_CONFIRMED_V1 = auto() +class CustomEmailMessagePayloadSchema(BaseModel): + kind: Literal[EmailMessageKind.CUSTOM_V1] + + theme: str + pre_header: str + header: str + content: str + button_text: str + button_link: str + + class TokenEmailMessagePayloadSchema(BaseModel): kind: Literal[ EmailMessageKind.EMAIL_CONFIRMATION_V2, @@ -60,7 +73,8 @@ class RecipientInvoiceNotificationEmailMessagePayloadSchema( AnyEmailMessagePayload = Annotated[ - TokenEmailMessagePayloadSchema + CustomEmailMessagePayloadSchema + | TokenEmailMessagePayloadSchema | ClassroomNotificationEmailMessagePayloadSchema | RecipientInvoiceNotificationEmailMessagePayloadSchema, Field(discriminator="kind"), diff --git a/app/notifications/services/adapters/base_adapter.py b/app/notifications/services/adapters/base_adapter.py index 40e650ce..e4d789ae 100644 --- a/app/notifications/services/adapters/base_adapter.py +++ b/app/notifications/services/adapters/base_adapter.py @@ -3,6 +3,7 @@ from app.common.schemas.notifications_sch import ( ClassroomNotificationPayloadSchema, + CustomNotificationPayloadSchema, EnrollmentNotificationPayloadSchema, InvitationAcceptanceNotificationPayloadSchema, NotificationKind, @@ -57,6 +58,13 @@ def adapt_student_recipient_invoice_payment_confirmed_v1( ) -> T: raise NotImplementedError + @abstractmethod + def adapt_custom_v1( + self, + payload: CustomNotificationPayloadSchema, + ) -> T: + raise NotImplementedError + def adapt(self) -> T: # cast is used because mypy doesn't understand pydantic's discriminated unions payload = self.notification.payload @@ -85,5 +93,9 @@ def adapt(self) -> T: return self.adapt_student_recipient_invoice_payment_confirmed_v1( cast(RecipientInvoiceNotificationPayloadSchema, payload) ) + case NotificationKind.CUSTOM_V1: + return self.adapt_custom_v1( + cast(CustomNotificationPayloadSchema, payload) + ) case _: assert_never(payload.kind) diff --git a/app/notifications/services/adapters/email_message_adapter.py b/app/notifications/services/adapters/email_message_adapter.py index 0ee04c0d..ce6dfff5 100644 --- a/app/notifications/services/adapters/email_message_adapter.py +++ b/app/notifications/services/adapters/email_message_adapter.py @@ -1,5 +1,6 @@ from app.common.schemas.notifications_sch import ( ClassroomNotificationPayloadSchema, + CustomNotificationPayloadSchema, EnrollmentNotificationPayloadSchema, InvitationAcceptanceNotificationPayloadSchema, RecipientInvoiceNotificationPayloadSchema, @@ -7,6 +8,7 @@ from app.common.schemas.pochta_sch import ( AnyEmailMessagePayload, ClassroomNotificationEmailMessagePayloadSchema, + CustomEmailMessagePayloadSchema, EmailMessageKind, RecipientInvoiceNotificationEmailMessagePayloadSchema, ) @@ -75,3 +77,16 @@ def adapt_student_recipient_invoice_payment_confirmed_v1( recipient_invoice_id=payload.recipient_invoice_id, notification_id=self.notification.id, ) + + def adapt_custom_v1( + self, payload: CustomNotificationPayloadSchema + ) -> CustomEmailMessagePayloadSchema: + return CustomEmailMessagePayloadSchema( + kind=EmailMessageKind.CUSTOM_V1, + theme=payload.theme, + pre_header=payload.pre_header, + header=payload.header, + content=payload.content, + button_text=payload.button_text, + button_link=payload.button_link, + ) diff --git a/app/notifications/services/adapters/telegram_message_adapter.py b/app/notifications/services/adapters/telegram_message_adapter.py index 7333d28d..92314322 100644 --- a/app/notifications/services/adapters/telegram_message_adapter.py +++ b/app/notifications/services/adapters/telegram_message_adapter.py @@ -6,6 +6,7 @@ from app.common.config import settings from app.common.schemas.notifications_sch import ( ClassroomNotificationPayloadSchema, + CustomNotificationPayloadSchema, EnrollmentNotificationPayloadSchema, InvitationAcceptanceNotificationPayloadSchema, RecipientInvoiceNotificationPayloadSchema, @@ -117,3 +118,13 @@ def adapt_student_recipient_invoice_payment_confirmed_v1( }, ), ) + + def adapt_custom_v1( + self, + payload: CustomNotificationPayloadSchema, + ) -> TelegramMessagePayloadSchema: + return TelegramMessagePayloadSchema( + message_text=f"{payload.header}\n\n{payload.content}", + button_text=payload.button_text, + button_link=payload.button_link, + ) diff --git a/app/pochta/routes/email_messages_sub.py b/app/pochta/routes/email_messages_sub.py index 7787b295..0c2acab4 100644 --- a/app/pochta/routes/email_messages_sub.py +++ b/app/pochta/routes/email_messages_sub.py @@ -19,6 +19,7 @@ } KIND_TO_TEMPLATE_ID: dict[EmailMessageKind, str] = { + EmailMessageKind.CUSTOM_V1: "228a8576-b802-11f0-bca7-d2544595dc68", EmailMessageKind.EMAIL_CONFIRMATION_V2: "05b83984-bd89-11f0-81f1-122da0a24080", EmailMessageKind.EMAIL_CHANGE_V2: "a25aced8-bd88-11f0-b8e4-122da0a24080", EmailMessageKind.PASSWORD_RESET_V2: "3b5242e2-bd89-11f0-8132-025779db5bd3", diff --git a/app/scheduler/dependencies/classroom_events_dep.py b/app/scheduler/dependencies/classroom_events_dep.py new file mode 100644 index 00000000..6e024871 --- /dev/null +++ b/app/scheduler/dependencies/classroom_events_dep.py @@ -0,0 +1,44 @@ +from typing import Annotated + +from fastapi import Depends, Path +from starlette import status + +from app.common.fastapi_ext import Responses, with_responses +from app.scheduler.models.events_db import ClassroomEvent + + +class ClassroomEventResponses(Responses): + CLASSROOM_EVENT_NOT_FOUND = status.HTTP_404_NOT_FOUND, "Classroom event not found" + + +@with_responses(ClassroomEventResponses) +async def get_classroom_event_by_id(event_id: Annotated[int, Path()]) -> ClassroomEvent: + classroom_event = await ClassroomEvent.find_first_by_id(event_id) + if classroom_event is None: + raise ClassroomEventResponses.CLASSROOM_EVENT_NOT_FOUND + return classroom_event + + +ClassroomEventByID = Annotated[ClassroomEvent, Depends(get_classroom_event_by_id)] + + +class MyClassroomEventResponses(Responses): + CLASSROOM_EVENT_ACCESS_DENIED = ( + status.HTTP_403_FORBIDDEN, + "Classroom event access denied", + ) + + +@with_responses(MyClassroomEventResponses) +async def get_my_classroom_event_by_ids( + classroom_event: ClassroomEventByID, + classroom_id: Annotated[int, Path()], +) -> ClassroomEvent: + if classroom_event.classroom_id != classroom_id: + raise MyClassroomEventResponses.CLASSROOM_EVENT_ACCESS_DENIED + return classroom_event + + +MyClassroomEventByIDs = Annotated[ + ClassroomEvent, Depends(get_my_classroom_event_by_ids) +] diff --git a/app/scheduler/dependencies/events_dep.py b/app/scheduler/dependencies/events_dep.py index 5e2752c7..81b99693 100644 --- a/app/scheduler/dependencies/events_dep.py +++ b/app/scheduler/dependencies/events_dep.py @@ -1,22 +1,20 @@ -from typing import Annotated +from typing import Annotated, Self -from fastapi import Depends, Path -from starlette import status +from fastapi import Query +from pydantic import AwareDatetime, BaseModel, model_validator -from app.common.fastapi_ext import Responses, with_responses -from app.scheduler.models.events_db import Event +class EventTimeFrameSchema(BaseModel): + happens_after: AwareDatetime + happens_before: AwareDatetime -class EventResponses(Responses): - EVENT_NOT_FOUND = status.HTTP_404_NOT_FOUND, "Event not found" + @model_validator(mode="after") + def validate_happens_after_and_happens_before(self) -> Self: + if self.happens_after >= self.happens_before: + raise ValueError( + "parameter happens_before must be later in time than happens_after" + ) + return self -@with_responses(EventResponses) -async def get_event_by_id(event_id: Annotated[int, Path()]) -> Event: - event = await Event.find_first_by_id(event_id) - if event is None: - raise EventResponses.EVENT_NOT_FOUND - return event - - -EventById = Annotated[Event, Depends(get_event_by_id)] +EventTimeFrameQuery = Annotated[EventTimeFrameSchema, Query()] diff --git a/app/scheduler/main.py b/app/scheduler/main.py index 11d4d092..58398042 100644 --- a/app/scheduler/main.py +++ b/app/scheduler/main.py @@ -6,7 +6,10 @@ from app.common.dependencies.authorization_dep import ProxyAuthorized from app.common.dependencies.mub_dep import MUBProtection from app.common.fastapi_ext import APIRouterExt -from app.scheduler.routes import events_mub +from app.scheduler.routes import ( + classroom_events_student_rst, + classroom_events_tutor_rst, +) outside_router = APIRouterExt(prefix="/api/public/scheduler-service") @@ -14,12 +17,13 @@ dependencies=[ProxyAuthorized], prefix="/api/protected/scheduler-service", ) +authorized_router.include_router(classroom_events_tutor_rst.router) +authorized_router.include_router(classroom_events_student_rst.router) mub_router = APIRouterExt( dependencies=[MUBProtection], prefix="/mub/scheduler-service", ) -mub_router.include_router(events_mub.router) internal_router = APIRouterExt( dependencies=[APIKeyProtection], diff --git a/app/scheduler/models/events_db.py b/app/scheduler/models/events_db.py index e4e5dc9f..4de4a923 100644 --- a/app/scheduler/models/events_db.py +++ b/app/scheduler/models/events_db.py @@ -1,18 +1,23 @@ from collections.abc import Sequence from datetime import datetime -from typing import Annotated, Self +from enum import StrEnum, auto +from typing import Annotated, Literal, Self from pydantic import AwareDatetime, Field from pydantic_marshals.sqlalchemy import MappedModel -from sqlalchemy import DateTime, String, and_, select +from sqlalchemy import DateTime, Enum, String, and_, select from sqlalchemy.orm import Mapped, mapped_column from app.common.config import Base from app.common.sqlalchemy_ext import db +class EventKind(StrEnum): + CLASSROOM = auto() + + class Event(Base): - __tablename__ = "scheduler_events" + __tablename__: str | None = "scheduler_events" id: Mapped[int] = mapped_column(primary_key=True) starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) @@ -20,9 +25,16 @@ class Event(Base): name: Mapped[str] = mapped_column(String(100)) description: Mapped[str | None] = mapped_column(String(1000), default=None) + kind: Mapped[EventKind] = mapped_column(Enum(EventKind)) + NameType = Annotated[str, Field(min_length=1, max_length=100)] DescriptionType = Annotated[str | None, Field(min_length=1, max_length=1000)] + __mapper_args__ = { + "polymorphic_on": kind, + "polymorphic_abstract": True, + } + InputSchema = MappedModel.create( columns=[ (starts_at, AwareDatetime), @@ -31,15 +43,36 @@ class Event(Base): (description, DescriptionType), ], ) - ResponseSchema = InputSchema.extend([id]) + ResponseSchema = InputSchema.extend(columns=[id]) + + +class ClassroomEvent(Event): + __tablename__ = None + + __mapper_args__ = { + "polymorphic_identity": EventKind.CLASSROOM, + "polymorphic_load": "inline", + } + + classroom_id: Mapped[int] = mapped_column(nullable=True) + + InputSchema = MappedModel.create(bases=[Event.InputSchema]) + ResponseSchema = MappedModel.create( + bases=[Event.ResponseSchema], + columns=[classroom_id], + extra_fields={"kind": (Literal[EventKind.CLASSROOM], EventKind.CLASSROOM)}, + ) @classmethod - async def find_all_events_in_time_frame( - cls, *, happens_after: datetime, happens_before: datetime + async def find_all_by_classroom_id_in_time_frame( + cls, + classroom_id: int, + happens_after: datetime, + happens_before: datetime, ) -> Sequence[Self]: - stmt = ( + return await db.get_all( select(cls) - .where(and_(cls.starts_at < happens_before, cls.ends_at > happens_after)) + .filter_by(classroom_id=classroom_id) + .filter(and_(cls.starts_at < happens_before, cls.ends_at > happens_after)) .order_by(cls.starts_at.desc()) ) - return await db.get_all(stmt) diff --git a/app/scheduler/routes/classroom_events_student_rst.py b/app/scheduler/routes/classroom_events_student_rst.py new file mode 100644 index 00000000..89744d67 --- /dev/null +++ b/app/scheduler/routes/classroom_events_student_rst.py @@ -0,0 +1,40 @@ +from collections.abc import Sequence +from typing import Annotated + +from fastapi import Path + +from app.common.fastapi_ext import APIRouterExt +from app.scheduler.dependencies.classroom_events_dep import ( + MyClassroomEventByIDs, +) +from app.scheduler.dependencies.events_dep import EventTimeFrameQuery +from app.scheduler.models.events_db import ClassroomEvent + +router = APIRouterExt(tags=["student classroom events"]) + + +@router.get( + path="/roles/student/classrooms/{classroom_id}/events/", + response_model=list[ClassroomEvent.ResponseSchema], + summary="List paginated events in a classroom by id", +) +async def list_classroom_events( + classroom_id: Annotated[int, Path()], + time_frame: EventTimeFrameQuery, +) -> Sequence[ClassroomEvent]: + return await ClassroomEvent.find_all_by_classroom_id_in_time_frame( + classroom_id=classroom_id, + happens_after=time_frame.happens_after, + happens_before=time_frame.happens_before, + ) + + +@router.get( + path="/roles/student/classrooms/{classroom_id}/events/{event_id}/", + response_model=ClassroomEvent.ResponseSchema, + summary="Retrieve a classroom event by ids", +) +async def retrieve_classroom_event( + classroom_event: MyClassroomEventByIDs, +) -> ClassroomEvent: + return classroom_event diff --git a/app/scheduler/routes/classroom_events_tutor_rst.py b/app/scheduler/routes/classroom_events_tutor_rst.py new file mode 100644 index 00000000..ef2229ea --- /dev/null +++ b/app/scheduler/routes/classroom_events_tutor_rst.py @@ -0,0 +1,89 @@ +from collections.abc import Sequence +from typing import Annotated, Self + +from fastapi import Path +from pydantic import model_validator +from starlette import status + +from app.common.fastapi_ext import APIRouterExt +from app.scheduler.dependencies.classroom_events_dep import ( + MyClassroomEventByIDs, +) +from app.scheduler.dependencies.events_dep import EventTimeFrameQuery +from app.scheduler.models.events_db import ClassroomEvent + +router = APIRouterExt(tags=["tutor classroom events"]) + + +@router.get( + path="/roles/tutor/classrooms/{classroom_id}/events/", + response_model=list[ClassroomEvent.ResponseSchema], + summary="List paginated events in a classroom by id", +) +async def list_classroom_events( + classroom_id: Annotated[int, Path()], + time_frame: EventTimeFrameQuery, +) -> Sequence[ClassroomEvent]: + return await ClassroomEvent.find_all_by_classroom_id_in_time_frame( + classroom_id=classroom_id, + happens_after=time_frame.happens_after, + happens_before=time_frame.happens_before, + ) + + +class ClassroomEventInputSchema(ClassroomEvent.InputSchema): + @model_validator(mode="after") + def validate_event_start_and_end_time(self) -> Self: + if self.starts_at >= self.ends_at: + raise ValueError( + "the start time of an event cannot be greater than or equal to the end time" + ) + return self + + +@router.post( + path="/roles/tutor/classrooms/{classroom_id}/events/", + status_code=status.HTTP_201_CREATED, + response_model=ClassroomEvent.ResponseSchema, + summary="Create a new event in a classroom by id", +) +async def create_classroom_event( + classroom_id: Annotated[int, Path()], + input_data: ClassroomEventInputSchema, +) -> ClassroomEvent: + return await ClassroomEvent.create( + **input_data.model_dump(), classroom_id=classroom_id + ) + + +@router.get( + path="/roles/tutor/classrooms/{classroom_id}/events/{event_id}/", + response_model=ClassroomEvent.ResponseSchema, + summary="Retrieve a classroom event by ids", +) +async def retrieve_classroom_event( + classroom_event: MyClassroomEventByIDs, +) -> ClassroomEvent: + return classroom_event + + +@router.put( + path="/roles/tutor/classrooms/{classroom_id}/events/{event_id}/", + response_model=ClassroomEvent.ResponseSchema, + summary="Update a classroom event by ids", +) +async def put_classroom_event( + classroom_event: MyClassroomEventByIDs, + put_data: ClassroomEventInputSchema, +) -> ClassroomEvent: + classroom_event.update(**put_data.model_dump()) + return classroom_event + + +@router.delete( + path="/roles/tutor/classrooms/{classroom_id}/events/{event_id}/", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a classroom event by ids", +) +async def delete_classroom_event(classroom_event: MyClassroomEventByIDs) -> None: + await classroom_event.delete() diff --git a/app/scheduler/routes/events_mub.py b/app/scheduler/routes/events_mub.py deleted file mode 100644 index 7f846aa6..00000000 --- a/app/scheduler/routes/events_mub.py +++ /dev/null @@ -1,81 +0,0 @@ -from collections.abc import Sequence -from typing import Self - -from fastapi import HTTPException -from pydantic import AwareDatetime, model_validator -from starlette import status - -from app.common.fastapi_ext import APIRouterExt -from app.scheduler.dependencies.events_dep import EventById -from app.scheduler.models.events_db import Event - -router = APIRouterExt(tags=["scheduler-events mub"]) - - -class EventInputSchema(Event.InputSchema): - @model_validator(mode="after") - def validate_event_start_and_end_time(self) -> Self: - if self.starts_at >= self.ends_at: - raise ValueError( - "the start time of an event cannot be greater than or equal to the end time" - ) - return self - - -@router.get( - path="/events/", - response_model=list[Event.ResponseSchema], - summary="List all events", -) -async def list_events( - happens_after: AwareDatetime, happens_before: AwareDatetime -) -> Sequence[Event]: - if happens_after >= happens_before: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Parameter happens_before must be later in time than happens_after", - ) - return await Event.find_all_events_in_time_frame( - happens_after=happens_after, happens_before=happens_before - ) - - -@router.post( - path="/events/", - status_code=status.HTTP_201_CREATED, - response_model=Event.ResponseSchema, - summary="Create a new event", -) -async def create_event(data: EventInputSchema) -> Event: - return await Event.create(**data.model_dump()) - - -@router.get( - path="/events/{event_id}/", - response_model=Event.ResponseSchema, - summary="Retrieve any event by id", -) -async def retrieve_event(event: EventById) -> Event: - return event - - -@router.put( - path="/events/{event_id}/", - response_model=Event.ResponseSchema, - summary="Update any event by id", -) -async def put_event( - event: EventById, - data: EventInputSchema, -) -> Event: - event.update(**data.model_dump()) - return event - - -@router.delete( - path="/events/{event_id}/", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete any event by id", -) -async def delete_event(event: EventById) -> None: - await event.delete() diff --git a/app/supbot/texts.py b/app/supbot/texts.py index faf17e89..12db352a 100644 --- a/app/supbot/texts.py +++ b/app/supbot/texts.py @@ -1,5 +1,7 @@ from aiogram.types import BotCommand, KeyboardButton +from app.common.config import settings + COMMAND_DESCRIPTIONS = { "/support": "Обращение в поддержку", "/vacancy": "Посмотреть вакансии", @@ -53,9 +55,8 @@ SKIP_BUTTON_TEXT = "Пропустить" # Vacancy Form Start -VACANCIES_WEBSITE_URL = "https://vacancy.xieffect.ru/vacancy" STARTING_VACANCY_FORM_MESSAGE = f""" -Наши вакансии размещены на сайте: {VACANCIES_WEBSITE_URL} +Наши вакансии размещены на сайте: {settings.frontend_vacancies_base_url} Вы можете отправить отклик там же или через бота """ CHOOSE_VACANCY_MESSAGE = "Выберите вакансию или введите свою:" diff --git a/tests/materials/functional/test_classroom_materials_list_rst.py b/tests/materials/functional/test_classroom_materials_list_rst.py index da0f307b..dd62c694 100644 --- a/tests/materials/functional/test_classroom_materials_list_rst.py +++ b/tests/materials/functional/test_classroom_materials_list_rst.py @@ -85,7 +85,7 @@ async def classroom_materials( ], ) async def test_tutor_classroom_materials_listing( - tutor_client: TestClient, + authorized_client: TestClient, classroom_id: int, classroom_materials: Sequence[ClassroomMaterial], role: Literal["student", "tutor"], @@ -105,7 +105,7 @@ async def test_tutor_classroom_materials_listing( cursor = None if offset is None else filtered_classroom_materials[offset] assert_response( - tutor_client.post( + authorized_client.post( f"/api/protected/material-service/roles/{role}" f"/classrooms/{classroom_id}/materials/searches/", json={ @@ -139,7 +139,7 @@ async def test_tutor_classroom_materials_listing( ], ) async def test_tutor_classroom_materials_listing_any_kind( - tutor_client: TestClient, + authorized_client: TestClient, classroom_id: int, classroom_materials: Sequence[ClassroomMaterial], role: Literal["student", "tutor"], @@ -158,7 +158,7 @@ async def test_tutor_classroom_materials_listing_any_kind( cursor = None if offset is None else filtered_classroom_materials[offset] assert_response( - tutor_client.post( + authorized_client.post( f"/api/protected/material-service/roles/{role}" f"/classrooms/{classroom_id}/materials/searches/", json={ diff --git a/tests/notifications/factories.py b/tests/notifications/factories.py index f11d2421..e0624cf6 100644 --- a/tests/notifications/factories.py +++ b/tests/notifications/factories.py @@ -32,6 +32,12 @@ class RecipientInvoiceNotificationPayloadFactory( __model__ = notifications_sch.RecipientInvoiceNotificationPayloadSchema +class CustomNotificationPayloadFactory( + BaseModelFactory[notifications_sch.CustomNotificationPayloadSchema] +): + __model__ = notifications_sch.CustomNotificationPayloadSchema + + class NotificationSimpleInputSchema(BaseModel): payload: notifications_sch.AnyNotificationPayloadSchema diff --git a/tests/notifications/service/adapters/test_email_message_adapter.py b/tests/notifications/service/adapters/test_email_message_adapter.py index fcdc748e..21705493 100644 --- a/tests/notifications/service/adapters/test_email_message_adapter.py +++ b/tests/notifications/service/adapters/test_email_message_adapter.py @@ -154,3 +154,31 @@ async def test_student_recipient_invoice_payment_confirmed_v1_notification_adapt notification_id=notification_mock.id, ).model_dump(), ) + + +async def test_custom_v1_notification_adapting( + notification_mock: Mock, +) -> None: + notification_payload: notifications_sch.CustomNotificationPayloadSchema = ( + factories.CustomNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.CUSTOM_V1 + ) + ) + notification_mock.payload = notification_payload + + email_notification_adapter = NotificationToEmailMessageAdapter( + notification=notification_mock + ) + + assert_contains( + email_notification_adapter.adapt(), + pochta_sch.CustomEmailMessagePayloadSchema( + kind=pochta_sch.EmailMessageKind.CUSTOM_V1, + theme=notification_payload.theme, + pre_header=notification_payload.pre_header, + header=notification_payload.header, + content=notification_payload.content, + button_text=notification_payload.button_text, + button_link=notification_payload.button_link, + ).model_dump(), + ) diff --git a/tests/notifications/service/adapters/test_telegram_message_adapter.py b/tests/notifications/service/adapters/test_telegram_message_adapter.py index bc2e4f21..6843648c 100644 --- a/tests/notifications/service/adapters/test_telegram_message_adapter.py +++ b/tests/notifications/service/adapters/test_telegram_message_adapter.py @@ -218,3 +218,27 @@ async def test_student_recipient_invoice_payment_confirmed_v1_notification_adapt "recipient_invoice_id": [str(notification_payload.recipient_invoice_id)], }, ) + + +async def test_custom_v1_notification_adapting( + notification_mock: Mock, +) -> None: + notification_payload: notifications_sch.CustomNotificationPayloadSchema = ( + factories.CustomNotificationPayloadFactory.build( + kind=notifications_sch.NotificationKind.CUSTOM_V1 + ) + ) + notification_mock.payload = notification_payload + + telegram_notification_adapter = NotificationToTelegramMessageAdapter( + notification=notification_mock + ) + + assert_contains( + telegram_notification_adapter.adapt(), + { + "message_text": f"{notification_payload.header}\n\n{notification_payload.content}", + "button_text": notification_payload.button_text, + "button_link": notification_payload.button_link, + }, + ) diff --git a/tests/pochta/factories.py b/tests/pochta/factories.py index 118a4df1..22423d55 100644 --- a/tests/pochta/factories.py +++ b/tests/pochta/factories.py @@ -3,6 +3,7 @@ from app.common.schemas.pochta_sch import ( ClassroomNotificationEmailMessagePayloadSchema, + CustomEmailMessagePayloadSchema, EmailMessageInputSchema, RecipientInvoiceNotificationEmailMessagePayloadSchema, TokenEmailMessagePayloadSchema, @@ -25,6 +26,12 @@ class EmailFormDataFactory(BaseModelFactory[EmailFormDataSchema]): subject = Use(BaseModelFactory.__faker__.sentence) +class CustomEmailMessagePayloadFactory( + BaseModelFactory[CustomEmailMessagePayloadSchema] +): + __model__ = CustomEmailMessagePayloadSchema + + class TokenEmailMessagePayloadFactory(BaseModelFactory[TokenEmailMessagePayloadSchema]): __model__ = TokenEmailMessagePayloadSchema diff --git a/tests/pochta/functional/test_email_messages_sub.py b/tests/pochta/functional/test_email_messages_sub.py index 912cc75e..63099c19 100644 --- a/tests/pochta/functional/test_email_messages_sub.py +++ b/tests/pochta/functional/test_email_messages_sub.py @@ -29,6 +29,11 @@ @pytest.mark.parametrize( ("kind", "payload_factory"), [ + pytest.param( + EmailMessageKind.CUSTOM_V1, + factories.CustomEmailMessagePayloadFactory, + id="custom_v1", + ), pytest.param( EmailMessageKind.EMAIL_CONFIRMATION_V2, factories.TokenEmailMessagePayloadFactory, diff --git a/tests/scheduler/conftest.py b/tests/scheduler/conftest.py index c6b7665b..ae8a49ac 100644 --- a/tests/scheduler/conftest.py +++ b/tests/scheduler/conftest.py @@ -1,28 +1,68 @@ import pytest +from faker import Faker +from starlette.testclient import TestClient -from app.scheduler.models.events_db import Event +from app.common.dependencies.authorization_dep import ProxyAuthData +from app.scheduler.models.events_db import ClassroomEvent from tests.common.active_session import ActiveSession from tests.common.types import AnyJSON +from tests.factories import ProxyAuthDataFactory from tests.scheduler import factories @pytest.fixture() -async def event( - active_session: ActiveSession, -) -> Event: +def tutor_auth_data() -> ProxyAuthData: + return ProxyAuthDataFactory.build() + + +@pytest.fixture() +def tutor_client(client: TestClient, tutor_auth_data: ProxyAuthData) -> TestClient: + return TestClient(client.app, headers=tutor_auth_data.as_headers) + + +@pytest.fixture() +def student_auth_data() -> ProxyAuthData: + return ProxyAuthDataFactory.build() + + +@pytest.fixture() +def student_client(client: TestClient, student_auth_data: ProxyAuthData) -> TestClient: + return TestClient(client.app, headers=student_auth_data.as_headers) + + +@pytest.fixture() +def classroom_id(faker: Faker) -> int: + return faker.random_int(1, 1000) + + +@pytest.fixture() +def other_classroom_id(faker: Faker, classroom_id: int) -> int: + return faker.random_int(classroom_id + 1, classroom_id + 1000) + + +@pytest.fixture() +async def classroom_event( + active_session: ActiveSession, classroom_id: int +) -> ClassroomEvent: async with active_session(): - return await Event.create(**factories.EventInputFactory.build_python()) + return await ClassroomEvent.create( + **factories.ClassroomEventInputFactory.build_python(), + classroom_id=classroom_id, + ) @pytest.fixture() -async def event_data( - event: Event, -) -> AnyJSON: - return Event.ResponseSchema.model_validate(event).model_dump(mode="json") +def classroom_event_data(classroom_event: ClassroomEvent) -> AnyJSON: + return ClassroomEvent.ResponseSchema.model_validate( + classroom_event, from_attributes=True + ).model_dump(mode="json") @pytest.fixture() -async def deleted_event_id(active_session: ActiveSession, event: Event) -> int: +async def deleted_classroom_event_id( + active_session: ActiveSession, + classroom_event: ClassroomEvent, +) -> int: async with active_session(): - await event.delete() - return event.id + await classroom_event.delete() + return classroom_event.id diff --git a/tests/scheduler/factories.py b/tests/scheduler/factories.py index d66510c7..91c7d145 100644 --- a/tests/scheduler/factories.py +++ b/tests/scheduler/factories.py @@ -2,14 +2,27 @@ from polyfactory import PostGenerated -from app.scheduler.models.events_db import Event +from app.scheduler.models.events_db import ClassroomEvent from tests.common.polyfactory_ext import BaseModelFactory -class EventInputFactory(BaseModelFactory[Event.InputSchema]): - __model__ = Event.InputSchema +class ClassroomEventInputFactory(BaseModelFactory[ClassroomEvent.InputSchema]): + __model__ = ClassroomEvent.InputSchema + ends_at = PostGenerated( lambda _, values: BaseModelFactory.__faker__.date_time_between( start_date=values["starts_at"], end_date="+120m", tzinfo=timezone.utc ) ) + + +class ClassroomEventInvalidTimeFrameInputFactory( + BaseModelFactory[ClassroomEvent.InputSchema] +): + __model__ = ClassroomEvent.InputSchema + + ends_at = PostGenerated( + lambda _, values: BaseModelFactory.__faker__.date_time( + end_datetime=values["starts_at"], tzinfo=timezone.utc + ) + ) diff --git a/tests/scheduler/functional/test_classroom_events_list_rst.py b/tests/scheduler/functional/test_classroom_events_list_rst.py new file mode 100644 index 00000000..963070c1 --- /dev/null +++ b/tests/scheduler/functional/test_classroom_events_list_rst.py @@ -0,0 +1,173 @@ +from collections.abc import AsyncIterator +from datetime import datetime, timedelta, timezone +from typing import Literal, assert_never + +import pytest +from faker import Faker +from starlette import status +from starlette.testclient import TestClient + +from app.scheduler.models.events_db import ClassroomEvent +from tests.common.active_session import ActiveSession +from tests.common.assert_contains_ext import assert_response +from tests.scheduler.factories import ClassroomEventInputFactory + +pytestmark = pytest.mark.anyio + +CLASSROOM_EVENT_LIST_SIZE = 6 + + +@pytest.fixture() +async def classroom_events( + faker: Faker, + active_session: ActiveSession, + classroom_id: int, +) -> AsyncIterator[list[ClassroomEvent]]: + classroom_events: list[ClassroomEvent] = [] + start_datetime: datetime = faker.date_time_between(tzinfo=timezone.utc) + + async with active_session(): + for _ in range(CLASSROOM_EVENT_LIST_SIZE): + end_datetime: datetime = ( + start_datetime + + timedelta(minutes=10) + + faker.time_delta(end_datetime="+120m") + ) + classroom_events.append( + await ClassroomEvent.create( + **ClassroomEventInputFactory.build_python( + starts_at=start_datetime, + ends_at=end_datetime, + ), + classroom_id=classroom_id, + ) + ) + start_datetime = end_datetime + faker.time_delta(end_datetime="+360m") + + classroom_events.sort( + key=lambda classroom_event: classroom_event.starts_at, reverse=True + ) + + yield classroom_events + + async with active_session(): + for classroom_event in classroom_events: + await classroom_event.delete() + + +classroom_events_list_request_parametrization = pytest.mark.parametrize( + ("index_happens_before", "index_happens_after"), + [ + pytest.param(None, None, id="start_to_end"), + pytest.param(None, CLASSROOM_EVENT_LIST_SIZE // 2, id="start_to_middle"), + pytest.param(CLASSROOM_EVENT_LIST_SIZE // 2, None, id="middle_to_end"), + pytest.param(None, 0, id="before_the_start"), + pytest.param(-1, None, id="after_the_end"), + ], +) + + +classroom_events_role_parametrization = pytest.mark.parametrize( + "role", + [ + pytest.param("student", id="student"), + pytest.param("tutor", id="tutor"), + ], +) + + +@classroom_events_list_request_parametrization +@classroom_events_role_parametrization +async def test_tutor_classroom_events_listing( + faker: Faker, + authorized_client: TestClient, + classroom_id: int, + classroom_events: list[ClassroomEvent], + index_happens_before: int | None, + index_happens_after: int | None, + role: Literal["tutor", "student"], +) -> None: + happens_after: datetime = ( + faker.date_time_between( + end_date=classroom_events[0].ends_at, tzinfo=timezone.utc + ) + if index_happens_after is None + else classroom_events[index_happens_after].ends_at + ) + happens_before: datetime = ( + faker.date_time_between( + start_date=classroom_events[-1].starts_at, tzinfo=timezone.utc + ) + if index_happens_before is None + else classroom_events[index_happens_before].starts_at + ) + + assert_response( + authorized_client.get( + f"/api/protected/scheduler-service/roles/{role}/classrooms/{classroom_id}/events/", + params={ + "happens_after": happens_after.isoformat(), + "happens_before": happens_before.isoformat(), + }, + ), + expected_json=[ + ClassroomEvent.ResponseSchema.model_validate( + classroom_event, from_attributes=True + ) + for classroom_event in classroom_events + if classroom_event.starts_at < happens_before + and classroom_event.ends_at > happens_after + ], + ) + + +@pytest.mark.parametrize( + "happens_before_mode", + [ + pytest.param("equal_to_happens_after", id="before_is_equal_to_after"), + pytest.param("less_than_happens_after", id="before_is_less_than_after"), + ], +) +@classroom_events_role_parametrization +async def test_classroom_events_listing_happens_before_le_happens_after( + faker: Faker, + authorized_client: TestClient, + classroom_id: int, + classroom_events: list[ClassroomEvent], + role: Literal["tutor", "student"], + happens_before_mode: Literal["equal_to_happens_after", "less_than_happens_after"], +) -> None: + happens_after: datetime = faker.date_time_between( + tzinfo=timezone.utc, + ) + happens_before: datetime + match happens_before_mode: + case "equal_to_happens_after": + happens_before = happens_after + case "less_than_happens_after": + happens_before = faker.date_time( + end_datetime=happens_after, tzinfo=timezone.utc + ) + case _: + assert_never(happens_before_mode) + + assert_response( + authorized_client.get( + f"/api/protected/scheduler-service/roles/{role}" + f"/classrooms/{classroom_id}/events/", + params={ + "happens_after": happens_after.isoformat(), + "happens_before": happens_before.isoformat(), + }, + ), + expected_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + expected_json={ + "detail": [ + { + "type": "value_error", + "loc": ["query"], + "msg": "Value error, parameter happens_before must be later in time than happens_after", + }, + ] + }, + ) diff --git a/tests/scheduler/functional/test_classroom_events_student_rst.py b/tests/scheduler/functional/test_classroom_events_student_rst.py new file mode 100644 index 00000000..3967d9b5 --- /dev/null +++ b/tests/scheduler/functional/test_classroom_events_student_rst.py @@ -0,0 +1,53 @@ +import pytest +from starlette import status +from starlette.testclient import TestClient + +from app.scheduler.models.events_db import ClassroomEvent +from tests.common.assert_contains_ext import assert_response +from tests.common.types import AnyJSON + +pytestmark = pytest.mark.anyio + + +async def test_student_classroom_events_retrieving( + student_client: TestClient, + classroom_event: ClassroomEvent, + classroom_event_data: AnyJSON, +) -> None: + assert_response( + student_client.get( + "/api/protected/scheduler-service/roles/student" + f"/classrooms/{classroom_event.classroom_id}/events/{classroom_event.id}/", + ), + expected_json=classroom_event_data, + ) + + +async def test_student_classroom_event_requesting_access_denied( + student_client: TestClient, + other_classroom_id: int, + classroom_event: ClassroomEvent, +) -> None: + assert_response( + student_client.get( + "/api/protected/scheduler-service/roles/student" + f"/classrooms/{other_classroom_id}/events/{classroom_event.id}/", + ), + expected_code=status.HTTP_403_FORBIDDEN, + expected_json={"detail": "Classroom event access denied"}, + ) + + +async def test_student_classroom_event_requesting_not_finding( + student_client: TestClient, + classroom_id: int, + deleted_classroom_event_id: int, +) -> None: + assert_response( + student_client.get( + "/api/protected/scheduler-service/roles/student" + f"/classrooms/{classroom_id}/events/{deleted_classroom_event_id}/", + ), + expected_code=status.HTTP_404_NOT_FOUND, + expected_json={"detail": "Classroom event not found"}, + ) diff --git a/tests/scheduler/functional/test_classroom_events_tutor_rst.py b/tests/scheduler/functional/test_classroom_events_tutor_rst.py new file mode 100644 index 00000000..42036b2b --- /dev/null +++ b/tests/scheduler/functional/test_classroom_events_tutor_rst.py @@ -0,0 +1,186 @@ +from typing import Any + +import pytest +from starlette import status +from starlette.testclient import TestClient + +from app.scheduler.models.events_db import ClassroomEvent, EventKind +from tests.common.active_session import ActiveSession +from tests.common.assert_contains_ext import assert_nodata_response, assert_response +from tests.common.polyfactory_ext import BaseModelFactory +from tests.common.types import AnyJSON +from tests.scheduler.factories import ( + ClassroomEventInputFactory, + ClassroomEventInvalidTimeFrameInputFactory, +) + +pytestmark = pytest.mark.anyio + + +async def test_tutor_classroom_event_creation( + active_session: ActiveSession, + tutor_client: TestClient, + classroom_id: int, +) -> None: + classroom_event_input_data = ClassroomEventInputFactory.build_json() + + classroom_event_id: int = assert_response( + tutor_client.post( + f"/api/protected/scheduler-service/roles/tutor/classrooms/{classroom_id}/events/", + json=classroom_event_input_data, + ), + expected_code=status.HTTP_201_CREATED, + expected_json={ + **classroom_event_input_data, + "id": int, + "classroom_id": classroom_id, + "kind": EventKind.CLASSROOM, + }, + ).json()["id"] + + async with active_session(): + classroom_event = await ClassroomEvent.find_first_by_id(classroom_event_id) + assert classroom_event is not None + await classroom_event.delete() + + +async def test_tutor_classroom_event_creation_invalid_time_frame( + tutor_client: TestClient, + classroom_id: int, +) -> None: + assert_response( + tutor_client.post( + f"/api/protected/scheduler-service/roles/tutor/classrooms/{classroom_id}/events/", + json=ClassroomEventInvalidTimeFrameInputFactory.build_json(), + ), + expected_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + expected_json={ + "detail": [ + { + "type": "value_error", + "loc": ["body"], + "msg": "Value error, the start time of an event cannot be greater than or equal to the end time", + } + ] + }, + ) + + +async def test_tutor_classroom_event_retrieving( + tutor_client: TestClient, + classroom_event: ClassroomEvent, + classroom_event_data: AnyJSON, +) -> None: + assert_response( + tutor_client.get( + "/api/protected/scheduler-service/roles/tutor" + f"/classrooms/{classroom_event.classroom_id}/events/{classroom_event.id}/", + ), + expected_json=classroom_event_data, + ) + + +async def test_tutor_classroom_event_updating( + tutor_client: TestClient, + classroom_event: ClassroomEvent, + classroom_event_data: AnyJSON, +) -> None: + classroom_event_put_data = ClassroomEventInputFactory.build_json() + + assert_response( + tutor_client.put( + "/api/protected/scheduler-service/roles/tutor" + f"/classrooms/{classroom_event.classroom_id}/events/{classroom_event.id}/", + json=classroom_event_put_data, + ), + expected_json={**classroom_event_data, **classroom_event_put_data}, + ) + + +async def test_tutor_classroom_event_updating_invalid_time_frame( + tutor_client: TestClient, + classroom_event: ClassroomEvent, +) -> None: + assert_response( + tutor_client.put( + "/api/protected/scheduler-service/roles/tutor" + f"/classrooms/{classroom_event.classroom_id}/events/{classroom_event.id}/", + json=ClassroomEventInvalidTimeFrameInputFactory.build_json(), + ), + expected_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + expected_json={ + "detail": [ + { + "type": "value_error", + "loc": ["body"], + "msg": "Value error, the start time of an event cannot be greater than or equal to the end time", + } + ] + }, + ) + + +async def test_tutor_classroom_event_deleting( + active_session: ActiveSession, + tutor_client: TestClient, + classroom_event: ClassroomEvent, +) -> None: + assert_nodata_response( + tutor_client.delete( + "/api/protected/scheduler-service/roles/tutor" + f"/classrooms/{classroom_event.classroom_id}/events/{classroom_event.id}/", + ) + ) + + async with active_session(): + assert await ClassroomEvent.find_first_by_id(classroom_event.id) is None + + +tutor_classroom_events_request_parametrization = pytest.mark.parametrize( + ("method", "body_factory"), + [ + pytest.param("GET", None, id="retrieve"), + pytest.param("PUT", ClassroomEventInputFactory, id="update"), + pytest.param("DELETE", None, id="delete"), + ], +) + + +@tutor_classroom_events_request_parametrization +async def test_tutor_classroom_event_requesting_access_denied( + tutor_client: TestClient, + other_classroom_id: int, + classroom_event: ClassroomEvent, + method: str, + body_factory: type[BaseModelFactory[Any]] | None, +) -> None: + assert_response( + tutor_client.request( + method=method, + url="/api/protected/scheduler-service/roles/tutor" + f"/classrooms/{other_classroom_id}/events/{classroom_event.id}/", + json=body_factory and body_factory.build_json(), + ), + expected_code=status.HTTP_403_FORBIDDEN, + expected_json={"detail": "Classroom event access denied"}, + ) + + +@tutor_classroom_events_request_parametrization +async def test_tutor_classroom_event_requesting_not_finding( + tutor_client: TestClient, + classroom_id: int, + deleted_classroom_event_id: int, + method: str, + body_factory: type[BaseModelFactory[Any]] | None, +) -> None: + assert_response( + tutor_client.request( + method=method, + url="/api/protected/scheduler-service/roles/tutor" + f"/classrooms/{classroom_id}/events/{deleted_classroom_event_id}/", + json=body_factory and body_factory.build_json(), + ), + expected_code=status.HTTP_404_NOT_FOUND, + expected_json={"detail": "Classroom event not found"}, + ) diff --git a/tests/scheduler/functional/test_events_list_mub.py b/tests/scheduler/functional/test_events_list_mub.py deleted file mode 100644 index 2cf27c25..00000000 --- a/tests/scheduler/functional/test_events_list_mub.py +++ /dev/null @@ -1,128 +0,0 @@ -from collections.abc import AsyncIterator, Sequence -from datetime import datetime, timedelta, timezone - -import pytest -from faker import Faker -from starlette import status -from starlette.testclient import TestClient - -from app.scheduler.models.events_db import Event -from tests.common.active_session import ActiveSession -from tests.common.assert_contains_ext import assert_response -from tests.scheduler.factories import EventInputFactory - -pytestmark = pytest.mark.anyio - -EVENT_LIST_SIZE = 6 - - -@pytest.fixture() -async def events( - active_session: ActiveSession, faker: Faker -) -> AsyncIterator[Sequence[Event]]: - events: list[Event] = [] - start_datetime: datetime = faker.date_time_between(tzinfo=timezone.utc) - async with active_session(): - for _ in range(EVENT_LIST_SIZE): - end_datetime: datetime = ( - start_datetime - + timedelta(minutes=10) - + faker.time_delta(end_datetime="+120m") - ) - events.append( - await Event.create( - **EventInputFactory.build_python( - starts_at=start_datetime, ends_at=end_datetime - ) - ) - ) - start_datetime = end_datetime + faker.time_delta(end_datetime="+360m") - - events.sort(key=lambda event: event.starts_at, reverse=True) - - yield events - - async with active_session(): - for event in events: - await event.delete() - - -@pytest.mark.parametrize( - ("index_happens_before", "index_happens_after"), - [ - pytest.param(None, None, id="start_to_end"), - pytest.param(None, EVENT_LIST_SIZE // 2, id="start_to_middle"), - pytest.param(EVENT_LIST_SIZE // 2, None, id="middle_to_end"), - pytest.param(None, 0, id="before_the_start"), - pytest.param(-1, None, id="after_the_end"), - ], -) -async def test_events_listing( - faker: Faker, - mub_client: TestClient, - events: list[Event], - index_happens_before: int | None, - index_happens_after: int | None, -) -> None: - happens_after: datetime = ( - faker.date_time_between(end_date=events[0].ends_at, tzinfo=timezone.utc) - if index_happens_after is None - else events[index_happens_after].ends_at - ) - - happens_before: datetime = ( - faker.date_time_between(start_date=events[-1].starts_at, tzinfo=timezone.utc) - if index_happens_before is None - else events[index_happens_before].starts_at - ) - - assert_response( - mub_client.get( - "/mub/scheduler-service/events/", - params={ - "happens_after": happens_after.isoformat(), - "happens_before": happens_before.isoformat(), - }, - ), - expected_json=[ - Event.ResponseSchema.model_validate(event) - for event in events - if event.starts_at < happens_before and event.ends_at > happens_after - ], - ) - - -@pytest.mark.parametrize( - "is_equal", - [ - pytest.param(True, id="after_equal_before"), - pytest.param(False, id="after_greater_than_before"), - ], -) -async def test_events_listing_happens_after_ge_happens_before( - faker: Faker, mub_client: TestClient, events: list[Event], is_equal: bool -) -> None: - happens_after: datetime = faker.date_time_between( - start_date="-25y", # start_date is needed for happens_before to have room for generation - tzinfo=timezone.utc, - ) - - happens_before: datetime = ( - happens_after - if is_equal - else faker.date_time_between(end_date=happens_after, tzinfo=timezone.utc) - ) - - assert_response( - mub_client.get( - "/mub/scheduler-service/events/", - params={ - "happens_after": happens_after.isoformat(), - "happens_before": happens_before.isoformat(), - }, - ), - expected_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - expected_json={ - "detail": "Parameter happens_before must be later in time than happens_after" - }, - ) diff --git a/tests/scheduler/functional/test_events_mub.py b/tests/scheduler/functional/test_events_mub.py deleted file mode 100644 index 1feb5489..00000000 --- a/tests/scheduler/functional/test_events_mub.py +++ /dev/null @@ -1,129 +0,0 @@ -from datetime import datetime, timezone -from typing import Any - -import pytest -from faker import Faker -from pydantic_marshals.contains import assert_contains -from starlette import status -from starlette.testclient import TestClient - -from app.scheduler.models.events_db import Event -from tests.common.active_session import ActiveSession -from tests.common.assert_contains_ext import assert_nodata_response, assert_response -from tests.common.polyfactory_ext import BaseModelFactory -from tests.common.types import AnyJSON -from tests.scheduler.factories import EventInputFactory - -pytestmark = pytest.mark.anyio - - -async def test_event_creation( - active_session: ActiveSession, - mub_client: TestClient, -) -> None: - event_input_data = EventInputFactory.build_json() - - event_id = assert_response( - mub_client.post("/mub/scheduler-service/events/", json=event_input_data), - expected_code=status.HTTP_201_CREATED, - expected_json={ - **event_input_data, - "id": int, - }, - ).json()["id"] - - async with active_session(): - event = await Event.find_first_by_id(event_id) - assert event is not None - await event.delete() - - -async def test_event_creation_end_time_le_start_time( - faker: Faker, - mub_client: TestClient, -) -> None: - start_datetime: datetime = faker.date_time_between(tzinfo=timezone.utc) - end_datetime: datetime = faker.date_time_between( - end_date=start_datetime, tzinfo=timezone.utc - ) - invalid_event_input_data = EventInputFactory.build_json( - starts_at=start_datetime, ends_at=end_datetime - ) - assert_contains( - mub_client.post( - "/mub/scheduler-service/events/", json=invalid_event_input_data - ).json(), - { - "detail": [ - { - "type": "value_error", - "loc": ["body"], - "msg": "Value error, the start time of an event cannot be greater than or equal to the end time", - } - ] - }, - ) - - -async def test_event_retrieving( - mub_client: TestClient, - event: Event, - event_data: AnyJSON, -) -> None: - assert_response( - mub_client.get(f"/mub/scheduler-service/events/{event.id}/"), - expected_json=event_data, - ) - - -async def test_event_updating( - mub_client: TestClient, - event: Event, -) -> None: - event_input_data = EventInputFactory.build_json() - assert_response( - mub_client.put( - f"/mub/scheduler-service/events/{event.id}/", - json=event_input_data, - ), - expected_json={**event_input_data, "id": event.id}, - ) - - -async def test_event_deleting( - active_session: ActiveSession, - mub_client: TestClient, - event: Event, -) -> None: - assert_nodata_response( - mub_client.delete(f"/mub/scheduler-service/events/{event.id}/") - ) - - async with active_session(): - assert await Event.find_first_by_id(event.id) is None - - -@pytest.mark.parametrize( - ("method", "body_factory"), - [ - pytest.param("GET", None, id="retrieve"), - pytest.param("PUT", EventInputFactory, id="update"), - pytest.param("DELETE", None, id="delete"), - ], -) -async def test_event_not_finding( - active_session: ActiveSession, - mub_client: TestClient, - deleted_event_id: Event, - method: str, - body_factory: type[BaseModelFactory[Any]] | None, -) -> None: - assert_response( - mub_client.request( - method=method, - url=f"/mub/scheduler-service/events/{deleted_event_id}/", - json=body_factory and body_factory.build_json(), - ), - expected_code=status.HTTP_404_NOT_FOUND, - expected_json={"detail": "Event not found"}, - )