From 115f922b4356fc78dc8bec705f90c1a744b5726d Mon Sep 17 00:00:00 2001 From: Artyom Date: Tue, 17 Sep 2024 21:08:08 +0300 Subject: [PATCH] Showing blocks in schedule + minor fixes --- fanfan/application/events/common.py | 112 -------------- .../application/events/get_current_event.py | 2 +- fanfan/application/events/get_event_by_id.py | 4 +- .../application/events/get_schedule_page.py | 4 +- fanfan/application/schedule_mgmt/__init__.py | 0 fanfan/application/schedule_mgmt/common.py | 2 + .../{events => schedule_mgmt}/move_event.py | 14 +- .../set_current_event.py | 18 ++- .../set_next_event.py | 6 +- .../{events => schedule_mgmt}/skip_event.py | 15 +- .../schedule_mgmt/utils/__init__.py | 0 .../utils/prepare_notifications.py | 137 ++++++++++++++++++ fanfan/common/__init__.py | 2 + fanfan/common/config.py | 3 + .../templates/global_announcement.jinja2 | 2 + .../subscription_notification.jinja2 | 15 ++ fanfan/core/models/block.py | 8 + fanfan/core/models/event.py | 3 + .../db/migrations/versions/016_block.py | 38 +++++ fanfan/infrastructure/db/models/block.py | 19 +++ fanfan/infrastructure/db/models/event.py | 26 +++- fanfan/infrastructure/di/__init__.py | 2 + fanfan/infrastructure/di/interactors.py | 14 +- fanfan/infrastructure/di/jinja.py | 18 +++ fanfan/presentation/tgbot/__init__.py | 1 - .../tgbot/dialogs/schedule/event_details.py | 4 +- .../tgbot/dialogs/schedule/move_event.py | 2 +- .../tgbot/dialogs/schedule/view_schedule.py | 3 +- .../tgbot/static/templates/__init__.py | 4 +- .../templates/global_announcement.jinja2 | 2 - .../static/templates/schedule_list.jinja2 | 8 + .../templates/selected_event_info.jinja2 | 15 +- .../subscription_notification.jinja2 | 13 -- uv.lock | 42 +++--- 34 files changed, 375 insertions(+), 183 deletions(-) delete mode 100644 fanfan/application/events/common.py create mode 100644 fanfan/application/schedule_mgmt/__init__.py create mode 100644 fanfan/application/schedule_mgmt/common.py rename fanfan/application/{events => schedule_mgmt}/move_event.py (88%) rename fanfan/application/{events => schedule_mgmt}/set_current_event.py (86%) rename fanfan/application/{events => schedule_mgmt}/set_next_event.py (90%) rename fanfan/application/{events => schedule_mgmt}/skip_event.py (83%) create mode 100644 fanfan/application/schedule_mgmt/utils/__init__.py create mode 100644 fanfan/application/schedule_mgmt/utils/prepare_notifications.py create mode 100644 fanfan/common/static/templates/global_announcement.jinja2 create mode 100644 fanfan/common/static/templates/subscription_notification.jinja2 create mode 100644 fanfan/core/models/block.py create mode 100644 fanfan/infrastructure/db/migrations/versions/016_block.py create mode 100644 fanfan/infrastructure/db/models/block.py create mode 100644 fanfan/infrastructure/di/jinja.py delete mode 100644 fanfan/presentation/tgbot/static/templates/global_announcement.jinja2 delete mode 100644 fanfan/presentation/tgbot/static/templates/subscription_notification.jinja2 diff --git a/fanfan/application/events/common.py b/fanfan/application/events/common.py deleted file mode 100644 index e5c6ab4..0000000 --- a/fanfan/application/events/common.py +++ /dev/null @@ -1,112 +0,0 @@ -from datetime import datetime - -from aiogram.utils.keyboard import InlineKeyboardBuilder -from jinja2 import Environment, FileSystemLoader -from pytz import timezone -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import joinedload - -from fanfan.common.config import get_config -from fanfan.core.models.notification import UserNotification -from fanfan.infrastructure.db.models import Event, Subscription, User, UserSettings -from fanfan.infrastructure.db.queries.events import next_event_query -from fanfan.infrastructure.db.queries.subscriptions import upcoming_subscriptions_query -from fanfan.presentation.tgbot import JINJA_TEMPLATES_DIR -from fanfan.presentation.tgbot.keyboards.buttons import ( - OPEN_SUBSCRIPTIONS_BUTTON, - PULL_DOWN_DIALOG, -) - -ANNOUNCEMENT_TIMESTAMP = "announcement_timestamp" -ANNOUNCEMENT_LOCK = "announcement_lock" - - -async def prepare_notifications( - session: AsyncSession, - next_event_before: Event | None, - changed_events: list[Event | None], -) -> list[UserNotification]: - # Prepare - config = get_config() - time = datetime.now(tz=timezone(config.timezone)).strftime("%H:%M") - notifications: list[UserNotification] = [] - - # Setup Jinja - jinja = Environment( - lstrip_blocks=True, - trim_blocks=True, - loader=FileSystemLoader(searchpath=JINJA_TEMPLATES_DIR), - enable_async=True, - autoescape=True, - ) - subscription_template = jinja.get_template( - "subscription_notification.jinja2", - ) - global_announcement_template = jinja.get_template( - "global_announcement.jinja2", - ) - - # Get current and next event - current_event = await session.scalar(select(Event).where(Event.current.is_(True))) - if not current_event: - return notifications - next_event = await session.scalar(next_event_query()) - - # Preparing global notifications - if next_event != next_event_before: - text = await global_announcement_template.render_async( - { - "current_event": current_event, - "next_event": next_event, - }, - ) - users = await session.scalars( - select(User).where( - User.settings.has(UserSettings.receive_all_announcements.is_(True)) - ) - ) - notifications += [ - UserNotification( - user_id=u.id, - title=f"📢 НА СЦЕНЕ ({time})", - text=text, - reply_markup=InlineKeyboardBuilder( - [[OPEN_SUBSCRIPTIONS_BUTTON], [PULL_DOWN_DIALOG]], - ).as_markup(), - ) - for u in users - ] - - # Checking subscriptions - subscriptions = await session.scalars( - upcoming_subscriptions_query().options( - joinedload(Subscription.event).undefer(Event.queue) - ) - ) - for subscription in subscriptions: - notify = False - for e in changed_events: - if current_event.order <= e.order <= subscription.event.order: - notify = True - if notify: - text = await subscription_template.render_async( - { - "current": subscription.event is current_event, - "id": subscription.event.id, - "title": subscription.event.title, - "counter": subscription.event.queue - current_event.queue, - }, - ) - notifications.append( - UserNotification( - user_id=subscription.user_id, - title=f"📢 СКОРО НА СЦЕНЕ ({time})", - text=text, - reply_markup=InlineKeyboardBuilder( - [[OPEN_SUBSCRIPTIONS_BUTTON], [PULL_DOWN_DIALOG]], - ).as_markup(), - ), - ) - - return notifications diff --git a/fanfan/application/events/get_current_event.py b/fanfan/application/events/get_current_event.py index 16b7032..3205d82 100644 --- a/fanfan/application/events/get_current_event.py +++ b/fanfan/application/events/get_current_event.py @@ -15,7 +15,7 @@ async def __call__(self) -> FullEventDTO: event = await self.session.scalar( select(Event) .where(Event.current.is_(True)) - .options(joinedload(Event.nomination)) + .options(joinedload(Event.nomination), joinedload(Event.block)) ) if event: return event.to_full_dto() diff --git a/fanfan/application/events/get_event_by_id.py b/fanfan/application/events/get_event_by_id.py index 7e9c7fd..20e9a72 100644 --- a/fanfan/application/events/get_event_by_id.py +++ b/fanfan/application/events/get_event_by_id.py @@ -1,6 +1,6 @@ from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import contains_eager, joinedload, undefer +from sqlalchemy.orm import contains_eager, joinedload from fanfan.application.common.id_provider import IdProvider from fanfan.core.exceptions.events import EventNotFound @@ -20,7 +20,7 @@ async def __call__( query = ( select(Event) .where(Event.id == event_id) - .options(joinedload(Event.nomination), undefer(Event.queue)) + .options(joinedload(Event.nomination), joinedload(Event.block)) ) if self.id_provider.get_current_user_id(): diff --git a/fanfan/application/events/get_schedule_page.py b/fanfan/application/events/get_schedule_page.py index bc1ea76..05f187b 100644 --- a/fanfan/application/events/get_schedule_page.py +++ b/fanfan/application/events/get_schedule_page.py @@ -1,6 +1,6 @@ from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import contains_eager, joinedload, undefer +from sqlalchemy.orm import contains_eager, joinedload from fanfan.application.common.id_provider import IdProvider from fanfan.core.models.event import UserFullEventDTO @@ -22,7 +22,7 @@ async def __call__( query = ( select(Event) .order_by(Event.order) - .options(joinedload(Event.nomination), undefer(Event.queue)) + .options(joinedload(Event.nomination), joinedload(Event.block)) ) total_query = select(func.count(Event.id)) diff --git a/fanfan/application/schedule_mgmt/__init__.py b/fanfan/application/schedule_mgmt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fanfan/application/schedule_mgmt/common.py b/fanfan/application/schedule_mgmt/common.py new file mode 100644 index 0000000..5f97521 --- /dev/null +++ b/fanfan/application/schedule_mgmt/common.py @@ -0,0 +1,2 @@ +ANNOUNCEMENT_TIMESTAMP = "announcement_timestamp" +ANNOUNCEMENT_LOCK = "announcement_lock" diff --git a/fanfan/application/events/move_event.py b/fanfan/application/schedule_mgmt/move_event.py similarity index 88% rename from fanfan/application/events/move_event.py rename to fanfan/application/schedule_mgmt/move_event.py index f916475..d357031 100644 --- a/fanfan/application/events/move_event.py +++ b/fanfan/application/schedule_mgmt/move_event.py @@ -7,10 +7,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from fanfan.application.common.id_provider import IdProvider -from fanfan.application.events.common import ( +from fanfan.application.schedule_mgmt.common import ( ANNOUNCEMENT_LOCK, ANNOUNCEMENT_TIMESTAMP, - prepare_notifications, +) +from fanfan.application.schedule_mgmt.utils.prepare_notifications import ( + EventChangeDTO, + EventChangeType, + PrepareNotifications, ) from fanfan.core.exceptions.events import ( AnnounceTooFast, @@ -39,11 +43,13 @@ def __init__( session: AsyncSession, redis: Redis, id_provider: IdProvider, + prepare_notifications: PrepareNotifications, notifier: Notifier, ) -> None: self.session = session self.redis = redis self.id_provider = id_provider + self.prepare_notifications = prepare_notifications self.notifier = notifier async def __call__(self, event_id: int, after_event_id: int) -> MoveEventResult: @@ -85,10 +91,10 @@ async def __call__(self, event_id: int, after_event_id: int) -> MoveEventResult: await self.session.refresh(event) # Prepare notifications - notifications = await prepare_notifications( + notifications = await self.prepare_notifications( session=self.session, next_event_before=next_event, - changed_events=[event], + event_changes=[EventChangeDTO(event, EventChangeType.MOVE)], ) # Commit diff --git a/fanfan/application/events/set_current_event.py b/fanfan/application/schedule_mgmt/set_current_event.py similarity index 86% rename from fanfan/application/events/set_current_event.py rename to fanfan/application/schedule_mgmt/set_current_event.py index 3383861..a814121 100644 --- a/fanfan/application/events/set_current_event.py +++ b/fanfan/application/schedule_mgmt/set_current_event.py @@ -7,10 +7,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from fanfan.application.common.id_provider import IdProvider -from fanfan.application.events.common import ( +from fanfan.application.schedule_mgmt.common import ( ANNOUNCEMENT_LOCK, ANNOUNCEMENT_TIMESTAMP, - prepare_notifications, +) +from fanfan.application.schedule_mgmt.utils.prepare_notifications import ( + EventChangeDTO, + EventChangeType, + PrepareNotifications, ) from fanfan.core.exceptions.events import ( AnnounceTooFast, @@ -38,6 +42,7 @@ def __init__( self, session: AsyncSession, redis: Redis, + prepare_notifications: PrepareNotifications, notifier: Notifier, id_provider: IdProvider, ) -> None: @@ -74,15 +79,18 @@ async def __call__(self, event_id: int | None) -> SetCurrentEventResult: raise CurrentEventNotAllowed event.current = True await self.session.flush([event]) - await self.session.refresh(event) else: event = None # Prepare subscriptions - notifications = await prepare_notifications( + notifications = await self.prepare_notifications( session=self.session, next_event_before=next_event, - changed_events=[event], + event_changes=[ + EventChangeDTO(event, EventChangeType.SET_AS_CURRENT) + if isinstance(event, Event) + else None + ], ) await self.session.commit() diff --git a/fanfan/application/events/set_next_event.py b/fanfan/application/schedule_mgmt/set_next_event.py similarity index 90% rename from fanfan/application/events/set_next_event.py rename to fanfan/application/schedule_mgmt/set_next_event.py index c41a2c6..dc5aea6 100644 --- a/fanfan/application/events/set_next_event.py +++ b/fanfan/application/schedule_mgmt/set_next_event.py @@ -1,12 +1,14 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from fanfan.application.schedule_mgmt.set_current_event import ( + SetCurrentEvent, + SetCurrentEventResult, +) from fanfan.core.exceptions.events import EventNotFound, NoNextEvent from fanfan.infrastructure.db.models import Event from fanfan.infrastructure.db.queries.events import next_event_query -from .set_current_event import SetCurrentEvent, SetCurrentEventResult - class SetNextEvent: def __init__(self, session: AsyncSession, set_current_event: SetCurrentEvent): diff --git a/fanfan/application/events/skip_event.py b/fanfan/application/schedule_mgmt/skip_event.py similarity index 83% rename from fanfan/application/events/skip_event.py rename to fanfan/application/schedule_mgmt/skip_event.py index ad51b8a..d4ac70d 100644 --- a/fanfan/application/events/skip_event.py +++ b/fanfan/application/schedule_mgmt/skip_event.py @@ -6,10 +6,14 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from fanfan.application.events.common import ( +from fanfan.application.schedule_mgmt.common import ( ANNOUNCEMENT_LOCK, ANNOUNCEMENT_TIMESTAMP, - prepare_notifications, +) +from fanfan.application.schedule_mgmt.utils.prepare_notifications import ( + EventChangeDTO, + EventChangeType, + PrepareNotifications, ) from fanfan.core.exceptions.events import ( AnnounceTooFast, @@ -36,11 +40,13 @@ def __init__( self, session: AsyncSession, redis: Redis, + prepare_notifications: PrepareNotifications, notifier: Notifier, ) -> None: self.session = session self.redis = redis self.notifier = notifier + self.prepare_notifications = prepare_notifications async def __call__(self, event_id: int) -> SkipEventResult: async with self.session, self.redis.lock(ANNOUNCEMENT_LOCK, 10): @@ -64,15 +70,16 @@ async def __call__(self, event_id: int) -> SkipEventResult: next_event = await self.session.scalar(next_event_query()) # Toggle event skip + change_type = EventChangeType.UNSKIP if event.skip else EventChangeType.SKIP event.skip = not event.skip await self.session.flush([event]) await self.session.refresh(event) # Prepare subscriptions - notifications = await prepare_notifications( + notifications = await self.prepare_notifications( session=self.session, next_event_before=next_event, - changed_events=[event], + event_changes=[EventChangeDTO(event, change_type)], ) await self.session.commit() diff --git a/fanfan/application/schedule_mgmt/utils/__init__.py b/fanfan/application/schedule_mgmt/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fanfan/application/schedule_mgmt/utils/prepare_notifications.py b/fanfan/application/schedule_mgmt/utils/prepare_notifications.py new file mode 100644 index 0000000..54b5352 --- /dev/null +++ b/fanfan/application/schedule_mgmt/utils/prepare_notifications.py @@ -0,0 +1,137 @@ +import enum +from dataclasses import dataclass +from datetime import datetime + +from aiogram.utils.keyboard import InlineKeyboardBuilder +from jinja2 import Environment +from pytz import timezone +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload + +from fanfan.common.config import Configuration +from fanfan.core.models.notification import UserNotification +from fanfan.infrastructure.db.models import Event, Subscription, User, UserSettings +from fanfan.infrastructure.db.queries.events import next_event_query +from fanfan.infrastructure.db.queries.subscriptions import upcoming_subscriptions_query +from fanfan.presentation.tgbot.keyboards.buttons import ( + OPEN_SUBSCRIPTIONS_BUTTON, + PULL_DOWN_DIALOG, +) + + +class EventChangeType(enum.StrEnum): + SET_AS_CURRENT = "set_as_current" + MOVE = "move" + SKIP = "skip" + UNSKIP = "unskip" + + +@dataclass +class EventChangeDTO: + event: Event + type: EventChangeType + + +class PrepareNotifications: + def __init__( + self, session: AsyncSession, config: Configuration, jinja: Environment + ): + self.session = session + self.config = config + self.jinja = jinja + + async def __call__( + self, + session: AsyncSession, + next_event_before: Event | None, + event_changes: list[EventChangeDTO | None], + ) -> list[UserNotification]: + # Prepare + time = datetime.now(tz=timezone(self.config.timezone)).strftime("%H:%M") + notifications: list[UserNotification] = [] + + # Prepare templates + subscription_template = self.jinja.get_template( + "subscription_notification.jinja2", + ) + global_announcement_template = self.jinja.get_template( + "global_announcement.jinja2", + ) + + # Get current and next event + current_event = await session.scalar( + select(Event) + .where(Event.current.is_(True)) + .options(joinedload(Event.block)) + ) + if not current_event: + return notifications + next_event = await session.scalar( + next_event_query().options(joinedload(Event.block)) + ) + + # Preparing global notifications + if next_event != next_event_before: + text = await global_announcement_template.render_async( + { + "current_event": current_event, + "next_event": next_event, + }, + ) + users = await session.scalars( + select(User).where( + User.settings.has(UserSettings.receive_all_announcements.is_(True)) + ) + ) + notifications += [ + UserNotification( + user_id=u.id, + title=f"📢 НА СЦЕНЕ ({time})", + text=text, + reply_markup=InlineKeyboardBuilder( + [[OPEN_SUBSCRIPTIONS_BUTTON], [PULL_DOWN_DIALOG]], + ).as_markup(), + ) + for u in users + ] + + # Checking subscriptions + subscriptions = await session.scalars( + upcoming_subscriptions_query().options( + joinedload(Subscription.event).joinedload(Event.block) + ) + ) + for subscription in subscriptions: + reason: str | None = None + notify = False + for e in event_changes: + if current_event.order <= e.event.order <= subscription.event.order: + notify = True + match e.type: + case EventChangeType.MOVE: + reason = f"(Выступление №{e.event.id} было перемещено)" + case EventChangeType.SKIP: + reason = f"(Выступление №{e.event.id} было пропущено)" + case EventChangeType.UNSKIP: + reason = f"(Выступление №{e.event.id} вернулось)" + if notify: + text = await subscription_template.render_async( + { + "event": subscription.event, + "current_event": current_event, + }, + ) + notifications.append( + UserNotification( + user_id=subscription.user_id, + title=f"📢 СКОРО НА СЦЕНЕ ({time})", + text=text, + bottom_text=reason, + reply_markup=InlineKeyboardBuilder( + [[OPEN_SUBSCRIPTIONS_BUTTON], [PULL_DOWN_DIALOG]], + ).as_markup(), + ), + ) + + return notifications diff --git a/fanfan/common/__init__.py b/fanfan/common/__init__.py index 7ca148a..cf4121b 100644 --- a/fanfan/common/__init__.py +++ b/fanfan/common/__init__.py @@ -2,6 +2,8 @@ from pathlib import Path COMMON_STATIC_DIR = Path(__file__).parent.joinpath("static") +JINJA_TEMPLATES_DIR = COMMON_STATIC_DIR / "templates" + TEMP_DIR = Path(tempfile.gettempdir()) TEMP_DIR.mkdir(parents=True, exist_ok=True) diff --git a/fanfan/common/config.py b/fanfan/common/config.py index 24ca540..c7c3c09 100644 --- a/fanfan/common/config.py +++ b/fanfan/common/config.py @@ -1,5 +1,6 @@ import logging +from dotenv import find_dotenv, load_dotenv from pydantic import ( DirectoryPath, HttpUrl, @@ -18,6 +19,8 @@ from fanfan.core.enums import BotMode +load_dotenv(find_dotenv(".local-env")) + class BotConfig(BaseSettings): model_config = SettingsConfigDict(env_prefix="BOT_") diff --git a/fanfan/common/static/templates/global_announcement.jinja2 b/fanfan/common/static/templates/global_announcement.jinja2 new file mode 100644 index 0000000..29d9f24 --- /dev/null +++ b/fanfan/common/static/templates/global_announcement.jinja2 @@ -0,0 +1,2 @@ +{% if current_event %}Сейчас: {{ current_event.id }}. {{ current_event.title }} {% if current_event.block %}({{ current_event.block.title }}){% endif %}{% endif %} +{% if next_event %}{{ "\n" }}Затем: {{ next_event.id }}. {{ next_event.title }} {% if next_event.block %}({{ next_event.block.title }}){% endif %}{% endif %} \ No newline at end of file diff --git a/fanfan/common/static/templates/subscription_notification.jinja2 b/fanfan/common/static/templates/subscription_notification.jinja2 new file mode 100644 index 0000000..270fc84 --- /dev/null +++ b/fanfan/common/static/templates/subscription_notification.jinja2 @@ -0,0 +1,15 @@ +{% macro events_pluralize(count) %} +{% if (count % 10 == 1) and (count % 100 != 11) %} +выступление +{%- elif (2 <= count % 10 <= 4) and (count % 100 < 10 or count % 100 >= 20) %} +выступления +{%- else %} +выступлений +{%- endif %} +{% endmacro %} +{% if event == current_event %} +Выступление {{ event.id }}. {{ event.title }} {% if event.block %}({{ event.block.title }}){% endif %} НАЧАЛОСЬ! +{% else %} +{% set counter = event.queue - current_event.queue %} +До выступления {{ event.id }}. {{ event.title }} {% if event.block %}({{ event.block.title }}){% endif %} осталось {{ counter }} {{ events_pluralize(counter) }} +{%- endif -%} \ No newline at end of file diff --git a/fanfan/core/models/block.py b/fanfan/core/models/block.py new file mode 100644 index 0000000..e1894f5 --- /dev/null +++ b/fanfan/core/models/block.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass +class BlockDTO: + id: int + title: str + start_order: int diff --git a/fanfan/core/models/event.py b/fanfan/core/models/event.py index 736d76b..65070e8 100644 --- a/fanfan/core/models/event.py +++ b/fanfan/core/models/event.py @@ -2,6 +2,8 @@ from dataclasses import dataclass +from fanfan.core.models.block import BlockDTO + @dataclass(frozen=True, slots=True) class EventDTO: @@ -16,6 +18,7 @@ class EventDTO: @dataclass(frozen=True, slots=True) class FullEventDTO(EventDTO): nomination: NominationDTO | None + block: BlockDTO | None @dataclass(frozen=True, slots=True) diff --git a/fanfan/infrastructure/db/migrations/versions/016_block.py b/fanfan/infrastructure/db/migrations/versions/016_block.py new file mode 100644 index 0000000..d2d217b --- /dev/null +++ b/fanfan/infrastructure/db/migrations/versions/016_block.py @@ -0,0 +1,38 @@ +"""block + +Revision ID: 016 +Revises: 015 +Create Date: 2024-09-17 21:00:26.267496 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '016' +down_revision = '015' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('blocks', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('start_order', sa.Integer(), nullable=False), + sa.Column('created_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_on', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_blocks')), + sa.UniqueConstraint('start_order', name=op.f('uq_blocks_start_order')) + ) + op.drop_column('settings', 'announcement_timestamp') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('settings', sa.Column('announcement_timestamp', sa.DOUBLE_PRECISION(precision=53), server_default=sa.text("'0'::double precision"), autoincrement=False, nullable=False)) + op.drop_table('blocks') + # ### end Alembic commands ### diff --git a/fanfan/infrastructure/db/models/block.py b/fanfan/infrastructure/db/models/block.py new file mode 100644 index 0000000..eb2c37e --- /dev/null +++ b/fanfan/infrastructure/db/models/block.py @@ -0,0 +1,19 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from fanfan.core.models.block import BlockDTO +from fanfan.infrastructure.db.models.base import Base + + +class Block(Base): + __tablename__ = "blocks" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + title: Mapped[str] = mapped_column() + start_order: Mapped[int] = mapped_column(unique=True) + + def to_dto(self) -> BlockDTO: + return BlockDTO( + id=self.id, + title=self.title, + start_order=self.start_order, + ) diff --git a/fanfan/infrastructure/db/models/event.py b/fanfan/infrastructure/db/models/event.py index 49243b9..96705e4 100644 --- a/fanfan/infrastructure/db/models/event.py +++ b/fanfan/infrastructure/db/models/event.py @@ -13,6 +13,7 @@ from fanfan.core.models.event import EventDTO, FullEventDTO, UserFullEventDTO from fanfan.infrastructure.db.models.base import Base +from fanfan.infrastructure.db.models.block import Block from fanfan.infrastructure.db.models.mixins.order import OrderMixin if TYPE_CHECKING: @@ -49,7 +50,27 @@ class Event(Base, OrderMixin): @declared_attr @classmethod - def queue(cls) -> Mapped[int]: + def block(cls) -> Mapped[Block | None]: + subquery = select( + cls.id, + select(Block.id) + .where(cls.order >= Block.start_order) + .order_by(Block.start_order.desc()) + .limit(1) + .label("block_id"), + ).subquery() + return relationship( + Block, + primaryjoin=(cls.id == subquery.c.id), + secondaryjoin=(Block.id == subquery.c.block_id), + secondary=subquery, + uselist=False, + viewonly=True, + ) + + @declared_attr + @classmethod + def queue(cls) -> Mapped[int | None]: queue_subquery = select( cls.id, func.row_number() @@ -85,6 +106,7 @@ def to_dto(self) -> EventDTO: def to_full_dto(self) -> FullEventDTO: self.nomination: Nomination + self.block: Block return FullEventDTO( id=self.id, title=self.title, @@ -92,6 +114,7 @@ def to_full_dto(self) -> FullEventDTO: skip=self.skip, queue=self.queue, nomination=self.nomination.to_dto() if self.nomination else None, + block=self.block.to_dto() if self.block else None, ) def to_user_full_dto(self) -> UserFullEventDTO: @@ -104,6 +127,7 @@ def to_user_full_dto(self) -> UserFullEventDTO: skip=self.skip, queue=self.queue, nomination=self.nomination.to_dto() if self.nomination else None, + block=self.block.to_dto() if self.block else None, subscription=self.user_subscription.to_dto() if self.user_subscription else None, diff --git a/fanfan/infrastructure/di/__init__.py b/fanfan/infrastructure/di/__init__.py index 4bb2d41..c318416 100644 --- a/fanfan/infrastructure/di/__init__.py +++ b/fanfan/infrastructure/di/__init__.py @@ -10,6 +10,7 @@ from fanfan.infrastructure.di.config import ConfigProvider from fanfan.infrastructure.di.db import DbProvider from fanfan.infrastructure.di.interactors import InteractorsProvider +from fanfan.infrastructure.di.jinja import JinjaProvider from fanfan.infrastructure.di.redis import RedisProvider from fanfan.infrastructure.di.timepad import TimepadProvider @@ -25,6 +26,7 @@ def get_common_providers() -> list[Provider]: DpProvider(), RedisProvider(), TimepadProvider(), + JinjaProvider(), ] diff --git a/fanfan/infrastructure/di/interactors.py b/fanfan/infrastructure/di/interactors.py index 797a7b4..802b249 100644 --- a/fanfan/infrastructure/di/interactors.py +++ b/fanfan/infrastructure/di/interactors.py @@ -11,10 +11,6 @@ from fanfan.application.events.get_event_by_id import GetEventById from fanfan.application.events.get_page_number_by_event import GetPageNumberByEvent from fanfan.application.events.get_schedule_page import GetSchedulePage -from fanfan.application.events.move_event import MoveEvent -from fanfan.application.events.set_current_event import SetCurrentEvent -from fanfan.application.events.set_next_event import SetNextEvent -from fanfan.application.events.skip_event import SkipEvent from fanfan.application.feedback.send_feedback import SendFeedback from fanfan.application.mailing.create_mailing import CreateMailing from fanfan.application.mailing.delete_mailing import DeleteMailing @@ -26,6 +22,13 @@ ) from fanfan.application.participants.get_participants_page import GetParticipantsPage from fanfan.application.quest.get_user_stats import GetUserStats +from fanfan.application.schedule_mgmt.move_event import MoveEvent +from fanfan.application.schedule_mgmt.set_current_event import SetCurrentEvent +from fanfan.application.schedule_mgmt.set_next_event import SetNextEvent +from fanfan.application.schedule_mgmt.skip_event import SkipEvent +from fanfan.application.schedule_mgmt.utils.prepare_notifications import ( + PrepareNotifications, +) from fanfan.application.settings.get_settings import GetSettings from fanfan.application.settings.update_settings import UpdateSettings from fanfan.application.subscriptions.create_subscription import CreateSubscription @@ -62,10 +65,13 @@ class InteractorsProvider(Provider): get_event_by_id = provide(GetEventById) get_page_number_by_event = provide(GetPageNumberByEvent) get_schedule_page = provide(GetSchedulePage) + move_event = provide(MoveEvent) set_current_event = provide(SetCurrentEvent) set_next_event = provide(SetNextEvent) skip_event = provide(SkipEvent) + # Might be not the best place for this + _prepare_notifications = provide(PrepareNotifications) send_feedback = provide(SendFeedback) diff --git a/fanfan/infrastructure/di/jinja.py b/fanfan/infrastructure/di/jinja.py new file mode 100644 index 0000000..4538c6c --- /dev/null +++ b/fanfan/infrastructure/di/jinja.py @@ -0,0 +1,18 @@ +from dishka import Provider, Scope, provide +from jinja2 import Environment, FileSystemLoader + +from fanfan.common import JINJA_TEMPLATES_DIR + + +class JinjaProvider(Provider): + scope = Scope.APP + + @provide + def provide_jinja_env(self) -> Environment: + return Environment( + lstrip_blocks=True, + trim_blocks=True, + loader=FileSystemLoader(searchpath=JINJA_TEMPLATES_DIR), + enable_async=True, + autoescape=True, + ) diff --git a/fanfan/presentation/tgbot/__init__.py b/fanfan/presentation/tgbot/__init__.py index 3d1362f..a540e27 100644 --- a/fanfan/presentation/tgbot/__init__.py +++ b/fanfan/presentation/tgbot/__init__.py @@ -6,4 +6,3 @@ UI_IMAGES_DIR = UI_DIR / "images" STATIC_DIR = BOT_ROOT_DIR / "static" -JINJA_TEMPLATES_DIR = STATIC_DIR / "templates" diff --git a/fanfan/presentation/tgbot/dialogs/schedule/event_details.py b/fanfan/presentation/tgbot/dialogs/schedule/event_details.py index 6e79e16..a5fec61 100644 --- a/fanfan/presentation/tgbot/dialogs/schedule/event_details.py +++ b/fanfan/presentation/tgbot/dialogs/schedule/event_details.py @@ -7,8 +7,8 @@ from dishka import AsyncContainer from fanfan.application.events.get_event_by_id import GetEventById -from fanfan.application.events.set_current_event import SetCurrentEvent -from fanfan.application.events.skip_event import SkipEvent +from fanfan.application.schedule_mgmt.set_current_event import SetCurrentEvent +from fanfan.application.schedule_mgmt.skip_event import SkipEvent from fanfan.application.subscriptions.delete_subscription import DeleteSubscription from fanfan.application.subscriptions.get_subscription_by_event import ( GetSubscriptionByEvent, diff --git a/fanfan/presentation/tgbot/dialogs/schedule/move_event.py b/fanfan/presentation/tgbot/dialogs/schedule/move_event.py index fa7e5f5..87f3a02 100644 --- a/fanfan/presentation/tgbot/dialogs/schedule/move_event.py +++ b/fanfan/presentation/tgbot/dialogs/schedule/move_event.py @@ -7,7 +7,7 @@ from aiogram_dialog.widgets.kbd import SwitchTo from aiogram_dialog.widgets.text import Const, Jinja -from fanfan.application.events.move_event import MoveEvent +from fanfan.application.schedule_mgmt.move_event import MoveEvent from fanfan.core.exceptions.base import AppException from fanfan.core.utils.pluralize import NOTIFICATIONS_PLURALS, pluralize from fanfan.presentation.tgbot import states diff --git a/fanfan/presentation/tgbot/dialogs/schedule/view_schedule.py b/fanfan/presentation/tgbot/dialogs/schedule/view_schedule.py index 17dcd79..857ba2d 100644 --- a/fanfan/presentation/tgbot/dialogs/schedule/view_schedule.py +++ b/fanfan/presentation/tgbot/dialogs/schedule/view_schedule.py @@ -17,7 +17,7 @@ ) from aiogram_dialog.widgets.text import Const, Jinja -from fanfan.application.events.set_next_event import SetNextEvent +from fanfan.application.schedule_mgmt.set_next_event import SetNextEvent from fanfan.core.exceptions.base import AppException from fanfan.core.utils.pluralize import NOTIFICATIONS_PLURALS, pluralize from fanfan.presentation.tgbot import states @@ -99,6 +99,7 @@ async def schedule_text_input_handler( schedule_main_window = Window( Title(Const(strings.titles.schedule)), Jinja(schedule_list), + Const("👆 Нажми на номер, чтобы выбрать выступление"), TextInput( id="schedule_main_window_text_input", type_factory=str, diff --git a/fanfan/presentation/tgbot/static/templates/__init__.py b/fanfan/presentation/tgbot/static/templates/__init__.py index 659da46..f025611 100644 --- a/fanfan/presentation/tgbot/static/templates/__init__.py +++ b/fanfan/presentation/tgbot/static/templates/__init__.py @@ -1,8 +1,8 @@ -from fanfan.presentation.tgbot import JINJA_TEMPLATES_DIR +from pathlib import Path def load_template(template_filename: str) -> str: - return JINJA_TEMPLATES_DIR.joinpath(template_filename).read_text(encoding="utf-8") + return Path(__file__).parent.joinpath(template_filename).read_text(encoding="utf-8") achievements_list = load_template("achievements_list.jinja2") diff --git a/fanfan/presentation/tgbot/static/templates/global_announcement.jinja2 b/fanfan/presentation/tgbot/static/templates/global_announcement.jinja2 deleted file mode 100644 index 6e7c969..0000000 --- a/fanfan/presentation/tgbot/static/templates/global_announcement.jinja2 +++ /dev/null @@ -1,2 +0,0 @@ -{% if current_event %}Сейчас: {{ current_event.id }}. {{ current_event.title }}{% endif %} -{% if next_event %}{{ "\n" }}Затем: {{ next_event.id }}. {{ next_event.title }}{% endif %} \ No newline at end of file diff --git a/fanfan/presentation/tgbot/static/templates/schedule_list.jinja2 b/fanfan/presentation/tgbot/static/templates/schedule_list.jinja2 index 1d2b201..67d2248 100644 --- a/fanfan/presentation/tgbot/static/templates/schedule_list.jinja2 +++ b/fanfan/presentation/tgbot/static/templates/schedule_list.jinja2 @@ -4,12 +4,20 @@ {% for event in events %} {% if event.nomination %} {% if loop.previtem %} + {% if event.block != loop.previtem.block %} +📆 {{event.block.title}} + {% endif %} {% if event.nomination != loop.previtem.nomination %} ➡️ {{event.nomination.title}} {% endif %} {% else %} + {% if event.block %} +📆 {{event.block.title}} + {% endif %} + {% if event.nomination %} ➡️ {{event.nomination.title}} {% endif %} + {% endif %} {% endif %} {% if event.skip %}{% endif %} {% if event.current %}{% endif %} diff --git a/fanfan/presentation/tgbot/static/templates/selected_event_info.jinja2 b/fanfan/presentation/tgbot/static/templates/selected_event_info.jinja2 index 5ca9932..140b2f6 100644 --- a/fanfan/presentation/tgbot/static/templates/selected_event_info.jinja2 +++ b/fanfan/presentation/tgbot/static/templates/selected_event_info.jinja2 @@ -8,13 +8,22 @@ {%- endif %} {% endmacro %}
{{ selected_event.id }}. {{ selected_event.title }}
+ +{% if selected_event.block %} +📆 Блок: {{ selected_event.block.title }} + +{% endif %} +{% if selected_event.nomination %} +💃 Номинация: {{ selected_event.nomination.title }} + +{% endif %} {% if selected_event.queue and current_event %} {% set queue_difference = selected_event.queue - current_event.queue %} {% if queue_difference < 0 %} -Закончилось {{ queue_difference|abs }} {{events_pluralize(queue_difference)}} назад ⌛ +⌛ Закончилось {{ queue_difference|abs }} {{events_pluralize(queue_difference)}} назад {% elif queue_difference == 0 %} -Выступление идёт ПРЯМО СЕЙЧАС 🔴 +🔴 Выступление идёт ПРЯМО СЕЙЧАС {% elif queue_difference > 0 %} -До начала осталось {{ queue_difference }} {{events_pluralize(queue_difference)}} ⌚ +⌚ До начала: {{ queue_difference }} {{events_pluralize(queue_difference)}} {% endif %} {% endif %} \ No newline at end of file diff --git a/fanfan/presentation/tgbot/static/templates/subscription_notification.jinja2 b/fanfan/presentation/tgbot/static/templates/subscription_notification.jinja2 deleted file mode 100644 index 6d84451..0000000 --- a/fanfan/presentation/tgbot/static/templates/subscription_notification.jinja2 +++ /dev/null @@ -1,13 +0,0 @@ -{% macro events_pluralize(count) %} -{% if (count % 10 == 1) and (count % 100 != 11) %} -выступление -{%- elif (2 <= count % 10 <= 4) and (count % 100 < 10 or count % 100 >= 20) %} -выступления -{%- else %} -выступлений -{%- endif %} -{% endmacro %} -{% if current %} -Выступление {{ id }}. {{ title }} НАЧАЛОСЬ!{% else %} -До выступления {{ id }}. {{ title }} осталось {{ counter }} {{ events_pluralize(counter) }} -{%- endif %} \ No newline at end of file diff --git a/uv.lock b/uv.lock index 7652601..45bfb52 100644 --- a/uv.lock +++ b/uv.lock @@ -203,30 +203,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.35.19" +version = "1.35.20" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/85/d4119201c65b56a2bcc8dc328db98cd1c769a2376aea1613a6f5e8f2a88b/boto3-1.35.19.tar.gz", hash = "sha256:9979fe674780a0b7100eae9156d74ee374cd1638a9f61c77277e3ce712f3e496", size = 108623 } +sdist = { url = "https://files.pythonhosted.org/packages/3c/e8/b996b0c863ae0b00a04de42143a123fd1f3c7c7d9810f3be8f3fe3cc1c62/boto3-1.35.20.tar.gz", hash = "sha256:47e89d95964f10beee21ee723c3290874fddf364269bd97d200e8bfa9bf93a06", size = 108603 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/45/606a55c6922ef53589d5f7a1963bbbcd10bae393b1109b9889db1d049c0c/boto3-1.35.19-py3-none-any.whl", hash = "sha256:84b3fe1727945bc3cada832d969ddb3dc0d08fce1677064ca8bdc13a89c1a143", size = 139160 }, + { url = "https://files.pythonhosted.org/packages/c4/c9/50e6fb7e29d81210821cb9e7510c6221fc1c4334701e384ee488033dc707/boto3-1.35.20-py3-none-any.whl", hash = "sha256:aaddbeb8c37608492f2c8286d004101464833d4c6e49af44601502b8b18785ed", size = 139157 }, ] [[package]] name = "botocore" -version = "1.35.19" +version = "1.35.20" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/e4/d0114af2ec0495c7b415b5a739a3f5b7c35a4e0b7ecfd1a7ea533606f834/botocore-1.35.19.tar.gz", hash = "sha256:42d6d8db7250cbd7899f786f9861e02cab17dc238f64d6acb976098ed9809625", size = 12749447 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/68/123156df0dc1734bab0efece4a35c5dbc6c1826e5f5bf0cb1b2b45f1344b/botocore-1.35.20.tar.gz", hash = "sha256:82ad8a73fcd5852d127461c8dadbe40bf679f760a4efb0dde8d4d269ad3f126f", size = 12750996 } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/d1/abce9c410813be6f134c30b2bfd08fd94a4f10fbc606de44101ab0a5dcb4/botocore-1.35.19-py3-none-any.whl", hash = "sha256:c83f7f0cacfe7c19b109b363ebfa8736e570d24922f16ed371681f58ebab44a9", size = 12537563 }, + { url = "https://files.pythonhosted.org/packages/c0/2e/ca478472e3a6cc96a23dcaf82af714e2befbf449aec98974bf0ac2c88102/botocore-1.35.20-py3-none-any.whl", hash = "sha256:62412038f960691a299e60492f9ee7e8e75af563f2eca7f3640b3b54b8f5d236", size = 12539792 }, ] [[package]] @@ -493,11 +493,11 @@ wheels = [ [[package]] name = "idna" -version = "3.9" +version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/6f/93e724eafe34e860d15d37a4f72a1511dd37c43a76a8671b22a15029d545/idna-3.9.tar.gz", hash = "sha256:e5c5dafde284f26e9e0f28f6ea2d6400abd5ca099864a67f576f3981c6476124", size = 191636 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/15/61933d1999bc5ad8cad612d67f02fa5b16a423076ea0816e39c2e797af12/idna-3.9-py3-none-any.whl", hash = "sha256:69297d5da0cc9281c77efffb4e730254dd45943f45bbfb461de5991713989b1e", size = 71671 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] [[package]] @@ -1049,23 +1049,23 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.34" +version = "2.0.35" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "(python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64')" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/fa/ca0fdd7b6b0cf53a8237a8ee7e487f8be16e4a2ee6d840d6e8e105cd9c86/sqlalchemy-2.0.34.tar.gz", hash = "sha256:10d8f36990dd929690666679b0f42235c159a7051534adb135728ee52828dd22", size = 9556527 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/76/62eb5c62593d6d351f17202aa532f17b91c51b1b04e24a3a97530cb6118e/SQLAlchemy-2.0.34-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:53e68b091492c8ed2bd0141e00ad3089bcc6bf0e6ec4142ad6505b4afe64163e", size = 2089191 }, - { url = "https://files.pythonhosted.org/packages/8a/7c/d43a14aef45bcb196f017ba2783eb3e42dd4c65c43be8b9f29bb5ec7d131/SQLAlchemy-2.0.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bcd18441a49499bf5528deaa9dee1f5c01ca491fc2791b13604e8f972877f812", size = 2079662 }, - { url = "https://files.pythonhosted.org/packages/b7/25/ec59e5d3643d49d57ae59a62b6e5b3da39344617ce249f2561bfb4ac0458/SQLAlchemy-2.0.34-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:165bbe0b376541092bf49542bd9827b048357f4623486096fc9aaa6d4e7c59a2", size = 3229161 }, - { url = "https://files.pythonhosted.org/packages/fd/2e/e6129761dd5588a5623c6051c31e45935b72a5b17ed87b209e39a0b2a25c/SQLAlchemy-2.0.34-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3330415cd387d2b88600e8e26b510d0370db9b7eaf984354a43e19c40df2e2b", size = 3240054 }, - { url = "https://files.pythonhosted.org/packages/70/08/4f994445215d7932bf2a490570fef9a5d1ba42cdf1cc9c48a6f7f04d1cfc/SQLAlchemy-2.0.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97b850f73f8abbffb66ccbab6e55a195a0eb655e5dc74624d15cff4bfb35bd74", size = 3175538 }, - { url = "https://files.pythonhosted.org/packages/5e/19/4d4cc024cd7d50e25bf1c1ba61974b2b6e2fab8ea22f1569c47380b34e95/SQLAlchemy-2.0.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee4c6917857fd6121ed84f56d1dc78eb1d0e87f845ab5a568aba73e78adf83", size = 3202149 }, - { url = "https://files.pythonhosted.org/packages/87/02/7ada4b6bfd5421aa7d65bd0ee9d76acc15b53ae26378b2ab8bba1ba3f78f/SQLAlchemy-2.0.34-cp312-cp312-win32.whl", hash = "sha256:fbb034f565ecbe6c530dff948239377ba859420d146d5f62f0271407ffb8c580", size = 2059547 }, - { url = "https://files.pythonhosted.org/packages/ad/fc/d1315ddb8529c768789954350268cd53167747649ddb709517c5e0a15c7e/SQLAlchemy-2.0.34-cp312-cp312-win_amd64.whl", hash = "sha256:707c8f44931a4facd4149b52b75b80544a8d824162602b8cd2fe788207307f9a", size = 2085274 }, - { url = "https://files.pythonhosted.org/packages/09/14/5c9b872fba29ccedeb905d0a5c203ad86287b8bb1bb8eda96bfe8a05f65b/SQLAlchemy-2.0.34-py3-none-any.whl", hash = "sha256:7286c353ee6475613d8beff83167374006c6b3e3f0e6491bfe8ca610eb1dec0f", size = 1880671 }, +sdist = { url = "https://files.pythonhosted.org/packages/36/48/4f190a83525f5cefefa44f6adc9e6386c4de5218d686c27eda92eb1f5424/sqlalchemy-2.0.35.tar.gz", hash = "sha256:e11d7ea4d24f0a262bccf9a7cd6284c976c5369dac21db237cff59586045ab9f", size = 9562798 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/2b/fff87e6db0da31212c98bbc445f83fb608ea92b96bda3f3f10e373bac76c/SQLAlchemy-2.0.35-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:eb60b026d8ad0c97917cb81d3662d0b39b8ff1335e3fabb24984c6acd0c900a2", size = 2089790 }, + { url = "https://files.pythonhosted.org/packages/68/92/4bb761bd82764d5827bf6b6095168c40fb5dbbd23670203aef2f96ba6bc6/SQLAlchemy-2.0.35-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6921ee01caf375363be5e9ae70d08ce7ca9d7e0e8983183080211a062d299468", size = 2080266 }, + { url = "https://files.pythonhosted.org/packages/22/46/068a65db6dc253c6f25a7598d99e0a1d60b14f661f9d09ef6c73c718fa4e/SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cdf1a0dbe5ced887a9b127da4ffd7354e9c1a3b9bb330dce84df6b70ccb3a8d", size = 3229760 }, + { url = "https://files.pythonhosted.org/packages/6e/36/59830dafe40dda592304debd4cd86e583f63472f3a62c9e2695a5795e786/SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93a71c8601e823236ac0e5d087e4f397874a421017b3318fd92c0b14acf2b6db", size = 3240649 }, + { url = "https://files.pythonhosted.org/packages/00/50/844c50c6996f9c7f000c959dd1a7436a6c94e449ee113046a1d19e470089/SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e04b622bb8a88f10e439084486f2f6349bf4d50605ac3e445869c7ea5cf0fa8c", size = 3176138 }, + { url = "https://files.pythonhosted.org/packages/df/d2/336b18cac68eecb67de474fc15c85f13be4e615c6f5bae87ea38c6734ce0/SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1b56961e2d31389aaadf4906d453859f35302b4eb818d34a26fab72596076bb8", size = 3202753 }, + { url = "https://files.pythonhosted.org/packages/f0/f3/ee1e62fabdc10910b5ef720ae08e59bc785f26652876af3a50b89b97b412/SQLAlchemy-2.0.35-cp312-cp312-win32.whl", hash = "sha256:0f9f3f9a3763b9c4deb8c5d09c4cc52ffe49f9876af41cc1b2ad0138878453cf", size = 2060113 }, + { url = "https://files.pythonhosted.org/packages/60/63/a3cef44a52979169d884f3583d0640e64b3c28122c096474a1d7cfcaf1f3/SQLAlchemy-2.0.35-cp312-cp312-win_amd64.whl", hash = "sha256:25b0f63e7fcc2a6290cb5f7f5b4fc4047843504983a28856ce9b35d8f7de03cc", size = 2085839 }, + { url = "https://files.pythonhosted.org/packages/0e/c6/33c706449cdd92b1b6d756b247761e27d32230fd6b2de5f44c4c3e5632b2/SQLAlchemy-2.0.35-py3-none-any.whl", hash = "sha256:2ab3f0336c0387662ce6221ad30ab3a5e6499aab01b9790879b6578fd9b8faa1", size = 1881276 }, ] [[package]]