diff --git a/.flake8 b/.flake8 index 6ab4761e..2f7531d6 100644 --- a/.flake8 +++ b/.flake8 @@ -26,14 +26,14 @@ extend-ignore = VNE003 WPS115 # # weird - PIE803 C101 FNE007 FNE008 N812 ANN101 ANN102 WPS110 WPS111 WPS114 WPS338 WPS407 WPS414 WPS440 VNE001 VNE002 CM001 + PIE803 C101 FNE007 FNE008 N812 ANN101 ANN102 PT004 WPS110 WPS111 WPS114 WPS338 WPS407 WPS414 WPS440 VNE001 VNE002 CM001 # too many WPS200 WPS201 WPS202 WPS203 WPS204 WPS210 WPS211 WPS212 WPS213 WPS214 WPS217 WPS218 WPS221 WPS224 WPS230 WPS231 WPS234 WPS235 WPS238 # "vague" imports WPS347 # # broken - PIE798 WPS226 WPS354 WPS432 WPS473 WPS507 FNE004 + PIE786 PIE798 WPS226 WPS354 WPS432 WPS473 WPS507 FNE004 # fails to understand `raise NotImplementedError` and overloading U100 # fails to understand enums 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/alembic/versions/056_promocodes.py b/alembic/versions/056_promocodes.py new file mode 100644 index 00000000..c42cbe3f --- /dev/null +++ b/alembic/versions/056_promocodes.py @@ -0,0 +1,54 @@ +"""promocodes + +Revision ID: 056 +Revises: 055 +Create Date: 2026-01-14 13:05:54.325494 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "056" +down_revision: Union[str, None] = "055" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "promocodes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=100), nullable=False), + sa.Column("code", sa.String(length=10), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_promocodes")), + schema="xi_back_2", + ) + op.create_index( + op.f("ix_xi_back_2_promocodes_code"), + "promocodes", + ["code"], + unique=True, + schema="xi_back_2", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_xi_back_2_promocodes_code"), + table_name="promocodes", + schema="xi_back_2", + ) + op.drop_table("promocodes", schema="xi_back_2") + # ### end Alembic commands ### diff --git a/app/common/aiogram_ext.py b/app/common/aiogram_ext.py index e94262ed..2edcf3e7 100644 --- a/app/common/aiogram_ext.py +++ b/app/common/aiogram_ext.py @@ -4,6 +4,8 @@ from typing import Any from aiogram import Bot, Dispatcher, Router +from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.client.telegram import TelegramAPIServer from aiogram.filters import CommandObject from aiogram.fsm.storage.redis import RedisStorage from aiogram.methods import GetUpdates @@ -101,7 +103,18 @@ async def maybe_initialize_from_config( return await self.initialize( - bot=Bot(bot_settings.token), + bot=Bot( + bot_settings.token, + session=( + None + if settings.telegram_server_base_url is None + else AiohttpSession( + api=TelegramAPIServer.from_base( + settings.telegram_server_base_url + ) + ) + ), + ), dispatcher=Dispatcher( storage=None if redis_dsn is None else RedisStorage.from_url(redis_dsn), **dispatcher_kwargs, diff --git a/app/common/config.py b/app/common/config.py index 983bdbe7..421f5b6e 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( @@ -191,6 +192,7 @@ def redis_supbot_dsn(self) -> str: supbot: SupbotSettings | None = None notifications_bot: TelegramBotSettings | None = None + telegram_server_base_url: str | None = None telegram_webhook_base_url: str | None = None sentry_dsn: str | None = None @@ -208,9 +210,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/filetype_ext.py b/app/common/filetype_ext.py new file mode 100644 index 00000000..0cc68297 --- /dev/null +++ b/app/common/filetype_ext.py @@ -0,0 +1,26 @@ +from typing import Final + +import filetype # type: ignore[import-untyped] +from filetype.types import image # type: ignore[import-untyped] + +FILE_HEADER_SIZE: Final[int] = 8192 + +SUPPORTED_IMAGE_FORMATS: list[filetype.Type] = [ + image.Avif(), + image.Bmp(), + image.Gif(), + image.Ico(), + image.Jpeg(), + image.Jpx(), + image.Png(), + image.Tiff(), + image.Webp(), +] + + +def match_filetype(obj: bytes, matchers: list[filetype.Type]) -> filetype.Type | None: + return filetype.match(obj, matchers) + + +def match_image_filetype(obj: bytes) -> filetype.Type | None: + return match_filetype(obj, SUPPORTED_IMAGE_FORMATS) diff --git a/app/common/livekit_ext.py b/app/common/livekit_ext.py index 3e1df5cc..c44d59ba 100644 --- a/app/common/livekit_ext.py +++ b/app/common/livekit_ext.py @@ -9,6 +9,8 @@ CreateRoomRequest, ListParticipantsRequest, ListRoomsRequest, + UpdateParticipantRequest, + UpdateRoomMetadataRequest, ) @@ -50,23 +52,57 @@ def api(self) -> LiveKitAPI: def room(self) -> RoomService: return self.api.room - def generate_access_token(self, identity: str, name: str, room_name: str) -> str: + def generate_access_token( + self, + room_name: str, + identity: str, + name: str, + metadata: str = "", + ) -> str: return ( AccessToken(self.api_key, self.api_secret) .with_identity(identity=identity) .with_name(name=name) .with_grants(VideoGrants(room_join=True, room=room_name)) + .with_metadata(metadata=metadata) ).to_jwt() async def list_rooms(self, room_names: list[str]) -> Iterator[Room]: response = await self.room.list_rooms(ListRoomsRequest(names=room_names)) return (room for room in response.rooms) - async def find_or_create_room(self, room_name: str) -> Room: - return await self.room.create_room(CreateRoomRequest(name=room_name)) + async def find_or_create_room(self, room_name: str, metadata: str) -> Room: + return await self.room.create_room( + CreateRoomRequest( + name=room_name, + metadata=metadata, + ) + ) + + async def update_room_metadata(self, room_name: str, metadata: str) -> Room: + return await self.room.update_room_metadata( + UpdateRoomMetadataRequest( + room=room_name, + metadata=metadata, + ) + ) async def list_room_participants(self, room_name: str) -> Iterator[ParticipantInfo]: response = await self.room.list_participants( ListParticipantsRequest(room=room_name) ) return (participant for participant in response.participants) + + async def update_participant_metadata( + self, + room_name: str, + identity: str, + metadata: str, + ) -> ParticipantInfo: + return await self.room.update_participant( + UpdateParticipantRequest( + room=room_name, + identity=identity, + metadata=metadata, + ) + ) 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/conferences/routes/classroom_conferences_rst.py b/app/conferences/routes/classroom_conferences_rst.py index 325af09a..173d03c2 100644 --- a/app/conferences/routes/classroom_conferences_rst.py +++ b/app/conferences/routes/classroom_conferences_rst.py @@ -5,7 +5,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 +from app.common.fastapi_ext import APIRouterExt, Responses from app.common.schemas.notifications_sch import ( ClassroomNotificationPayloadSchema, NotificationInputSchema, @@ -15,7 +15,11 @@ LivekitRoomByClassroomID, LivekitRoomNameByClassroomID, ) -from app.conferences.schemas.conferences_sch import ConferenceParticipantSchema +from app.conferences.schemas.conferences_sch import ( + ConferenceParticipantSchema, + ParticipantMetadataSchema, + RoomMetadataSchema, +) from app.conferences.services import conferences_svc router = APIRouterExt(tags=["classroom conferences"]) @@ -50,6 +54,21 @@ async def reactivate_classroom_conference( ) +@router.put( + path="/roles/tutor/classrooms/{classroom_id}/conference/metadata/", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update metadata of a conference in a classroom by id", +) +async def update_classroom_conference_metadata( + livekit_room: LivekitRoomByClassroomID, + metadata: RoomMetadataSchema, +) -> None: + await conferences_svc.update_room_metadata( + livekit_room=livekit_room, + metadata=metadata, + ) + + @router.post( path="/roles/tutor/classrooms/{classroom_id}/conference/access-tokens/", summary="Create a tutor access token for a conference in a classroom by id", @@ -82,3 +101,56 @@ async def list_classroom_conference_participants( return await conferences_svc.list_room_participants( livekit_room_name=livekit_room.name ) + + +class ConferenceParticipantResponses(Responses): + CONFERENCE_PARTICIPANT_NOT_FOUND = ( + status.HTTP_404_NOT_FOUND, + "Conference participant not found", + ) + + +@router.put( + path="/roles/tutor/classrooms/{classroom_id}/conference/participants/current/metadata/", + status_code=status.HTTP_204_NO_CONTENT, + responses=ConferenceParticipantResponses.responses(), + summary="Update metadata of the current participant of a conference in a classroom by id", +) +@router.put( # TODO split this if metadata will become different + path="/roles/student/classrooms/{classroom_id}/conference/participants/current/metadata/", + status_code=status.HTTP_204_NO_CONTENT, + responses=ConferenceParticipantResponses.responses(), + summary="Update metadata of the current participant of a conference in a classroom by id", +) +async def update_classroom_conference_current_participant_metadata( + auth_data: AuthorizationData, + livekit_room: LivekitRoomByClassroomID, + metadata: ParticipantMetadataSchema, +) -> None: + participant_info = await conferences_svc.update_participant_metadata( + livekit_room=livekit_room, + user_id=auth_data.user_id, + metadata=metadata, + ) + if participant_info is None: + raise ConferenceParticipantResponses.CONFERENCE_PARTICIPANT_NOT_FOUND + + +@router.put( + path="/roles/tutor/classrooms/{classroom_id}/conference/participants/{participant_user_id}/metadata/", + status_code=status.HTTP_204_NO_CONTENT, + responses=ConferenceParticipantResponses.responses(), + summary="Update metadata of a participant of a conference in a classroom by ids", +) +async def update_classroom_conference_other_participant_metadata( + livekit_room: LivekitRoomByClassroomID, + participant_user_id: int, + metadata: ParticipantMetadataSchema, +) -> None: + participant_info = await conferences_svc.update_participant_metadata( + livekit_room=livekit_room, + user_id=participant_user_id, + metadata=metadata, + ) + if participant_info is None: + raise ConferenceParticipantResponses.CONFERENCE_PARTICIPANT_NOT_FOUND diff --git a/app/conferences/schemas/conferences_sch.py b/app/conferences/schemas/conferences_sch.py index 500a7f67..5f997c5e 100644 --- a/app/conferences/schemas/conferences_sch.py +++ b/app/conferences/schemas/conferences_sch.py @@ -1,6 +1,19 @@ from pydantic import BaseModel +class BaseMetadataSchema(BaseModel): + def model_dump_metadata_json(self) -> str: + return self.model_dump_json(exclude_none=True) + + +class RoomMetadataSchema(BaseMetadataSchema): + active_material_id: int | None = None + + +class ParticipantMetadataSchema(BaseMetadataSchema): + is_hand_raised: bool = False + + class ConferenceParticipantSchema(BaseModel): user_id: int display_name: str diff --git a/app/conferences/services/conferences_svc.py b/app/conferences/services/conferences_svc.py index 9760b3d1..c18543b9 100644 --- a/app/conferences/services/conferences_svc.py +++ b/app/conferences/services/conferences_svc.py @@ -1,12 +1,21 @@ -from livekit.protocol.models import Room +from livekit.api import TwirpError +from livekit.protocol.models import ParticipantInfo, Room +from starlette import status from app.common.config import livekit from app.common.config_bdg import users_internal_bridge -from app.conferences.schemas.conferences_sch import ConferenceParticipantSchema +from app.conferences.schemas.conferences_sch import ( + ConferenceParticipantSchema, + ParticipantMetadataSchema, + RoomMetadataSchema, +) async def reactivate_room(livekit_room_name: str) -> Room: - return await livekit.find_or_create_room(room_name=livekit_room_name) + return await livekit.find_or_create_room( + room_name=livekit_room_name, + metadata=RoomMetadataSchema().model_dump_metadata_json(), + ) async def find_room_by_name(livekit_room_name: str) -> Room | None: @@ -16,13 +25,24 @@ async def find_room_by_name(livekit_room_name: str) -> Room | None: return None +async def update_room_metadata( + livekit_room: Room, + metadata: RoomMetadataSchema, +) -> Room: + return await livekit.update_room_metadata( + room_name=livekit_room.name, + metadata=metadata.model_dump_metadata_json(), + ) + + async def generate_access_token(livekit_room: Room, user_id: int) -> str: current_user_profile = await users_internal_bridge.retrieve_user(user_id=user_id) return livekit.generate_access_token( + room_name=livekit_room.name, identity=str(user_id), name=current_user_profile.display_name, - room_name=livekit_room.name, + metadata=ParticipantMetadataSchema().model_dump_metadata_json(), ) @@ -38,3 +58,20 @@ async def list_room_participants( room_name=livekit_room_name ) ] + + +async def update_participant_metadata( + livekit_room: Room, + user_id: int, + metadata: ParticipantMetadataSchema, +) -> ParticipantInfo | None: + try: + return await livekit.update_participant_metadata( + room_name=livekit_room.name, + identity=str(user_id), + metadata=metadata.model_dump_metadata_json(), + ) + except TwirpError as e: + if e.status == status.HTTP_404_NOT_FOUND: + return None + raise e # pragma: no cover # undocumented exceptions from livekit diff --git a/app/main.py b/app/main.py index 154b0214..3d694b72 100644 --- a/app/main.py +++ b/app/main.py @@ -29,6 +29,7 @@ posts, scheduler, storage_v2, + subscriptions, supbot, users, ) @@ -187,6 +188,7 @@ async def custom_swagger_ui_html() -> Response: app.include_router(supbot.api_router) app.include_router(classrooms.api_router) app.include_router(users.api_router) +app.include_router(subscriptions.api_router) old_openapi = app.openapi diff --git a/app/notifications/main.py b/app/notifications/main.py index 1517a3ed..61df9152 100644 --- a/app/notifications/main.py +++ b/app/notifications/main.py @@ -1,3 +1,4 @@ +import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import Any @@ -60,11 +61,14 @@ @asynccontextmanager async def lifespan(_: Any) -> AsyncIterator[None]: - await telegram_app.maybe_initialize_from_config( - bot_name="notifications bot", - bot_settings=settings.notifications_bot, - webhook_prefix=outside_router.prefix, - ) + try: + await telegram_app.maybe_initialize_from_config( + bot_name="notifications bot", + bot_settings=settings.notifications_bot, + webhook_prefix=outside_router.prefix, + ) + except Exception as e: # pragma: no cover # setup-level safety + logging.error("Notifications bot initialization failed", exc_info=e) yield 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/storage_v2/dependencies/storage_token_dep.py b/app/storage_v2/dependencies/storage_token_dep.py index 63858acf..c2fe8865 100644 --- a/app/storage_v2/dependencies/storage_token_dep.py +++ b/app/storage_v2/dependencies/storage_token_dep.py @@ -7,6 +7,7 @@ from app.common.dependencies.authorization_dep import AuthorizationData from app.common.fastapi_ext import Responses, with_responses from app.common.schemas.storage_sch import StorageTokenPayloadSchema +from app.storage_v2.models.access_groups_db import AccessGroup class StorageTokenResponses(Responses): @@ -36,3 +37,24 @@ def validate_and_deserialize_storage_token( StorageTokenPayload = Annotated[ StorageTokenPayloadSchema, Depends(validate_and_deserialize_storage_token) ] + + +@with_responses(StorageTokenResponses) +async def validate_upload_permissions( + storage_token_payload: StorageTokenPayload, +) -> StorageTokenPayloadSchema: + if not storage_token_payload.can_upload_files: + raise StorageTokenResponses.INVALID_STORAGE_TOKEN + + access_group = await AccessGroup.find_first_by_id( + storage_token_payload.access_group_id + ) + if access_group is None: + raise StorageTokenResponses.INVALID_STORAGE_TOKEN + + return storage_token_payload + + +UploadAllowedStorageTokenPayload = Annotated[ + StorageTokenPayloadSchema, Depends(validate_upload_permissions) +] diff --git a/app/storage_v2/dependencies/uploads_dep.py b/app/storage_v2/dependencies/uploads_dep.py index a342b525..a5c2c521 100644 --- a/app/storage_v2/dependencies/uploads_dep.py +++ b/app/storage_v2/dependencies/uploads_dep.py @@ -1,21 +1,32 @@ from typing import Annotated from fastapi import Depends, UploadFile -from filetype import filetype # type: ignore[import-untyped] -from filetype.types.image import Webp # type: ignore[import-untyped] from starlette import status from app.common.fastapi_ext import Responses, with_responses +from app.common.filetype_ext import FILE_HEADER_SIZE, match_image_filetype class FileFormatResponses(Responses): WRONG_FORMAT = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Invalid file format" + CONTENT_TYPE_MISMATCH = ( + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + "File content doesn't match the content-type header", + ) @with_responses(FileFormatResponses) -def validate_image_upload(upload: UploadFile) -> UploadFile: - if not filetype.match(upload.file, [Webp()]): +async def validate_image_upload(upload: UploadFile) -> UploadFile: + upload_header_data = await upload.read(FILE_HEADER_SIZE) + image_type = match_image_filetype(upload_header_data) + + if image_type is None: raise FileFormatResponses.WRONG_FORMAT + + if image_type.mime != upload.content_type: + raise FileFormatResponses.CONTENT_TYPE_MISMATCH + + await upload.seek(0) return upload diff --git a/app/storage_v2/models/files_db.py b/app/storage_v2/models/files_db.py index be7ff471..8dcf46c5 100644 --- a/app/storage_v2/models/files_db.py +++ b/app/storage_v2/models/files_db.py @@ -1,9 +1,9 @@ from enum import StrEnum from pathlib import Path -from shutil import copyfileobj -from typing import BinaryIO, Literal, Self +from typing import Literal, Self from uuid import UUID, uuid4 +import aiofiles from pydantic_marshals.sqlalchemy import MappedModel from sqlalchemy import Enum from sqlalchemy.orm import Mapped, mapped_column @@ -57,16 +57,16 @@ def content_disposition(self) -> ContentDisposition: @classmethod async def create_with_content( cls, - content: BinaryIO, - filename: str | None, + content: bytes, + filename: str, file_kind: FileKind, ) -> Self: file = await cls.create( - name=filename or "upload", + name=filename, kind=file_kind, ) - with file.path.open("wb") as f: - copyfileobj(content, f) # TODO maybe convert to async + async with aiofiles.open(file.path, "wb") as f: + await f.write(content) return file async def delete(self) -> None: diff --git a/app/storage_v2/routers/files_rst.py b/app/storage_v2/routers/files_rst.py index 48b1db61..548deafa 100644 --- a/app/storage_v2/routers/files_rst.py +++ b/app/storage_v2/routers/files_rst.py @@ -1,8 +1,10 @@ from datetime import datetime +from io import BytesIO from os import stat from typing import Annotated from fastapi import Header, UploadFile +from PIL import Image from starlette import status from starlette.responses import FileResponse, Response from starlette.staticfiles import NotModifiedResponse @@ -13,9 +15,10 @@ from app.storage_v2.dependencies.storage_token_dep import ( StorageTokenPayload, StorageTokenResponses, + UploadAllowedStorageTokenPayload, ) from app.storage_v2.dependencies.uploads_dep import ValidatedImageUpload -from app.storage_v2.models.access_groups_db import AccessGroup, AccessGroupFile +from app.storage_v2.models.access_groups_db import AccessGroupFile from app.storage_v2.models.files_db import File, FileKind router = APIRouterExt(tags=["files"]) @@ -23,21 +26,13 @@ async def upload_file( storage_token_payload: StorageTokenPayloadSchema, - upload: UploadFile, + upload_content: bytes, + upload_filename: str | None, file_kind: FileKind, ) -> File: - if not storage_token_payload.can_upload_files: - raise StorageTokenResponses.INVALID_STORAGE_TOKEN - - access_group = await AccessGroup.find_first_by_id( - storage_token_payload.access_group_id - ) - if access_group is None: - raise StorageTokenResponses.INVALID_STORAGE_TOKEN - file = await File.create_with_content( - content=upload.file, - filename=upload.filename, + content=upload_content, + filename=upload_filename or "upload", file_kind=file_kind, ) @@ -56,12 +51,13 @@ async def upload_file( summary="Upload a new uncategorized file", ) async def upload_uncategorized_file( - storage_token_payload: StorageTokenPayload, + storage_token_payload: UploadAllowedStorageTokenPayload, upload: UploadFile, ) -> File: return await upload_file( storage_token_payload=storage_token_payload, - upload=upload, + upload_content=await upload.read(), + upload_filename=upload.filename, file_kind=FileKind.UNCATEGORIZED, ) @@ -73,12 +69,18 @@ async def upload_uncategorized_file( summary="Upload a new image file", ) async def upload_image_file( - storage_token_payload: StorageTokenPayload, + storage_token_payload: UploadAllowedStorageTokenPayload, upload: ValidatedImageUpload, ) -> File: + image = Image.open(BytesIO(await upload.read())) + processed_image = BytesIO() + image.save(processed_image, format="webp") + processed_image.seek(0) + return await upload_file( storage_token_payload=storage_token_payload, - upload=upload, + upload_content=processed_image.read(), + upload_filename=upload.filename, file_kind=FileKind.IMAGE, ) diff --git a/app/subscriptions/__init__.py b/app/subscriptions/__init__.py new file mode 100644 index 00000000..7d9835dc --- /dev/null +++ b/app/subscriptions/__init__.py @@ -0,0 +1,3 @@ +from app.subscriptions.main import api_router + +__all__ = ["api_router"] diff --git a/app/subscriptions/dependencies/__init__.py b/app/subscriptions/dependencies/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/subscriptions/dependencies/promocodes_dep.py b/app/subscriptions/dependencies/promocodes_dep.py new file mode 100644 index 00000000..e061783c --- /dev/null +++ b/app/subscriptions/dependencies/promocodes_dep.py @@ -0,0 +1,37 @@ +from typing import Annotated + +from fastapi import Depends, Path +from starlette import status + +from app.common.fastapi_ext import Responses, with_responses +from app.subscriptions.models.promocodes_db import Promocode + + +class PromocodeResponses(Responses): + PROMOCODE_NOT_FOUND = status.HTTP_404_NOT_FOUND, "Promocode not found" + + +@with_responses(PromocodeResponses) +async def get_promocode_by_id( + promocode_id: Annotated[int, Path()], +) -> Promocode: + promocode = await Promocode.find_first_by_id(promocode_id) + if promocode is None: + raise PromocodeResponses.PROMOCODE_NOT_FOUND + return promocode + + +PromocodeByID = Annotated[Promocode, Depends(get_promocode_by_id)] + + +@with_responses(PromocodeResponses) +async def get_promocode_by_code( + code: Annotated[str, Path()], +) -> Promocode: + promocode = await Promocode.find_first_by_kwargs(code=code) + if promocode is None: + raise PromocodeResponses.PROMOCODE_NOT_FOUND + return promocode + + +PromocodeByCode = Annotated[Promocode, Depends(get_promocode_by_code)] diff --git a/app/subscriptions/main.py b/app/subscriptions/main.py new file mode 100644 index 00000000..f5cbfb8e --- /dev/null +++ b/app/subscriptions/main.py @@ -0,0 +1,39 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from app.common.dependencies.api_key_dep import APIKeyProtection +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.subscriptions.routes import promocodes_mub + +outside_router = APIRouterExt(prefix="/api/public/subscription-service") + +authorized_router = APIRouterExt( + dependencies=[ProxyAuthorized], + prefix="/api/protected/subscription-service", +) + +mub_router = APIRouterExt( + dependencies=[MUBProtection], + prefix="/mub/subscription-service", +) +mub_router.include_router(promocodes_mub.router) + +internal_router = APIRouterExt( + dependencies=[APIKeyProtection], + prefix="/internal/subscription-service", +) + + +@asynccontextmanager +async def lifespan(_: Any) -> AsyncIterator[None]: + yield + + +api_router = APIRouterExt(lifespan=lifespan) +api_router.include_router(outside_router) +api_router.include_router(authorized_router) +api_router.include_router(mub_router) +api_router.include_router(internal_router) diff --git a/app/subscriptions/models/__init__.py b/app/subscriptions/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/subscriptions/models/promocodes_db.py b/app/subscriptions/models/promocodes_db.py new file mode 100644 index 00000000..dcd481ee --- /dev/null +++ b/app/subscriptions/models/promocodes_db.py @@ -0,0 +1,53 @@ +from datetime import datetime +from typing import Annotated + +from pydantic import AwareDatetime, Field +from pydantic_marshals.sqlalchemy import MappedModel +from sqlalchemy import DateTime, String, select +from sqlalchemy.orm import Mapped, mapped_column + +from app.common.config import Base +from app.common.sqlalchemy_ext import db +from app.common.utils.datetime import datetime_utc_now + + +class Promocode(Base): + __tablename__ = "promocodes" + + id: Mapped[int] = mapped_column(primary_key=True) + + title: Mapped[str] = mapped_column(String(100)) + code: Mapped[str] = mapped_column(String(10), index=True, unique=True) + + valid_from: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), default=None + ) + valid_until: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), default=None + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime_utc_now + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime_utc_now + ) + + TitleType = Annotated[str, Field(min_length=1, max_length=100)] + CodeType = Annotated[str, Field(min_length=1, max_length=10)] + + InputSchema = MappedModel.create( + columns=[ + (title, TitleType), + (code, CodeType), + (valid_from, AwareDatetime | None), + (valid_until, AwareDatetime | None), + ] + ) + ResponseSchema = InputSchema.extend( + columns=[id, (created_at, AwareDatetime), (updated_at, AwareDatetime)] + ) + + @classmethod + async def is_present_by_code(cls, code: str) -> bool: + return await db.is_present(select(cls).filter_by(code=code)) diff --git a/app/subscriptions/routes/__init__.py b/app/subscriptions/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/subscriptions/routes/promocodes_mub.py b/app/subscriptions/routes/promocodes_mub.py new file mode 100644 index 00000000..4875ff3a --- /dev/null +++ b/app/subscriptions/routes/promocodes_mub.py @@ -0,0 +1,99 @@ +from collections.abc import Sequence +from typing import Annotated, Self + +from fastapi import Query +from pydantic import model_validator +from starlette import status + +from app.common.fastapi_ext import APIRouterExt, Responses +from app.common.utils.datetime import datetime_utc_now +from app.subscriptions.dependencies.promocodes_dep import PromocodeByCode, PromocodeByID +from app.subscriptions.models.promocodes_db import Promocode + +router = APIRouterExt(tags=["promocodes mub"]) + + +@router.get( + "/promocodes/", + response_model=list[Promocode.ResponseSchema], + summary="List paginated promocodes", +) +async def list_promocodes( + offset: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=100)] = 100, +) -> Sequence[Promocode]: + return await Promocode.find_paginated_by_kwargs( + offset, limit, Promocode.created_at.desc() + ) + + +class PromocodeInputSchema(Promocode.InputSchema): + @model_validator(mode="after") + def validate_promocode_valid_from_and_until_date(self) -> Self: + if ( + self.valid_from is not None and self.valid_until is not None + ) and self.valid_from >= self.valid_until: + raise ValueError("the end date cannot be earlier than the start date") + return self + + +class PromocodeConflictResponses(Responses): + PROMOCODE_ALREADY_EXISTS = status.HTTP_409_CONFLICT, "Promocode already exists" + + +@router.post( + "/promocodes/", + status_code=status.HTTP_201_CREATED, + response_model=Promocode.ResponseSchema, + responses=PromocodeConflictResponses.responses(), + summary="Create a new promocode", +) +async def create_promocode(data: PromocodeInputSchema) -> Promocode: + if await Promocode.is_present_by_code(code=data.code): + raise PromocodeConflictResponses.PROMOCODE_ALREADY_EXISTS + return await Promocode.create(**data.model_dump()) + + +@router.get( + "/promocodes/by-id/{promocode_id}/", + response_model=Promocode.ResponseSchema, + summary="Retrieve any promocode by id", +) +async def retrieve_promocode_by_id(promocode: PromocodeByID) -> Promocode: + return promocode + + +@router.get( + "/promocodes/by-code/{code}/", + response_model=Promocode.ResponseSchema, + summary="Retrieve any promocode by code", +) +async def retrieve_promocode_by_code(promocode: PromocodeByCode) -> Promocode: + return promocode + + +@router.put( + "/promocodes/{promocode_id}/", + response_model=Promocode.ResponseSchema, + responses=PromocodeConflictResponses.responses(), + summary="Update any promocode by id", +) +async def put_promocode( + promocode: PromocodeByID, + data: PromocodeInputSchema, +) -> Promocode: + if data.code != promocode.code and await Promocode.is_present_by_code( + code=data.code + ): + raise PromocodeConflictResponses.PROMOCODE_ALREADY_EXISTS + promocode.update(**data.model_dump(), updated_at=datetime_utc_now()) + return promocode + + +@router.delete( + "/promocodes/{promocode_id}/", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete any promocode by id", +) +async def delete_promocode(promocode: PromocodeByID) -> None: + await promocode.delete() diff --git a/app/supbot/main.py b/app/supbot/main.py index b3a76cb4..0f545c23 100644 --- a/app/supbot/main.py +++ b/app/supbot/main.py @@ -1,3 +1,4 @@ +import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import Any @@ -45,14 +46,17 @@ @asynccontextmanager async def lifespan(_: Any) -> AsyncIterator[None]: - await telegram_app.maybe_initialize_from_config( - bot_name="supbot", - bot_settings=settings.supbot, - bot_commands=BOT_COMMANDS, - webhook_prefix=outside_router.prefix, - redis_dsn=settings.redis_supbot_dsn, - group_id=settings.supbot and settings.supbot.group_id, - ) + try: + await telegram_app.maybe_initialize_from_config( + bot_name="supbot", + bot_settings=settings.supbot, + bot_commands=BOT_COMMANDS, + webhook_prefix=outside_router.prefix, + redis_dsn=settings.redis_supbot_dsn, + group_id=settings.supbot and settings.supbot.group_id, + ) + except Exception as e: # pragma: no cover # setup-level safety + logging.error("Supbot initialization failed", exc_info=e) yield 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/app/users/models/users_db.py b/app/users/models/users_db.py index 29349369..9a29a854 100644 --- a/app/users/models/users_db.py +++ b/app/users/models/users_db.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from enum import StrEnum from pathlib import Path -from typing import Annotated, Self +from typing import Annotated, ClassVar, Self from passlib.handlers.pbkdf2 import pbkdf2_sha256 from pydantic import AfterValidator, AwareDatetime, StringConstraints @@ -30,6 +30,8 @@ class OnboardingStage(StrEnum): class User(Base): __tablename__ = "users" + avatar_shape: ClassVar[tuple[int, int]] = 128, 128 + @staticmethod def generate_hash(password: str) -> str: return pbkdf2_sha256.hash(password) diff --git a/app/users/routes/avatar_rst.py b/app/users/routes/avatar_rst.py index f1b12f7d..f2a5b2b8 100644 --- a/app/users/routes/avatar_rst.py +++ b/app/users/routes/avatar_rst.py @@ -1,12 +1,14 @@ -from typing import Annotated +from io import BytesIO -import filetype # type: ignore[import-untyped] -from fastapi import File, UploadFile -from filetype.types.image import Webp # type: ignore[import-untyped] +import aiofiles +from fastapi import UploadFile +from PIL import Image from starlette import status from app.common.fastapi_ext import APIRouterExt, Responses +from app.common.filetype_ext import FILE_HEADER_SIZE, match_image_filetype from app.users.dependencies.users_dep import AuthorizedUser +from app.users.models.users_db import User router = APIRouterExt(tags=["current user avatar"]) @@ -23,13 +25,24 @@ class AvatarResponses(Responses): ) async def update_or_create_avatar( user: AuthorizedUser, - avatar: Annotated[UploadFile, File(description="image/webp")], + avatar: UploadFile, ) -> None: - if not filetype.match(avatar.file, [Webp()]): + avatar_header_data = await avatar.read(FILE_HEADER_SIZE) + + if match_image_filetype(avatar_header_data) is None: raise AvatarResponses.WRONG_FORMAT - with user.avatar_path.open("wb") as file: - file.write(await avatar.read()) + await avatar.seek(0) + avatar_image: Image.Image = Image.open(BytesIO(await avatar.read())) + + avatar_image = avatar_image.resize(User.avatar_shape) + + processed_avatar = BytesIO() + avatar_image.save(processed_avatar, format="webp") + processed_avatar.seek(0) + + async with aiofiles.open(user.avatar_path, "wb") as file: + await file.write(processed_avatar.read()) @router.delete( diff --git a/poetry.lock b/poetry.lock index 17826cec..fbc32382 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,46 +2,45 @@ [[package]] name = "aiofiles" -version = "24.1.0" +version = "25.1.0" description = "File support for asyncio." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, - {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, + {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, + {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, ] [[package]] name = "aiogram" -version = "3.20.0.post0" +version = "3.25.0" description = "Modern and fully asynchronous framework for Telegram Bot API" optional = false -python-versions = ">=3.9" +python-versions = "<3.15,>=3.10" groups = ["main"] files = [ - {file = "aiogram-3.20.0.post0-py3-none-any.whl", hash = "sha256:c8f5a68b0729e74efa15a7fc285bd49fa3d0603de5e424404219a822f8e5f4d1"}, - {file = "aiogram-3.20.0.post0.tar.gz", hash = "sha256:2443799b4514ac251fcf2d561603e250f8763808542222fa1136901058eda9a3"}, + {file = "aiogram-3.25.0-py3-none-any.whl", hash = "sha256:0243966e93fbde14e90c0dfd0b3776c637ebf7ddcca2c7ee81ecbd68d9490cce"}, + {file = "aiogram-3.25.0.tar.gz", hash = "sha256:8a8b0c34f8c4ca8a6501b954abb0eeba26743449e35e20b70c0d810347354c3c"}, ] [package.dependencies] -aiofiles = ">=23.2.1,<24.2" -aiohttp = ">=3.9.0,<3.12" +aiofiles = ">=23.2.1,<26.0" +aiohttp = ">=3.9.0,<3.14" certifi = ">=2023.7.22" magic-filter = ">=1.0.12,<1.1" -pydantic = ">=2.4.1,<2.12" +pydantic = ">=2.4.1,<2.13" typing-extensions = ">=4.7.0,<=5.0" [package.extras] cli = ["aiogram-cli (>=1.1.0,<2.0.0)"] -dev = ["black (>=24.4.2,<24.5.0)", "isort (>=5.13.2,<5.14.0)", "motor-types (>=1.0.0b4,<1.1.0)", "mypy (>=1.10.0,<1.11.0)", "packaging (>=24.1,<25.0)", "pre-commit (>=3.5,<4.0)", "ruff (>=0.5.1,<0.6.0)", "toml (>=0.10.2,<0.11.0)"] docs = ["furo (>=2024.8.6,<2024.9.0)", "markdown-include (>=0.8.1,<0.9.0)", "pygments (>=2.18.0,<2.19.0)", "pymdown-extensions (>=10.3,<11.0)", "sphinx (>=8.0.2,<8.1.0)", "sphinx-autobuild (>=2024.9.3,<2024.10.0)", "sphinx-copybutton (>=0.5.2,<0.6.0)", "sphinx-intl (>=2.2.0,<2.3.0)", "sphinx-substitution-extensions (>=2024.8.6,<2024.9.0)", "sphinxcontrib-towncrier (>=0.4.0a0,<0.5.0)", "towncrier (>=24.8.0,<24.9.0)"] fast = ["aiodns (>=3.0.0)", "uvloop (>=0.17.0) ; (sys_platform == \"darwin\" or sys_platform == \"linux\") and platform_python_implementation != \"PyPy\" and python_version < \"3.13\"", "uvloop (>=0.21.0) ; (sys_platform == \"darwin\" or sys_platform == \"linux\") and platform_python_implementation != \"PyPy\" and python_version >= \"3.13\""] -i18n = ["babel (>=2.13.0,<2.14.0)"] -mongo = ["motor (>=3.3.2,<3.7.0)"] -proxy = ["aiohttp-socks (>=0.8.3,<0.9.0)"] -redis = ["redis[hiredis] (>=5.0.1,<5.3.0)"] -test = ["aresponses (>=2.1.6,<2.2.0)", "pycryptodomex (>=3.19.0,<3.20.0)", "pytest (>=7.4.2,<7.5.0)", "pytest-aiohttp (>=1.0.5,<1.1.0)", "pytest-asyncio (>=0.21.1,<0.22.0)", "pytest-cov (>=4.1.0,<4.2.0)", "pytest-html (>=4.0.2,<4.1.0)", "pytest-lazy-fixture (>=0.6.3,<0.7.0)", "pytest-mock (>=3.12.0,<3.13.0)", "pytest-mypy (>=0.10.3,<0.11.0)", "pytz (>=2023.3,<2024.0)"] +i18n = ["babel (>=2.13.0,<3)"] +mongo = ["motor (>=3.3.2,<3.8)", "pymongo (>4.5,<4.16)"] +proxy = ["aiohttp-socks (>=0.10.1,<0.11.0)"] +redis = ["redis[hiredis] (>=6.2.0,<8)"] +signature = ["cryptography (>=46.0.0)"] [[package]] name = "aiohappyeyeballs" @@ -2445,102 +2444,111 @@ flake8 = ">=5.0.0" [[package]] name = "pillow" -version = "11.2.1" -description = "Python Imaging Library (Fork)" +version = "12.2.0" +description = "Python Imaging Library (fork)" optional = false -python-versions = ">=3.9" -groups = ["tests"] +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047"}, - {file = "pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d"}, - {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97"}, - {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579"}, - {file = "pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d"}, - {file = "pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad"}, - {file = "pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2"}, - {file = "pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70"}, - {file = "pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788"}, - {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e"}, - {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e"}, - {file = "pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6"}, - {file = "pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193"}, - {file = "pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7"}, - {file = "pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f"}, - {file = "pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4"}, - {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443"}, - {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c"}, - {file = "pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3"}, - {file = "pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941"}, - {file = "pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb"}, - {file = "pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28"}, - {file = "pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155"}, - {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14"}, - {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b"}, - {file = "pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2"}, - {file = "pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691"}, - {file = "pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c"}, - {file = "pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22"}, - {file = "pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91"}, - {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751"}, - {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9"}, - {file = "pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd"}, - {file = "pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e"}, - {file = "pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681"}, - {file = "pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8"}, - {file = "pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb"}, - {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a"}, - {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36"}, - {file = "pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67"}, - {file = "pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1"}, - {file = "pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044"}, - {file = "pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"}, + {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"}, + {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"}, + {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"}, + {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"}, + {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"}, + {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"}, + {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"}, + {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"}, + {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"}, + {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"}, + {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"}, + {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"}, + {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"}, + {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"}, + {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"}, + {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"}, + {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"}, + {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"}, + {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"}, + {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"}, + {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, + {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, ] [package.extras] -docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] fpx = ["olefile"] mic = ["olefile"] -test-arrow = ["pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"] -typing = ["typing-extensions ; python_version < \"3.10\""] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] [[package]] @@ -3155,14 +3163,14 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-lazy-fixtures" -version = "1.3.4" +version = "1.4.0" description = "Allows you to use fixtures in @pytest.mark.parametrize." optional = false python-versions = ">=3.8" groups = ["tests"] files = [ - {file = "pytest_lazy_fixtures-1.3.4-py3-none-any.whl", hash = "sha256:3fcb1032f1ffcde367588f4229f7fc6ff64b7a0522c9cd305a08baf39c1c4f5c"}, - {file = "pytest_lazy_fixtures-1.3.4.tar.gz", hash = "sha256:7dd2c110830897b83f041d3a503cbdda10c98ced6dca7602fc43e2f6017c27ed"}, + {file = "pytest_lazy_fixtures-1.4.0-py3-none-any.whl", hash = "sha256:c5db4506fa0ade5887189d1a18857fec4c329b4f49043fef6732c67c9553389a"}, + {file = "pytest_lazy_fixtures-1.4.0.tar.gz", hash = "sha256:f544b60c96b909b307558a62cc1f28f026f11e9f03d7f583a1dc636de3dbcb10"}, ] [package.dependencies] @@ -3700,6 +3708,18 @@ asgiref = ">=3.8.1,<4.0.0" pydantic = ">=2.7.0,<3.0.0" python-socketio = ">=5.11.2,<6.0.0" +[[package]] +name = "types-aiofiles" +version = "25.1.0.20251011" +description = "Typing stubs for aiofiles" +optional = false +python-versions = ">=3.9" +groups = ["types"] +files = [ + {file = "types_aiofiles-25.1.0.20251011-py3-none-any.whl", hash = "sha256:8ff8de7f9d42739d8f0dadcceeb781ce27cd8d8c4152d4a7c52f6b20edb8149c"}, + {file = "types_aiofiles-25.1.0.20251011.tar.gz", hash = "sha256:1c2b8ab260cb3cd40c15f9d10efdc05a6e1e6b02899304d80dfa0410e028d3ff"}, +] + [[package]] name = "types-cffi" version = "1.17.0.20250326" @@ -4255,5 +4275,5 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" -python-versions = "~=3.12,<4.0" -content-hash = "aac06f7746a11c72445f6adb78f2fe9b38416f940765b033c5e82ef868c4f430" +python-versions = "~=3.12,<3.15" +content-hash = "855178da1cdda866751889417cb42c114e1b9dba5d80c45ab2199bd7c1eb1a09" diff --git a/pyproject.toml b/pyproject.toml index 7af023c6..cb00c9af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ {name = "xi.team", email = "xieffect@yandex.ru"}, ] readme = "README.md" -requires-python = "~=3.12,<4.0" +requires-python = "~=3.12,<3.15" dynamic = ["dependencies"] [build-system] @@ -34,15 +34,18 @@ livekit-api = "1.0.5" passlib = "^1.7.4" cryptography = "^42.0.5" discord-webhook = {extras = ["async"], version = "^1.3.1"} -aiogram = "^3.4.1" +aiogram = "^3.25.0" aiosmtplib = "^3.0.2" +aiofiles = "^25.1.0" itsdangerous = "^2.2.0" faststream = {extras = ["redis"], version = "^0.6.2"} sentry-sdk = {extras = ["asyncio", "fastapi", "sqlalchemy", "redis", "httpx"], version = "2.44.0"} +pillow = "^12.2.0" [tool.poetry.group.types.dependencies] types-passlib = "^1.7.7.13" types-redis = "^4.6.0" +types-aiofiles = "^25.1.0.20251011" [tool.poetry.group.dev.dependencies] watchfiles = "^0.21.0" @@ -78,7 +81,7 @@ dlint = "0.14.0" [tool.poetry.group.tests.dependencies] pytest = "^8.3.5" pytest-cov = "^6.1.1" -pytest-lazy-fixtures = "^1.3.4" +pytest-lazy-fixtures = "^1.4.0" pydantic-marshals = {extras = ["assert-contains"], version = "0.3.18"} freezegun = "^1.5.1" respx = "^0.22.0" @@ -86,7 +89,6 @@ polyfactory = "^2.21.0" faker = "^37.1.0" faker-file = "^0.18.4" rstr = "^3.2.2" -pillow = "^11.2.1" [tool.isort] profile = "black" diff --git a/tests/common/faker_ext.py b/tests/common/faker_ext.py index 0b7cd6c0..b1b9a5e0 100644 --- a/tests/common/faker_ext.py +++ b/tests/common/faker_ext.py @@ -4,6 +4,7 @@ from faker_file.providers import ( # type: ignore[import-untyped] bin_file, pdf_file, + png_file, webp_file, ) @@ -13,6 +14,7 @@ def _setup_faker(faker: Faker) -> None: faker.add_provider(internet) faker.add_provider(bin_file.BinFileProvider) faker.add_provider(webp_file.GraphicWebpFileProvider) + faker.add_provider(png_file.GraphicPngFileProvider) faker.add_provider(pdf_file.PdfFileProvider) diff --git a/tests/common/livekit_testing.py b/tests/common/livekit_testing.py index a33f7bf0..47e684c7 100644 --- a/tests/common/livekit_testing.py +++ b/tests/common/livekit_testing.py @@ -3,15 +3,20 @@ import pytest from google.protobuf.message import Message -from livekit.api.twirp_client import TwirpClient +from livekit.api.twirp_client import TwirpClient, TwirpError from tests.common.mock_stack import MockStack class LiveKitRouteMock: - def __init__(self, response_data: Message) -> None: + def __init__( + self, + response_data: Message | None = None, + side_effect: TwirpError | None = None, + ) -> None: self.request_data: Message | None = None self.response_data = response_data + self.side_effect = side_effect def request( self, @@ -19,8 +24,10 @@ def request( response_class: type[Message], ) -> Message: assert self.request_data is None, "LiveKit mock has been called before" - assert isinstance(self.response_data, response_class) self.request_data = request_data + if self.side_effect is not None: + raise self.side_effect + assert isinstance(self.response_data, response_class) return self.response_data def assert_requested_once_with(self, expected_data: Message) -> None: @@ -33,9 +40,13 @@ def __init__(self) -> None: self.route_mocks: dict[tuple[str, str], LiveKitRouteMock] = {} def route( - self, service: str, method: str, response_data: Message + self, + service: str, + method: str, + response_data: Message | None = None, + side_effect: TwirpError | None = None, ) -> LiveKitRouteMock: - route_mock = LiveKitRouteMock(response_data) + route_mock = LiveKitRouteMock(response_data, side_effect) self.route_mocks[(service, method)] = route_mock return route_mock diff --git a/tests/conferences/conftest.py b/tests/conferences/conftest.py index 139890fd..a4db40ad 100644 --- a/tests/conferences/conftest.py +++ b/tests/conferences/conftest.py @@ -6,6 +6,7 @@ from starlette.testclient import TestClient from app.common.dependencies.authorization_dep import ProxyAuthData +from app.conferences.schemas.conferences_sch import RoomMetadataSchema from tests.common.types import PytestRequest from tests.factories import ProxyAuthDataFactory @@ -27,6 +28,16 @@ def outsider_user_id(outsider_auth_data: ProxyAuthData) -> int: return outsider_auth_data.user_id +@pytest.fixture() +def other_auth_data() -> ProxyAuthData: + return ProxyAuthDataFactory.build() + + +@pytest.fixture() +def other_user_id(other_auth_data: ProxyAuthData) -> int: + return other_auth_data.user_id + + ClassroomRoleType = Literal["tutor", "student"] @@ -49,4 +60,7 @@ async def classroom_conference_room_name(classroom_id: int) -> str: @pytest.fixture() async def classroom_conference_room(classroom_conference_room_name: str) -> Room: - return Room(name=classroom_conference_room_name) + return Room( + name=classroom_conference_room_name, + metadata=RoomMetadataSchema().model_dump_metadata_json(), + ) diff --git a/tests/conferences/factories.py b/tests/conferences/factories.py index 54fe40cb..20bd681e 100644 --- a/tests/conferences/factories.py +++ b/tests/conferences/factories.py @@ -1,6 +1,18 @@ -from app.conferences.schemas.conferences_sch import ConferenceParticipantSchema +from app.conferences.schemas.conferences_sch import ( + ConferenceParticipantSchema, + ParticipantMetadataSchema, + RoomMetadataSchema, +) from tests.common.polyfactory_ext import BaseModelFactory +class RoomMetadataFactory(BaseModelFactory[RoomMetadataSchema]): + __model__ = RoomMetadataSchema + + +class ParticipantMetadataFactory(BaseModelFactory[ParticipantMetadataSchema]): + __model__ = ParticipantMetadataSchema + + class ConferenceParticipantFactory(BaseModelFactory[ConferenceParticipantSchema]): __model__ = ConferenceParticipantSchema diff --git a/tests/conferences/router/test_classroom_conferences_rst.py b/tests/conferences/router/test_classroom_conferences_rst.py index 95b33f08..415efaec 100644 --- a/tests/conferences/router/test_classroom_conferences_rst.py +++ b/tests/conferences/router/test_classroom_conferences_rst.py @@ -3,7 +3,8 @@ import pytest from faker import Faker -from livekit.protocol.models import Room +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 @@ -14,11 +15,19 @@ NotificationInputSchema, NotificationKind, ) +from app.conferences.schemas.conferences_sch import ( + ParticipantMetadataSchema, + RoomMetadataSchema, +) 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 +from tests.conferences.factories import ( + ConferenceParticipantFactory, + ParticipantMetadataFactory, + RoomMetadataFactory, +) pytestmark = pytest.mark.anyio @@ -102,6 +111,40 @@ async def test_classroom_conference_reactivation_no_students( ) +async def test_classroom_conference_metadata_updating( + mock_stack: MockStack, + outsider_client: TestClient, + classroom_id: int, + classroom_conference_room_name: str, + classroom_conference_room: Room, +) -> None: + new_room_metadata: RoomMetadataSchema = RoomMetadataFactory.build() + + find_room_by_name_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.find_room_by_name", + return_value=classroom_conference_room, + ) + update_room_metadata_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.update_room_metadata" + ) + + assert_nodata_response( + outsider_client.put( + "/api/protected/conference-service/roles/tutor" + f"/classrooms/{classroom_id}/conference/metadata/", + json=new_room_metadata.model_dump(), + ), + ) + + find_room_by_name_mock.assert_awaited_once_with( + livekit_room_name=classroom_conference_room_name, + ) + update_room_metadata_mock.assert_awaited_once_with( + livekit_room=classroom_conference_room, + metadata=new_room_metadata, + ) + + async def test_classroom_conference_access_token_generation( faker: Faker, mock_stack: MockStack, @@ -135,7 +178,8 @@ async def test_classroom_conference_access_token_generation( livekit_room_name=classroom_conference_room_name ) generate_access_token_mock.assert_awaited_once_with( - livekit_room=classroom_conference_room, user_id=outsider_user_id + livekit_room=classroom_conference_room, + user_id=outsider_user_id, ) @@ -143,7 +187,6 @@ async def test_classroom_conference_participants_listing( faker: Faker, mock_stack: MockStack, outsider_client: TestClient, - outsider_user_id: int, parametrized_classroom_role: ClassroomRoleType, classroom_id: int, classroom_conference_room_name: str, @@ -178,21 +221,173 @@ async def test_classroom_conference_participants_listing( ) +participant_metadata_updating_request_parametrization = pytest.mark.parametrize( + ("participant_user_id", "participant_user_id_in_path", "role"), + [ + pytest.param( + lf("outsider_user_id"), + "current", + "tutor", + id="tutor-current_participant", + ), + pytest.param( + lf("outsider_user_id"), + "current", + "student", + id="student-current_participant", + ), + pytest.param( + lf("other_user_id"), + lf("other_user_id"), + "tutor", + id="tutor-current_participant", + ), + ], +) + + +@participant_metadata_updating_request_parametrization +async def test_classroom_conference_participant_metadata_updating( + faker: Faker, + mock_stack: MockStack, + outsider_client: TestClient, + outsider_user_id: int, + classroom_id: int, + classroom_conference_room_name: str, + classroom_conference_room: Room, + role: ClassroomRoleType, + participant_user_id: int, + participant_user_id_in_path: int | str, +) -> None: + new_participant_metadata: ParticipantMetadataSchema = ( + ParticipantMetadataFactory.build() + ) + new_participant_info = ParticipantInfo( + name=faker.user_name(), + identity=str(participant_user_id), + metadata=new_participant_metadata.model_dump_metadata_json(), + ) + + find_room_by_name_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.find_room_by_name", + return_value=classroom_conference_room, + ) + update_participant_metadata_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.update_participant_metadata", + return_value=new_participant_info, + ) + + assert_nodata_response( + outsider_client.put( + f"/api/protected/conference-service/roles/{role}" + f"/classrooms/{classroom_id}/conference" + f"/participants/{participant_user_id_in_path}/metadata/", + json=new_participant_metadata.model_dump(), + ), + ) + + find_room_by_name_mock.assert_awaited_once_with( + livekit_room_name=classroom_conference_room_name + ) + update_participant_metadata_mock.assert_awaited_once_with( + livekit_room=classroom_conference_room, + user_id=participant_user_id, + metadata=new_participant_metadata, + ) + + +@participant_metadata_updating_request_parametrization +async def test_classroom_conference_participant_metadata_updating_participant_not_found( + faker: Faker, + mock_stack: MockStack, + outsider_client: TestClient, + outsider_user_id: int, + classroom_id: int, + classroom_conference_room_name: str, + classroom_conference_room: Room, + role: ClassroomRoleType, + participant_user_id: int, + participant_user_id_in_path: int | str, +) -> None: + new_participant_metadata: ParticipantMetadataSchema = ( + ParticipantMetadataFactory.build() + ) + + find_room_by_name_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.find_room_by_name", + return_value=classroom_conference_room, + ) + update_participant_metadata_mock = mock_stack.enter_async_mock( + "app.conferences.services.conferences_svc.update_participant_metadata", + ) + + assert_response( + outsider_client.put( + f"/api/protected/conference-service/roles/{role}" + f"/classrooms/{classroom_id}/conference" + f"/participants/{participant_user_id_in_path}/metadata/", + json=new_participant_metadata.model_dump(), + ), + expected_code=status.HTTP_404_NOT_FOUND, + expected_json={"detail": "Conference participant not found"}, + ) + + find_room_by_name_mock.assert_awaited_once_with( + livekit_room_name=classroom_conference_room_name + ) + update_participant_metadata_mock.assert_awaited_once_with( + livekit_room=classroom_conference_room, + user_id=participant_user_id, + metadata=new_participant_metadata, + ) + + @pytest.mark.parametrize( - ("method", "path"), + ("method", "path", "role"), [ - pytest.param("POST", "access-tokens/", id="generate_access_token"), - pytest.param("GET", "participants/", id="list_participants"), + pytest.param("PUT", "metadata/", "tutor", id="update_room_metadata-tutor"), + pytest.param( + "POST", + "access-tokens/", + "tutor", + id="generate_access_token-tutor", + ), + pytest.param( + "POST", + "access-tokens/", + "student", + id="generate_access_token-student", + ), + pytest.param("GET", "participants/", "tutor", id="list_participants-tutor"), + pytest.param("GET", "participants/", "student", id="list_participants-student"), + pytest.param( + "PUT", + "participants/current/metadata/", + "tutor", + id="update_current_participant_metadata-tutor", + ), + pytest.param( + "PUT", + "participants/current/metadata/", + "student", + id="update_current_participant_metadata-student", + ), + pytest.param( + "PUT", + lfc(lambda other_user_id: f"participants/{other_user_id}/metadata/"), + "tutor", + id="update_other_participant_metadata-tutor", + ), ], ) async def test_classroom_conference_requesting_conference_not_active( mock_stack: MockStack, outsider_client: TestClient, - parametrized_classroom_role: str, classroom_id: int, classroom_conference_room_name: str, method: str, path: str, + role: ClassroomRoleType, ) -> None: find_room_by_name_mock = mock_stack.enter_async_mock( "app.conferences.services.conferences_svc.find_room_by_name", @@ -202,7 +397,7 @@ async def test_classroom_conference_requesting_conference_not_active( outsider_client.request( method=method, url=( - f"/api/protected/conference-service/roles/{parametrized_classroom_role}" + f"/api/protected/conference-service/roles/{role}" f"/classrooms/{classroom_id}/conference/{path}" ), ), diff --git a/tests/conferences/service/test_conference_service.py b/tests/conferences/service/test_conference_service.py index 499275f7..ae67b8c6 100644 --- a/tests/conferences/service/test_conference_service.py +++ b/tests/conferences/service/test_conference_service.py @@ -1,6 +1,7 @@ import jwt import pytest from faker import Faker +from livekit.api import TwirpError, TwirpErrorCode from livekit.protocol.models import ParticipantInfo, Room from livekit.protocol.room import ( CreateRoomRequest, @@ -8,41 +9,63 @@ ListParticipantsResponse, ListRoomsRequest, ListRoomsResponse, + UpdateParticipantRequest, + UpdateRoomMetadataRequest, ) from pydantic_marshals.contains import assert_contains from respx import MockRouter +from starlette import status from app.common.config import settings -from app.conferences.schemas.conferences_sch import ConferenceParticipantSchema +from app.conferences.schemas.conferences_sch import ( + ConferenceParticipantSchema, + ParticipantMetadataSchema, + RoomMetadataSchema, +) from app.conferences.services import conferences_svc from tests.common.livekit_testing import LiveKitMock from tests.common.respx_ext import assert_last_httpx_request from tests.common.types import AnyJSON -from tests.conferences.factories import ConferenceParticipantFactory +from tests.conferences.factories import ( + ConferenceParticipantFactory, + ParticipantMetadataFactory, + RoomMetadataFactory, +) from tests.factories import UserProfileFactory pytestmark = pytest.mark.anyio @pytest.fixture() -async def livekit_room_name(faker: Faker) -> str: +def livekit_room_name(faker: Faker) -> str: return faker.user_name() +@pytest.fixture() +def default_livekit_room(livekit_room_name: str) -> Room: + return Room( + name=livekit_room_name, + metadata=RoomMetadataSchema().model_dump_metadata_json(), + ) + + async def test_room_reactivation( livekit_mock: LiveKitMock, - users_internal_respx_mock: MockRouter, livekit_room_name: str, + default_livekit_room: Room, ) -> None: - livekit_room = Room(name=livekit_room_name) - - create_room_mock = livekit_mock.route("RoomService", "CreateRoom", livekit_room) + create_room_mock = livekit_mock.route( + "RoomService", "CreateRoom", default_livekit_room + ) result = await conferences_svc.reactivate_room(livekit_room_name=livekit_room_name) - assert result == livekit_room + assert result == default_livekit_room create_room_mock.assert_requested_once_with( - CreateRoomRequest(name=livekit_room_name) + CreateRoomRequest( + name=livekit_room_name, + metadata=RoomMetadataSchema().model_dump_metadata_json(), + ) ) @@ -55,35 +78,61 @@ async def test_room_reactivation( ) async def test_room_finding_by_name( livekit_mock: LiveKitMock, - users_internal_respx_mock: MockRouter, - livekit_room_name: str, + default_livekit_room: Room, is_room_found: bool, ) -> None: - livekit_room = Room(name=livekit_room_name) if is_room_found else None - list_rooms_mock = livekit_mock.route( "RoomService", "ListRooms", - ListRoomsResponse(rooms=[] if livekit_room is None else [livekit_room]), + ListRoomsResponse(rooms=[default_livekit_room] if is_room_found else []), ) result = await conferences_svc.find_room_by_name( - livekit_room_name=livekit_room_name + livekit_room_name=default_livekit_room.name ) - assert result == livekit_room + if is_room_found: + assert result == default_livekit_room + else: + assert result is None list_rooms_mock.assert_requested_once_with( - ListRoomsRequest(names=[livekit_room_name]) + ListRoomsRequest(names=[default_livekit_room.name]) + ) + + +async def test_room_updating( + livekit_mock: LiveKitMock, + default_livekit_room: Room, +) -> None: + new_room_metadata: RoomMetadataSchema = RoomMetadataFactory.build() + updated_livekit_room = Room( + name=default_livekit_room.name, + metadata=new_room_metadata.model_dump_metadata_json(), + ) + + create_room_mock = livekit_mock.route( + "RoomService", "UpdateRoomMetadata", updated_livekit_room + ) + + result = await conferences_svc.update_room_metadata( + livekit_room=default_livekit_room, + metadata=new_room_metadata, + ) + assert result == updated_livekit_room + + create_room_mock.assert_requested_once_with( + UpdateRoomMetadataRequest( + room=default_livekit_room.name, + metadata=new_room_metadata.model_dump_metadata_json(), + ) ) async def test_conference_access_token_generation( faker: Faker, users_internal_respx_mock: MockRouter, - livekit_room_name: str, + default_livekit_room: Room, ) -> None: - livekit_room = Room(name=livekit_room_name) - user_id: int = faker.random_int() user_profile_data: AnyJSON = UserProfileFactory.build_json() users_internal_bridge_mock = users_internal_respx_mock.get( @@ -91,7 +140,7 @@ async def test_conference_access_token_generation( ).respond(json=user_profile_data) access_token = await conferences_svc.generate_access_token( - livekit_room=livekit_room, + livekit_room=default_livekit_room, user_id=user_id, ) @@ -100,7 +149,7 @@ async def test_conference_access_token_generation( { "sub": str(user_id), "name": user_profile_data["display_name"], - "video": {"room": livekit_room_name}, + "video": {"room": default_livekit_room.name}, }, ) @@ -113,7 +162,6 @@ async def test_conference_access_token_generation( async def test_listing_room_participants( faker: Faker, livekit_mock: LiveKitMock, - users_internal_respx_mock: MockRouter, livekit_room_name: str, ) -> None: participants: list[ConferenceParticipantSchema] = ( @@ -128,6 +176,7 @@ async def test_listing_room_participants( ParticipantInfo( name=participant.display_name, identity=str(participant.user_id), + metadata=ParticipantMetadataSchema().model_dump_metadata_json(), ) for participant in participants ] @@ -144,3 +193,81 @@ async def test_listing_room_participants( list_participants_mock.assert_requested_once_with( ListParticipantsRequest(room=livekit_room_name) ) + + +async def test_participant_metadata_updating( + livekit_mock: LiveKitMock, + default_livekit_room: Room, +) -> None: + conference_participant_data: ConferenceParticipantSchema = ( + ConferenceParticipantFactory.build() + ) + new_participant_metadata: ParticipantMetadataSchema = ( + ParticipantMetadataFactory.build() + ) + new_participant_info = ParticipantInfo( + name=conference_participant_data.display_name, + identity=str(conference_participant_data.user_id), + metadata=new_participant_metadata.model_dump_metadata_json(), + ) + + update_participant_mock = livekit_mock.route( + "RoomService", + "UpdateParticipant", + new_participant_info, + ) + + assert ( + await conferences_svc.update_participant_metadata( + livekit_room=default_livekit_room, + user_id=conference_participant_data.user_id, + metadata=new_participant_metadata, + ) + == new_participant_info + ) + + update_participant_mock.assert_requested_once_with( + UpdateParticipantRequest( + room=default_livekit_room.name, + identity=str(conference_participant_data.user_id), + metadata=new_participant_metadata.model_dump_metadata_json(), + ) + ) + + +async def test_participant_metadata_updating_participant_not_found( + faker: Faker, + livekit_mock: LiveKitMock, + default_livekit_room: Room, +) -> None: + user_id: int = faker.random_int(1, 1000) + new_participant_metadata: ParticipantMetadataSchema = ( + ParticipantMetadataFactory.build() + ) + + update_participant_mock = livekit_mock.route( + "RoomService", + "UpdateParticipant", + side_effect=TwirpError( + code=TwirpErrorCode.NOT_FOUND, + msg="participant not found", + status=status.HTTP_404_NOT_FOUND, + ), + ) + + assert ( + await conferences_svc.update_participant_metadata( + livekit_room=default_livekit_room, + user_id=user_id, + metadata=new_participant_metadata, + ) + is None + ) + + update_participant_mock.assert_requested_once_with( + UpdateParticipantRequest( + room=default_livekit_room.name, + identity=str(user_id), + metadata=new_participant_metadata.model_dump_metadata_json(), + ) + ) 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/materials/functional/test_classroom_materials_student_rst.py b/tests/materials/functional/test_classroom_materials_student_rst.py index ce1ec2a5..440b9885 100644 --- a/tests/materials/functional/test_classroom_materials_student_rst.py +++ b/tests/materials/functional/test_classroom_materials_student_rst.py @@ -2,7 +2,7 @@ import pytest from freezegun import freeze_time -from pytest_lazy_fixtures import lf, lfc +from pytest_lazy_fixtures import lfc from starlette import status from starlette.testclient import TestClient @@ -54,30 +54,26 @@ async def test_material_retrieving( pytest.param( MaterialAccessMode.READ_ONLY, lfc( - lambda access_group_id, user_id: StorageTokenPayloadSchema( - access_group_id=access_group_id, - user_id=user_id, + lambda classroom_material, student_user_id: StorageTokenPayloadSchema( + access_group_id=classroom_material.access_group_id, + user_id=student_user_id, can_upload_files=False, can_read_files=True, ydoc_access_level=YDocAccessLevel.READ_ONLY, ), - lf("classroom_material.access_group_id"), - lf("student_user_id"), ), id=MaterialAccessMode.READ_ONLY.value, ), pytest.param( MaterialAccessMode.READ_WRITE, lfc( - lambda access_group_id, user_id: StorageTokenPayloadSchema( - access_group_id=access_group_id, - user_id=user_id, + lambda classroom_material, student_user_id: StorageTokenPayloadSchema( + access_group_id=classroom_material.access_group_id, + user_id=student_user_id, can_upload_files=True, can_read_files=True, ydoc_access_level=YDocAccessLevel.READ_WRITE, ), - lf("classroom_material.access_group_id"), - lf("student_user_id"), ), id=MaterialAccessMode.READ_WRITE.value, ), 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"}, - ) diff --git a/tests/storage_v2/conftest.py b/tests/storage_v2/conftest.py index 54c6cd89..ab4f4bd3 100644 --- a/tests/storage_v2/conftest.py +++ b/tests/storage_v2/conftest.py @@ -1,10 +1,13 @@ +from collections.abc import AsyncIterator from dataclasses import dataclass +from io import BytesIO from os import stat from typing import Any, Protocol from uuid import UUID, uuid4 import pytest from faker import Faker +from PIL import Image from pytest_lazy_fixtures import lf from starlette.responses import FileResponse from starlette.testclient import TestClient @@ -72,15 +75,29 @@ def outsider_internal_client( @pytest.fixture() -async def ydoc(faker: Faker, active_session: ActiveSession) -> YDoc: +async def ydoc(faker: Faker, active_session: ActiveSession) -> AsyncIterator[YDoc]: async with active_session(): - return await YDoc.create(content=faker.binary(length=64)) + ydoc = await YDoc.create(content=faker.binary(length=64)) + + yield ydoc + + async with active_session() as session: + session.add(ydoc) + await ydoc.delete() @pytest.fixture() -async def other_ydoc(faker: Faker, active_session: ActiveSession) -> YDoc: +async def other_ydoc( + faker: Faker, active_session: ActiveSession +) -> AsyncIterator[YDoc]: async with active_session(): - return await YDoc.create(content=faker.binary(length=64)) + ydoc = await YDoc.create(content=faker.binary(length=64)) + + yield ydoc + + async with active_session() as session: + session.add(ydoc) + await ydoc.delete() @pytest.fixture() @@ -89,9 +106,18 @@ def missing_ydoc_id() -> UUID: @pytest.fixture() -async def access_group(active_session: ActiveSession, ydoc: YDoc) -> AccessGroup: +async def access_group( + active_session: ActiveSession, + ydoc: YDoc, +) -> AsyncIterator[AccessGroup]: async with active_session(): - return await AccessGroup.create(main_ydoc_id=ydoc.id) + access_group = await AccessGroup.create(main_ydoc_id=ydoc.id) + + yield access_group + + async with active_session() as session: + session.add(access_group) + await access_group.delete() @pytest.fixture() @@ -104,45 +130,78 @@ def uncategorized_file_content(faker: Faker) -> bytes: return faker.bin_file(raw=True) # type: ignore[no-any-return] +def process_image_content(image_content: bytes) -> bytes: + image = Image.open(BytesIO(image_content)) + processed_image_buffer = BytesIO() + image.save(processed_image_buffer, format="webp") + processed_image_buffer.seek(0) + return processed_image_buffer.read() + + @pytest.fixture() -def image_file_content(faker: Faker) -> bytes: +def webp_image_file_content(faker: Faker) -> bytes: return faker.graphic_webp_file(raw=True) # type: ignore[no-any-return] +@pytest.fixture() +def png_image_file_content(faker: Faker) -> bytes: + return faker.graphic_png_file(raw=True) # type: ignore[no-any-return] + + @dataclass class FileInputData: kind: FileKind name: str - content: bytes content_type: str + input_content: bytes + processed_content: bytes @pytest.fixture() def uncategorized_file_input_data( - faker: Faker, uncategorized_file_content: bytes + faker: Faker, + uncategorized_file_content: bytes, ) -> FileInputData: return FileInputData( kind=FileKind.UNCATEGORIZED, name=faker.file_name(), - content=uncategorized_file_content, - content_type=faker.mime_type(), + input_content=uncategorized_file_content, + processed_content=uncategorized_file_content, + content_type=faker.mime_type(category="application"), ) @pytest.fixture() -def image_file_input_data(faker: Faker, image_file_content: bytes) -> FileInputData: +def webp_image_file_input_data( + faker: Faker, webp_image_file_content: bytes +) -> FileInputData: return FileInputData( kind=FileKind.IMAGE, name=faker.file_name(extension="webp"), - content=image_file_content, + input_content=webp_image_file_content, + processed_content=process_image_content(webp_image_file_content), content_type="image/webp", ) +@pytest.fixture() +def png_image_file_input_data( + faker: Faker, png_image_file_content: bytes +) -> FileInputData: + return FileInputData( + kind=FileKind.IMAGE, + name=faker.file_name(extension="png"), + input_content=png_image_file_content, + processed_content=process_image_content(png_image_file_content), + content_type="image/png", + ) + + @pytest.fixture( params=[ pytest.param(lf("uncategorized_file_input_data"), id="uncategorized"), - pytest.param(lf("image_file_input_data"), id="image"), + pytest.param(lf("webp_image_file_input_data"), id="webp_image"), + pytest.param(lf("png_image_file_input_data"), id="png_image"), ], ) def parametrized_file_input_data( @@ -155,7 +214,7 @@ def parametrized_file_input_data( async def file( active_session: ActiveSession, parametrized_file_input_data: FileInputData, -) -> File: +) -> AsyncIterator[File]: async with active_session(): file = await File.create( name=parametrized_file_input_data.name, @@ -163,9 +222,13 @@ async def file( ) with file.path.open("wb") as f: - f.write(parametrized_file_input_data.content) + f.write(parametrized_file_input_data.processed_content) + + yield file - return file + async with active_session() as session: + session.add(file) + await file.delete() @pytest.fixture() @@ -180,13 +243,19 @@ async def access_group_file( active_session: ActiveSession, access_group: AccessGroup, file: File, -) -> AccessGroupFile: +) -> AsyncIterator[AccessGroupFile]: async with active_session(): - return await AccessGroupFile.create( + access_group_file = await AccessGroupFile.create( access_group_id=access_group.id, file_id=file.id, ) + yield access_group_file + + async with active_session() as session: + session.add(access_group_file) + await access_group_file.delete() + @pytest.fixture() def file_response(file: File) -> FileResponse: diff --git a/tests/storage_v2/functional/test_file_reads_rst.py b/tests/storage_v2/functional/test_file_reads_rst.py index d4b355b7..204ce3d1 100644 --- a/tests/storage_v2/functional/test_file_reads_rst.py +++ b/tests/storage_v2/functional/test_file_reads_rst.py @@ -79,7 +79,7 @@ async def test_file_reading( }, expected_json=None, ) - assert response.content == parametrized_file_input_data.content + assert response.content == parametrized_file_input_data.processed_content async def test_file_reading_not_modified_by_etag( diff --git a/tests/storage_v2/functional/test_file_uploads_rst.py b/tests/storage_v2/functional/test_file_uploads_rst.py index 0240058b..7173678b 100644 --- a/tests/storage_v2/functional/test_file_uploads_rst.py +++ b/tests/storage_v2/functional/test_file_uploads_rst.py @@ -1,6 +1,11 @@ +import random +from io import BytesIO from uuid import UUID import pytest +from faker import Faker +from PIL import Image +from pydantic_marshals.contains import assert_contains from pytest_lazy_fixtures import lf, lfc from starlette import status from starlette.testclient import TestClient @@ -47,7 +52,7 @@ async def test_file_uploading( files={ "upload": ( parametrized_file_input_data.name, - parametrized_file_input_data.content, + parametrized_file_input_data.input_content, parametrized_file_input_data.content_type, ) }, @@ -73,15 +78,89 @@ async def test_file_uploading( assert file.path.is_file() with file.path.open("rb") as f: - assert f.read() == parametrized_file_input_data.content + real_file_content = f.read() + + if parametrized_file_input_data.content_type.startswith("image/"): + image_result = Image.open(BytesIO(real_file_content)) + try: + image_result.verify() + except Exception as e: + raise AssertionError("Invalid resulting image") from e + + assert_contains( + { + "image_format": image_result.format, + "image_content": real_file_content, + }, + { + "image_format": "WEBP", + "image_content": parametrized_file_input_data.processed_content, + }, + ) + else: + assert real_file_content == parametrized_file_input_data.processed_content await file.delete() +CONTENT_TYPES_AND_FILE_EXTENSIONS: list[tuple[str, str]] = [ + ("image/avif", "avif"), + ("image/bmp", "bmp"), + ("image/gif", "gif"), + ("image/x-icon", "ico"), + ("image/jpeg", "jpe"), + ("image/jpeg", "jpeg"), + ("image/jpeg", "jpg"), + ("image/jpx", "jpx"), + ("image/png", "png"), + ("image/tiff", "tif"), + ("image/tiff", "tiff"), + ("image/webp", "webp"), +] + + +@pytest.mark.parametrize( + "file_input_data", + [ + pytest.param(lf("webp_image_file_input_data"), id="webp"), + pytest.param(lf("png_image_file_input_data"), id="png"), + ], +) +async def test_image_file_uploading_content_type_mismatch( + faker: Faker, + authorized_client: TestClient, + file_upload_storage_token: str, + file_input_data: FileInputData, +) -> None: + content_type, file_extension = random.choice( + [ + (content_type, file_extension) + for content_type, file_extension in CONTENT_TYPES_AND_FILE_EXTENSIONS + if content_type != file_input_data.content_type + ] + ) + + assert_response( + authorized_client.post( + "/api/protected/storage-service/v2/file-kinds/image/files/", + headers={"X-Storage-Token": file_upload_storage_token}, + files={ + "upload": ( + faker.file_name(extension=file_extension), + file_input_data.input_content, + content_type, + ) + }, + ), + expected_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + expected_json={"detail": "File content doesn't match the content-type header"}, + ) + + async def test_image_file_uploading_wrong_content_format( + faker: Faker, authorized_client: TestClient, uncategorized_file_content: bytes, - image_file_input_data: FileInputData, file_upload_storage_token: str, ) -> None: assert_response( @@ -90,9 +169,9 @@ async def test_image_file_uploading_wrong_content_format( headers={"X-Storage-Token": file_upload_storage_token}, files={ "upload": ( - image_file_input_data.name, + faker.file_name(extension="webp"), uncategorized_file_content, - image_file_input_data.content_type, + "image/webp", ) }, ), @@ -150,7 +229,7 @@ async def test_file_uploading_invalid_token( files={ "upload": ( parametrized_file_input_data.name, - parametrized_file_input_data.content, + parametrized_file_input_data.input_content, parametrized_file_input_data.content_type, ) }, diff --git a/tests/subscriptions/__init__.py b/tests/subscriptions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/subscriptions/conftest.py b/tests/subscriptions/conftest.py new file mode 100644 index 00000000..ffab221a --- /dev/null +++ b/tests/subscriptions/conftest.py @@ -0,0 +1,41 @@ +import pytest +from faker import Faker + +from app.subscriptions.models.promocodes_db import Promocode +from tests.common.active_session import ActiveSession +from tests.common.types import AnyJSON +from tests.subscriptions import factories + + +@pytest.fixture() +async def promocode(active_session: ActiveSession, faker: Faker) -> Promocode: + async with active_session(): + return await Promocode.create( + **factories.LimitedPromocodeInputFactory.build_python( + code=faker.pystr(min_chars=10, max_chars=10), + ) + ) + + +@pytest.fixture() +async def promocode_data(promocode: Promocode) -> AnyJSON: + return Promocode.ResponseSchema.model_validate(promocode).model_dump(mode="json") + + +@pytest.fixture() +async def other_promocode(active_session: ActiveSession, faker: Faker) -> Promocode: + async with active_session(): + return await Promocode.create( + **factories.LimitedPromocodeInputFactory.build_python( + code=faker.pystr(min_chars=9, max_chars=9), + ) + ) + + +@pytest.fixture() +async def deleted_promocode( + active_session: ActiveSession, promocode: Promocode +) -> Promocode: + async with active_session(): + await promocode.delete() + return promocode diff --git a/tests/subscriptions/factories.py b/tests/subscriptions/factories.py new file mode 100644 index 00000000..7e58c38d --- /dev/null +++ b/tests/subscriptions/factories.py @@ -0,0 +1,39 @@ +from datetime import timezone + +from polyfactory import PostGenerated +from pydantic import AwareDatetime + +from app.subscriptions.models.promocodes_db import Promocode +from tests.common.polyfactory_ext import BaseModelFactory + + +class PromocodeInputSchema(Promocode.InputSchema): + valid_from: AwareDatetime + valid_until: AwareDatetime + + +class LimitedPromocodeInputFactory(BaseModelFactory[PromocodeInputSchema]): + __model__ = PromocodeInputSchema + + valid_until = PostGenerated( + lambda _, values: BaseModelFactory.__faker__.date_time_between( + start_date=values["valid_from"], tzinfo=timezone.utc + ) + ) + + +class UnlimitedPromocodeInputFactory(BaseModelFactory[Promocode.InputSchema]): + __model__ = Promocode.InputSchema + + valid_from = None + valid_until = None + + +class InvalidPeriodPromocodeInputFactory(BaseModelFactory[PromocodeInputSchema]): + __model__ = PromocodeInputSchema + + valid_from = PostGenerated( + lambda _, values: BaseModelFactory.__faker__.date_time_between( + start_date=values["valid_until"], tzinfo=timezone.utc + ) + ) diff --git a/tests/subscriptions/functional/__init__.py b/tests/subscriptions/functional/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/subscriptions/functional/test_promocodes_list_mub.py b/tests/subscriptions/functional/test_promocodes_list_mub.py new file mode 100644 index 00000000..0117c4f1 --- /dev/null +++ b/tests/subscriptions/functional/test_promocodes_list_mub.py @@ -0,0 +1,69 @@ +from collections.abc import AsyncIterator + +import pytest +from faker import Faker +from starlette.testclient import TestClient + +from app.subscriptions.models.promocodes_db import Promocode +from tests.common.active_session import ActiveSession +from tests.common.assert_contains_ext import assert_response +from tests.subscriptions.factories import LimitedPromocodeInputFactory + +pytestmark = pytest.mark.anyio + +PROMOCODES_LIST_SIZE = 5 +MAX_CODE_CHAR = 10 + + +@pytest.fixture() +async def promocodes( + faker: Faker, + active_session: ActiveSession, +) -> AsyncIterator[list[Promocode]]: + async with active_session(): + promocodes: list[Promocode] = [ + await Promocode.create( + **LimitedPromocodeInputFactory.build_python( + code=faker.pystr(min_chars=i + 1, max_chars=i + 1) + ) + ) + for i in range(PROMOCODES_LIST_SIZE) + ] + + promocodes.sort(key=lambda promocode: promocode.created_at, reverse=True) + + yield promocodes + + async with active_session(): + for promocode in promocodes: + await promocode.delete() + + +@pytest.mark.parametrize( + ("offset", "limit"), + [ + pytest.param(0, PROMOCODES_LIST_SIZE, id="start_to_end"), + pytest.param( + PROMOCODES_LIST_SIZE // 2, PROMOCODES_LIST_SIZE, id="middle_to_end" + ), + pytest.param(0, PROMOCODES_LIST_SIZE // 2, id="start_to_middle"), + ], +) +async def test_promocodes_listing( + mub_client: TestClient, + promocodes: list[Promocode], + offset: int, + limit: int, +) -> None: + assert_response( + mub_client.get( + "/mub/subscription-service/promocodes/", + params={"offset": offset, "limit": limit}, + ), + expected_json=[ + Promocode.ResponseSchema.model_validate( + promocode, from_attributes=True + ).model_dump(mode="json") + for promocode in promocodes[offset:limit] + ], + ) diff --git a/tests/subscriptions/functional/test_promocodes_mub.py b/tests/subscriptions/functional/test_promocodes_mub.py new file mode 100644 index 00000000..7b9917de --- /dev/null +++ b/tests/subscriptions/functional/test_promocodes_mub.py @@ -0,0 +1,252 @@ +from typing import Any + +import pytest +from freezegun import freeze_time +from pytest_lazy_fixtures import lfc +from starlette import status +from starlette.testclient import TestClient + +from app.common.utils.datetime import datetime_utc_now +from app.subscriptions.models.promocodes_db import Promocode +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.subscriptions import factories + +pytestmark = pytest.mark.anyio + + +promocode_body_factory_parametrization = pytest.mark.parametrize( + "body_factory", + [ + pytest.param( + factories.LimitedPromocodeInputFactory, + id="limited_promocode", + ), + pytest.param( + factories.UnlimitedPromocodeInputFactory, + id="unlimited_promocode", + ), + ], +) + + +@promocode_body_factory_parametrization +@freeze_time() +async def test_promocode_creation( + active_session: ActiveSession, + mub_client: TestClient, + body_factory: type[BaseModelFactory[Any]], +) -> None: + promocode_input_data: AnyJSON = body_factory.build_json() + + promocode_id: int = assert_response( + mub_client.post( + "/mub/subscription-service/promocodes/", + json=promocode_input_data, + ), + expected_code=status.HTTP_201_CREATED, + expected_json={ + **promocode_input_data, + "id": int, + "created_at": datetime_utc_now(), + "updated_at": datetime_utc_now(), + }, + ).json()["id"] + + async with active_session(): + promocode = await Promocode.find_first_by_id(promocode_id) + assert promocode is not None + await promocode.delete() + + +async def test_promocode_creation_invalid_period( + mub_client: TestClient, +) -> None: + assert_response( + mub_client.post( + "/mub/subscription-service/promocodes/", + json=factories.InvalidPeriodPromocodeInputFactory.build_json(), + ), + expected_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + expected_json={ + "detail": [ + { + "type": "value_error", + "loc": ["body"], + "msg": "Value error, the end date cannot be earlier than the start date", + } + ] + }, + ) + + +async def test_promocode_creation_promocode_already_exists( + mub_client: TestClient, + other_promocode: Promocode, +) -> None: + assert_response( + mub_client.post( + "/mub/subscription-service/promocodes/", + json=factories.LimitedPromocodeInputFactory.build_json( + code=other_promocode.code + ), + ), + expected_code=status.HTTP_409_CONFLICT, + expected_json={"detail": "Promocode already exists"}, + ) + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + lfc(lambda promocode: f"by-id/{promocode.id}/"), + id="by_id", + ), + pytest.param( + lfc(lambda promocode: f"by-code/{promocode.code}/"), + id="by_code", + ), + ], +) +async def test_promocode_retrieving( + mub_client: TestClient, + promocode_data: AnyJSON, + path: str, +) -> None: + assert_response( + mub_client.get( + f"/mub/subscription-service/promocodes/{path}", + ), + expected_json=promocode_data, + ) + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + lfc(lambda deleted_promocode: f"by-id/{deleted_promocode.id}/"), + id="by_id", + ), + pytest.param( + lfc(lambda deleted_promocode: f"by-code/{deleted_promocode.code}/"), + id="by_code", + ), + ], +) +async def test_promocode_retrieving_promocode_not_found( + mub_client: TestClient, + path: str, +) -> None: + assert_response( + mub_client.get( + f"/mub/subscription-service/promocodes/{path}", + ), + expected_code=status.HTTP_404_NOT_FOUND, + expected_json={"detail": "Promocode not found"}, + ) + + +@promocode_body_factory_parametrization +@freeze_time() +async def test_promocode_updating( + mub_client: TestClient, + promocode: Promocode, + promocode_data: AnyJSON, + body_factory: type[BaseModelFactory[Any]], +) -> None: + promocode_put_data = body_factory.build_json() + + assert_response( + mub_client.put( + f"/mub/subscription-service/promocodes/{promocode.id}/", + json=promocode_put_data, + ), + expected_json={ + **promocode_data, + **promocode_put_data, + "updated_at": datetime_utc_now(), + }, + ) + + +async def test_promocode_updating_invalid_period( + mub_client: TestClient, + promocode: Promocode, +) -> None: + assert_response( + mub_client.put( + f"/mub/subscription-service/promocodes/{promocode.id}/", + json=factories.InvalidPeriodPromocodeInputFactory.build_json(), + ), + expected_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + expected_json={ + "detail": [ + { + "type": "value_error", + "loc": ["body"], + "msg": "Value error, the end date cannot be earlier than the start date", + } + ] + }, + ) + + +async def test_promocode_updating_promocode_already_exists( + mub_client: TestClient, + promocode: Promocode, + other_promocode: Promocode, +) -> None: + assert_response( + mub_client.put( + f"/mub/subscription-service/promocodes/{promocode.id}/", + json=factories.LimitedPromocodeInputFactory.build_json( + code=other_promocode.code + ), + ), + expected_code=status.HTTP_409_CONFLICT, + expected_json={"detail": "Promocode already exists"}, + ) + + +async def test_promocode_deleting( + active_session: ActiveSession, + mub_client: TestClient, + promocode: Promocode, +) -> None: + assert_nodata_response( + mub_client.delete(f"/mub/subscription-service/promocodes/{promocode.id}/"), + ) + + async with active_session(): + assert await Promocode.find_first_by_id(promocode.id) is None + + +@pytest.mark.parametrize( + ("method", "body_factory"), + [ + pytest.param("PUT", factories.LimitedPromocodeInputFactory, id="put"), + pytest.param("DELETE", None, id="delete"), + ], +) +async def test_promocode_not_finding( + mub_client: TestClient, + deleted_promocode: Promocode, + method: str, + body_factory: type[BaseModelFactory[Any]] | None, +) -> None: + assert_response( + mub_client.request( + method, + f"/mub/subscription-service/promocodes/{deleted_promocode.id}/", + json=body_factory and body_factory.build_json(), + ), + expected_code=status.HTTP_404_NOT_FOUND, + expected_json={"detail": "Promocode not found"}, + ) diff --git a/tests/users/functional/test_avatars.py b/tests/users/functional/test_avatars.py index 8fd3247b..7c89e5e6 100644 --- a/tests/users/functional/test_avatars.py +++ b/tests/users/functional/test_avatars.py @@ -1,48 +1,95 @@ from collections.abc import AsyncIterator +from io import BytesIO import pytest from faker import Faker +from PIL import Image +from pydantic_marshals.contains import assert_contains +from pytest_lazy_fixtures import lfc from starlette import status from starlette.testclient import TestClient from app.users.models.users_db import User from tests.common.assert_contains_ext import assert_nodata_response, assert_response +from tests.common.types import PytestRequest pytestmark = pytest.mark.anyio +@pytest.fixture( + params=[ + pytest.param(lfc(lambda faker: faker.graphic_webp_file(raw=True)), id="webp"), + pytest.param(lfc(lambda faker: faker.graphic_png_file(raw=True)), id="png"), + ] +) +def image_content(request: PytestRequest[bytes]) -> bytes: + return request.param + + @pytest.fixture() -async def image(faker: Faker) -> bytes: - return faker.graphic_webp_file(raw=True) # type: ignore[no-any-return] +def processed_image_content(image_content: bytes) -> bytes: + image: Image.Image = Image.open(BytesIO(image_content)) + image = image.resize(User.avatar_shape) + + processed_image_buffer = BytesIO() + image.save(processed_image_buffer, format="webp") + + processed_image_buffer.seek(0) + return processed_image_buffer.read() @pytest.fixture() -async def _create_avatar(user: User, image: bytes) -> AsyncIterator[None]: +async def create_avatar(faker: Faker, user: User) -> AsyncIterator[None]: with user.avatar_path.open("wb") as f: - f.write(image) + f.write(faker.graphic_webp_file(raw=True)) yield user.avatar_path.unlink(missing_ok=True) async def test_avatar_uploading( - authorized_client: TestClient, user: User, image: bytes + authorized_client: TestClient, + user: User, + image_content: bytes, + processed_image_content: bytes, ) -> None: assert_nodata_response( authorized_client.put( "/api/protected/user-service/users/current/avatar/", - files={"avatar": ("avatar.webp", image, "image/webp")}, + files={"avatar": ("avatar.webp", image_content, "image/webp")}, ) ) assert user.avatar_path.is_file() with user.avatar_path.open("rb") as f: - assert f.read() == image + real_image_content = f.read() + + image_result = Image.open(BytesIO(real_image_content)) + try: + image_result.verify() + except Exception as e: + raise AssertionError("Invalid resulting image") from e + + assert_contains( + { + "image_format": image_result.format, + "image_width": image_result.width, + "image_height": image_result.height, + "image_content": real_image_content, + }, + { + "image_format": "WEBP", + "image_width": User.avatar_shape[0], + "image_height": User.avatar_shape[1], + "image_content": processed_image_content, + }, + ) user.avatar_path.unlink() async def test_avatar_uploading_wrong_format( - authorized_client: TestClient, faker: Faker + faker: Faker, + authorized_client: TestClient, ) -> None: assert_response( authorized_client.put( @@ -54,25 +101,51 @@ async def test_avatar_uploading_wrong_format( ) -@pytest.mark.usefixtures("_create_avatar") +@pytest.mark.usefixtures("create_avatar") async def test_avatar_replacing( - authorized_client: TestClient, user: User, faker: Faker + authorized_client: TestClient, + user: User, + image_content: bytes, + processed_image_content: bytes, ) -> None: - image_2 = faker.graphic_webp_file(raw=True) assert_nodata_response( authorized_client.put( "/api/protected/user-service/users/current/avatar/", - files={"avatar": ("avatar.webp", image_2, "image/webp")}, + files={"avatar": ("avatar.webp", image_content, "image/webp")}, ) ) assert user.avatar_path.is_file() with user.avatar_path.open("rb") as f: - assert f.read() == image_2 + real_image_content = f.read() + + image_result = Image.open(BytesIO(real_image_content)) + try: + image_result.verify() + except Exception as e: + raise AssertionError("Invalid resulting image") from e + + assert_contains( + { + "image_format": image_result.format, + "image_width": image_result.width, + "image_height": image_result.height, + "image_content": real_image_content, + }, + { + "image_format": "WEBP", + "image_width": User.avatar_shape[0], + "image_height": User.avatar_shape[1], + "image_content": processed_image_content, + }, + ) -@pytest.mark.usefixtures("_create_avatar") -async def test_avatar_deletion(authorized_client: TestClient, user: User) -> None: +@pytest.mark.usefixtures("create_avatar") +async def test_avatar_deletion( + authorized_client: TestClient, + user: User, +) -> None: assert_nodata_response( authorized_client.delete("/api/protected/user-service/users/current/avatar/") ) @@ -80,9 +153,10 @@ async def test_avatar_deletion(authorized_client: TestClient, user: User) -> Non assert not user.avatar_path.is_file() -@pytest.mark.usefixtures("_create_avatar") +@pytest.mark.usefixtures("create_avatar") async def test_mub_user_deletion_with_avatar( - mub_client: TestClient, user: User + mub_client: TestClient, + user: User, ) -> None: assert_nodata_response(mub_client.delete(f"/mub/user-service/users/{user.id}/"))