Init project
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
BIRTHDAY_POOL_BOT__REPOSITORY__DSN=sqlite+aiosqlite:///db.sqlite3
|
||||||
|
BIRTHDAY_POOL_BOT__TELEGRAM_BOT__TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Databases
|
||||||
|
db.sqlite3
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.13-slim AS core
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN pip install uv && apt update && apt install -y git
|
||||||
|
COPY pyproject.toml uv.lock README.md /app
|
||||||
|
COPY birthday_pool_bot /app
|
||||||
|
ENTRYPOINT ["uv", "run"]
|
||||||
|
|
||||||
|
|
||||||
|
FROM core AS app
|
||||||
|
|
||||||
|
RUN uv sync --group sqlite
|
||||||
|
ENTRYPOINT ["uv", "run", "python", "-m", "birthday_pool_bot"]
|
||||||
|
|
||||||
|
|
||||||
|
FROM core AS test
|
||||||
|
|
||||||
|
RUN uv sync --all-groups
|
||||||
0
birthday_pool_bot/__init__.py
Normal file
0
birthday_pool_bot/__init__.py
Normal file
6
birthday_pool_bot/__main__.py
Normal file
6
birthday_pool_bot/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from .cli import get_cli
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli = get_cli()
|
||||||
|
cli()
|
||||||
53
birthday_pool_bot/cli.py
Normal file
53
birthday_pool_bot/cli.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import asyncio
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .notifications import get_cli as get_notifications_cli
|
||||||
|
from .repositories import RepositoriesContainer
|
||||||
|
from .repositories.migrations import get_cli as get_migrations_cli
|
||||||
|
from .service import BirthdayPoolBotService
|
||||||
|
from .settings import Settings
|
||||||
|
from .telegram_bot import get_cli as get_telegram_bot_cli
|
||||||
|
|
||||||
|
|
||||||
|
def callback(
|
||||||
|
ctx: typer.Context,
|
||||||
|
env: pathlib.Path | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--env", "-e",
|
||||||
|
help="`.env` config file location",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
ctx.obj = ctx.obj or {}
|
||||||
|
|
||||||
|
if (settings := ctx.obj.get("settings")) is None:
|
||||||
|
if env is not None:
|
||||||
|
ctx.obj["settings"] = Settings(_env_file=env)
|
||||||
|
else:
|
||||||
|
ctx.obj["settings"] = Settings()
|
||||||
|
settings = ctx.obj["settings"]
|
||||||
|
|
||||||
|
ctx.obj["repositories_container"] = RepositoriesContainer(settings=settings.repository)
|
||||||
|
|
||||||
|
|
||||||
|
def run(ctx: typer.Context):
|
||||||
|
settings: Settings = ctx.obj["settings"]
|
||||||
|
|
||||||
|
birthday_pool_bot_service = BirthdayPoolBotService(
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(birthday_pool_bot_service.run())
|
||||||
|
|
||||||
|
|
||||||
|
def get_cli() -> typer.Typer:
|
||||||
|
cli = typer.Typer()
|
||||||
|
|
||||||
|
cli.callback()(callback)
|
||||||
|
cli.add_typer(get_migrations_cli(), name="migrations")
|
||||||
|
cli.add_typer(get_telegram_bot_cli(), name="telegram-bot")
|
||||||
|
cli.add_typer(get_notifications_cli(), name="notifications")
|
||||||
|
cli.command(name="run")(run)
|
||||||
|
|
||||||
|
return cli
|
||||||
74
birthday_pool_bot/dto.py
Normal file
74
birthday_pool_bot/dto.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from pydantic_extra_types.phone_numbers import PhoneNumberValidator
|
||||||
|
from pydantic_filters import BaseFilter
|
||||||
|
|
||||||
|
|
||||||
|
PhoneNumber = Annotated[str, PhoneNumberValidator(number_format="E164")]
|
||||||
|
|
||||||
|
|
||||||
|
class BankEnum(enum.Enum):
|
||||||
|
T_BANK = "t-bank"
|
||||||
|
SBER = "sber"
|
||||||
|
ALFA_BANK = "alfa-bank"
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentData(BaseModel):
|
||||||
|
phone: PhoneNumber
|
||||||
|
bank: BankEnum
|
||||||
|
|
||||||
|
|
||||||
|
class User(BaseModel):
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
||||||
|
name: str | None = None
|
||||||
|
birthday: date | None = None
|
||||||
|
gift_payment_data: PaymentData | None = None
|
||||||
|
phone: PhoneNumber | None = None
|
||||||
|
telegram_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserFilter(BaseFilter):
|
||||||
|
id: set[uuid.UUID]
|
||||||
|
id__gt: uuid.UUID
|
||||||
|
phone: set[PhoneNumber]
|
||||||
|
telegram_id: set[int]
|
||||||
|
|
||||||
|
|
||||||
|
class Pool(BaseModel):
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
||||||
|
owner_id: uuid.UUID
|
||||||
|
birthday_user_id: uuid.UUID
|
||||||
|
description: str | None = None
|
||||||
|
payment_data: PaymentData
|
||||||
|
|
||||||
|
owner: User | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PoolFilter(BaseFilter):
|
||||||
|
id: set[uuid.UUID]
|
||||||
|
id__gt: uuid.UUID
|
||||||
|
owner_id: set[uuid.UUID]
|
||||||
|
birthday_user_id: set[uuid.UUID]
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription(BaseModel):
|
||||||
|
from_user_id: uuid.UUID
|
||||||
|
to_user_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
pool_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
from_user: User | None = None
|
||||||
|
to_user: User | None = None
|
||||||
|
pool: Pool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionFilter(BaseFilter):
|
||||||
|
from_user_id: set[uuid.UUID]
|
||||||
|
from_user_id__gt: uuid.UUID
|
||||||
|
to_user_id: set[uuid.UUID]
|
||||||
|
to_user_id__gt: uuid.UUID
|
||||||
|
pool_id: set[uuid.UUID]
|
||||||
65
birthday_pool_bot/interfaces.py
Normal file
65
birthday_pool_bot/interfaces.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import AsyncContextManager, AsyncGenerator, Protocol
|
||||||
|
|
||||||
|
from birthday_pool_bot.dto import Pool, User
|
||||||
|
|
||||||
|
|
||||||
|
class MessengerInterface(Protocol):
|
||||||
|
async def send_message(self, chat_id: str, message: str):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class MigratorInterface(Protocol):
|
||||||
|
def get_migrations_directory_path(self) -> pathlib.Path:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_current_migration(self) -> str | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_latest_migration(self) -> str | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_migrations(self) -> Generator[str, None, None]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def create_migration(
|
||||||
|
self,
|
||||||
|
migration_id: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def migrate(self, migration_id: str | None = None, **kwargs) -> str | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def rollback(self, migration_id: str | None = None, **kwargs) -> str | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def squash_migrations(
|
||||||
|
self,
|
||||||
|
new_migration_id: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryInterface(Protocol):
|
||||||
|
def transaction(self) -> AsyncContextManager:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class CacheInterface(Protocol):
|
||||||
|
async def set(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: Any,
|
||||||
|
ttl: timedelta | None = None,
|
||||||
|
):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get(self, key: str) -> Any:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def delete(self, key: str):
|
||||||
|
raise NotImplementedError
|
||||||
13
birthday_pool_bot/notifications/__init__.py
Normal file
13
birthday_pool_bot/notifications/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from .cli import get_cli
|
||||||
|
from .service import NotificationsService
|
||||||
|
from .settings import NotificationsSettings
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
# cli
|
||||||
|
"get_cli",
|
||||||
|
# service
|
||||||
|
"NotificationsService",
|
||||||
|
# settings
|
||||||
|
"NotificationsSettings",
|
||||||
|
)
|
||||||
44
birthday_pool_bot/notifications/cli.py
Normal file
44
birthday_pool_bot/notifications/cli.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .service import NotificationsService
|
||||||
|
from .settings import NotificationsSettings
|
||||||
|
|
||||||
|
|
||||||
|
def callback(ctx: typer.Context):
|
||||||
|
ctx.obj["settings"] = ctx.obj["settings"].notifications
|
||||||
|
|
||||||
|
|
||||||
|
def run(ctx: typer.Context):
|
||||||
|
settings: NotificationsSettings = ctx.obj["settings"]
|
||||||
|
repositories_container: RepositoriesContainer = ctx.obj["repositories_container"]
|
||||||
|
|
||||||
|
service = NotificationsService(
|
||||||
|
settings=settings,
|
||||||
|
repositories_container=repositories_container,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(service.run())
|
||||||
|
|
||||||
|
|
||||||
|
def run_once(ctx: typer.Context):
|
||||||
|
settings: NotificationsSettings = ctx.obj["settings"]
|
||||||
|
repositories_container: RepositoriesContainer = ctx.obj["repositories_container"]
|
||||||
|
|
||||||
|
service = NotificationsService(
|
||||||
|
settings=settings,
|
||||||
|
repositories_container=repositories_container,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(service.send_notifications())
|
||||||
|
|
||||||
|
|
||||||
|
def get_cli() -> typer.Typer:
|
||||||
|
cli = typer.Typer()
|
||||||
|
|
||||||
|
cli.callback()(callback)
|
||||||
|
cli.command(name="run")(run)
|
||||||
|
cli.command(name="run-once")(run_once)
|
||||||
|
|
||||||
|
return cli
|
||||||
81
birthday_pool_bot/notifications/service.py
Normal file
81
birthday_pool_bot/notifications/service.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import facet
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
from .settings import NotificationsSettings
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsService(facet.AsyncioServiceMixin):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: NotificationsSettings,
|
||||||
|
repositories_container: RepositoriesContainer,
|
||||||
|
):
|
||||||
|
self._settings = settings
|
||||||
|
self._repositories_container = repositories_container
|
||||||
|
self._bot = Bot(token=self._settings.telegram_bot_token)
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
scheduler = AsyncIOScheduler()
|
||||||
|
scheduler.add_job(
|
||||||
|
func=self.send_notifications,
|
||||||
|
name="send_notifications",
|
||||||
|
trigger=CronTrigger.from_crontab(self._settings.cron),
|
||||||
|
)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
async def send_notifications(self):
|
||||||
|
birthday = date.today() + timedelta(days=2)
|
||||||
|
|
||||||
|
users_repository = self._repositories_container.users
|
||||||
|
subscriptions_repository = self._repositories_container.subscriptions
|
||||||
|
|
||||||
|
async with users_repository.transaction():
|
||||||
|
users = await users_repository.get_users_by_birthdays(
|
||||||
|
birthday=birthday,
|
||||||
|
)
|
||||||
|
if not users:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with subscriptions_repository.transaction():
|
||||||
|
subscriptions = await subscriptions_repository.get_to_users_subscriptions(
|
||||||
|
to_users_ids={user.id for user in users},
|
||||||
|
with_from_user=True,
|
||||||
|
with_to_user=True,
|
||||||
|
with_pool=True,
|
||||||
|
)
|
||||||
|
if not subscriptions:
|
||||||
|
return
|
||||||
|
|
||||||
|
for subscription in subscriptions:
|
||||||
|
if subscription.from_user.telegram_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"У {subscription.name} скоро день рождения: *{birthday.strftime('%d %B')}*\n\n"
|
||||||
|
)
|
||||||
|
if subscription.pool is not None:
|
||||||
|
if subscription.pool.owner_id == subscription.from_user.id:
|
||||||
|
text += "Вы собираете деньги на подарок\n\n"
|
||||||
|
else:
|
||||||
|
text += (
|
||||||
|
"Вы подписаны на сбор денег для подарка, вы можете отправить их сюда:\n"
|
||||||
|
f"_Телефон_: {subscription.pool.payment_data.phone}\n"
|
||||||
|
f"_Банк_: {subscription.pool.payment_data.bank.value}\n\n"
|
||||||
|
)
|
||||||
|
elif subscription.to_user.gift_payment_data is not None:
|
||||||
|
text += (
|
||||||
|
f"Вы можете отправить именнинику свой подарок сюда:\n"
|
||||||
|
f"_Телефон_: {subscription.to_user.gift_payment_data.phone}\n"
|
||||||
|
f"_Банк_: {subscription.to_user.gift_payment_data.bank.value}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._bot.send_message(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
chat_id=subscription.from_user.telegram_id,
|
||||||
|
)
|
||||||
6
birthday_pool_bot/notifications/settings.py
Normal file
6
birthday_pool_bot/notifications/settings.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsSettings(BaseModel):
|
||||||
|
cron: str = "0 12 * * *"
|
||||||
|
telegram_bot_token: str = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
14
birthday_pool_bot/repositories/__init__.py
Normal file
14
birthday_pool_bot/repositories/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from .container import RepositoriesContainer
|
||||||
|
from .exceptions import AlreadyExistsError, HaveNoSessionError
|
||||||
|
from .settings import RepositorySettings
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
# container
|
||||||
|
"RepositoriesContainer",
|
||||||
|
# exceptions
|
||||||
|
"AlreadyExistsError",
|
||||||
|
"HaveNoSessionError",
|
||||||
|
# settings
|
||||||
|
"RepositorySettings",
|
||||||
|
)
|
||||||
150
birthday_pool_bot/repositories/base.py
Normal file
150
birthday_pool_bot/repositories/base.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import contextvars
|
||||||
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from pydantic_filters.drivers.sqlalchemy import append_to_statement
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlmodel import delete, insert, select, update
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from birthday_pool_bot.interfaces import RepositoryInterface
|
||||||
|
|
||||||
|
|
||||||
|
DTOType = TypeVar("DTOType")
|
||||||
|
FilterType = TypeVar("FilterType")
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRepository(Generic[DTOType, FilterType]):
|
||||||
|
def __init__(self, settings: RepositorySettings):
|
||||||
|
self._settings = settings
|
||||||
|
|
||||||
|
engine_parameters = {
|
||||||
|
"url": str(self._settings.dsn),
|
||||||
|
"pool_recycle": self._settings.pool_recycle,
|
||||||
|
}
|
||||||
|
if not str(self._settings.dsn).startswith("sqlite"):
|
||||||
|
engine_parameters["pool_timeout"] = self._settings.pool_timeout
|
||||||
|
engine_parameters["pool_size"] = self._settings.pool_size
|
||||||
|
|
||||||
|
self._engine = create_async_engine(**engine_parameters)
|
||||||
|
self._session_context = contextvars.ContextVar("session_context")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session(self) -> AsyncSession:
|
||||||
|
session = self._session_context.get(None)
|
||||||
|
if session is None:
|
||||||
|
raise HaveNoSessionError()
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction(self) -> AsyncGenerator[None, None]:
|
||||||
|
session_args = {"bind": self._engine, "expire_on_commit": False}
|
||||||
|
async with AsyncSession(**session_args) as session:
|
||||||
|
with self._set_session_to_session_context(session=session):
|
||||||
|
async with session.begin():
|
||||||
|
yield
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _set_session_to_session_context(self, session: AsyncSession) -> Generator[None, None, None]:
|
||||||
|
token = self._session_context.set(session)
|
||||||
|
yield
|
||||||
|
self._session_context.reset(token)
|
||||||
|
|
||||||
|
def get_db_table(self) -> type[BaseSQLModel]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_items_count(
|
||||||
|
self,
|
||||||
|
filter_: FilterType | None = None,
|
||||||
|
) -> int:
|
||||||
|
table = self.get_db_table()
|
||||||
|
statement = select(func.count())
|
||||||
|
statement = append_to_statement(
|
||||||
|
statement=statement,
|
||||||
|
model=table,
|
||||||
|
filter_=filter_,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.session.exec(statement)
|
||||||
|
items_count = result.first()
|
||||||
|
|
||||||
|
return items_count
|
||||||
|
|
||||||
|
async def get_items(
|
||||||
|
self,
|
||||||
|
filter_: FilterType | None = None,
|
||||||
|
pagination: BasePagination | None = None,
|
||||||
|
sort: BaseSort | None = None,
|
||||||
|
options: list | None = None,
|
||||||
|
) -> AsyncGenerator[DTOType]:
|
||||||
|
table = self.get_db_table()
|
||||||
|
statement = select(table)
|
||||||
|
statement = append_to_statement(
|
||||||
|
statement=statement,
|
||||||
|
model=table,
|
||||||
|
filter_=filter_,
|
||||||
|
pagination=pagination,
|
||||||
|
sort=sort,
|
||||||
|
)
|
||||||
|
if options:
|
||||||
|
statement = statement.options(*options)
|
||||||
|
|
||||||
|
result = await self.session.exec(statement)
|
||||||
|
db_items = result.all()
|
||||||
|
|
||||||
|
for db_item in db_items:
|
||||||
|
yield db_item.to_item()
|
||||||
|
|
||||||
|
async def create_item(self, item: DTOType) -> DTOType:
|
||||||
|
table = self.get_db_table()
|
||||||
|
values = table.from_item(item=item).to_values()
|
||||||
|
statement = insert(table).values(values).returning(table)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.session.exec(statement)
|
||||||
|
except IntegrityError:
|
||||||
|
raise AlreadyExistsError(model=table, values=values)
|
||||||
|
db_item = result.first()[0]
|
||||||
|
|
||||||
|
return db_item.to_item()
|
||||||
|
|
||||||
|
async def update_items(
|
||||||
|
self,
|
||||||
|
filter_: FilterType | None = None,
|
||||||
|
**values,
|
||||||
|
) -> AsyncGenerator[DTOType]:
|
||||||
|
table = self.get_db_table()
|
||||||
|
statement = update(table)
|
||||||
|
statement = append_to_statement(
|
||||||
|
statement=statement,
|
||||||
|
model=table,
|
||||||
|
filter_=filter_,
|
||||||
|
)
|
||||||
|
statement = statement.values(**values).returning(table)
|
||||||
|
|
||||||
|
result = await self.session.exec(statement)
|
||||||
|
db_items = result.all()
|
||||||
|
|
||||||
|
for db_item, *_ in db_items:
|
||||||
|
yield db_item.to_item()
|
||||||
|
|
||||||
|
async def delete_items(
|
||||||
|
self,
|
||||||
|
filter_: FilterType | None = None,
|
||||||
|
pagination: BasePagination | None = None,
|
||||||
|
sort: BaseSort | None = None,
|
||||||
|
):
|
||||||
|
table = self.get_db_table()
|
||||||
|
statement = delete(table)
|
||||||
|
statement = append_to_statement(
|
||||||
|
statement=statement,
|
||||||
|
model=table,
|
||||||
|
filter_=filter_,
|
||||||
|
pagination=pagination,
|
||||||
|
sort=sort,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.session.exec(statement)
|
||||||
25
birthday_pool_bot/repositories/container.py
Normal file
25
birthday_pool_bot/repositories/container.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from .repositories import (
|
||||||
|
PoolsRepository,
|
||||||
|
SubscriptionsRepository,
|
||||||
|
UsersRepository,
|
||||||
|
)
|
||||||
|
from .settings import RepositorySettings
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoriesContainer:
|
||||||
|
def __init__(self, settings: RepositorySettings):
|
||||||
|
self._pools_repository = PoolsRepository(settings=settings)
|
||||||
|
self._subscriptions_repository = SubscriptionsRepository(settings=settings)
|
||||||
|
self._users_repository = UsersRepository(settings=settings)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pools(self) -> PoolsRepository:
|
||||||
|
return self._pools_repository
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subscriptions(self) -> SubscriptionsRepository:
|
||||||
|
return self._subscriptions_repository
|
||||||
|
|
||||||
|
@property
|
||||||
|
def users(self) -> UsersRepository:
|
||||||
|
return self._users_repository
|
||||||
20
birthday_pool_bot/repositories/exceptions.py
Normal file
20
birthday_pool_bot/repositories/exceptions.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HaveNoSessionError(RepositoryException):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("Have no actual session")
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyExistsError(RepositoryException):
|
||||||
|
def __init__(self, model: SQLModel, values: dict[str, Any]):
|
||||||
|
super().__init__(f"Record for model '{model.__name__}' already exists")
|
||||||
|
|
||||||
|
self.model = model
|
||||||
|
self.values = values
|
||||||
10
birthday_pool_bot/repositories/migrations/__init__.py
Normal file
10
birthday_pool_bot/repositories/migrations/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from .cli import get_cli
|
||||||
|
from .migrator import Migrator
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
# cli
|
||||||
|
"get_cli",
|
||||||
|
# migrator
|
||||||
|
"Migrator",
|
||||||
|
)
|
||||||
75
birthday_pool_bot/repositories/migrations/cli.py
Normal file
75
birthday_pool_bot/repositories/migrations/cli.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from birthday_pool_bot.settings import Settings
|
||||||
|
|
||||||
|
from .migrator import Migrator
|
||||||
|
|
||||||
|
|
||||||
|
def callback(ctx: typer.Context):
|
||||||
|
ctx.obj = ctx.obj or {}
|
||||||
|
|
||||||
|
settings = ctx.obj["settings"]
|
||||||
|
ctx.obj["migrator"] = Migrator(settings=settings.repository)
|
||||||
|
|
||||||
|
|
||||||
|
def show_migrations_list(ctx: typer.Context):
|
||||||
|
migrator: Migrator = ctx.obj["migrator"]
|
||||||
|
|
||||||
|
table = Table("ID")
|
||||||
|
for migration_id in migrator.get_migrations():
|
||||||
|
table.add_row(migration_id)
|
||||||
|
Console().print(table)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_migrations(ctx: typer.Context):
|
||||||
|
migrator: Migrator = ctx.obj["migrator"]
|
||||||
|
|
||||||
|
migrator.migrate()
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_migrations(
|
||||||
|
ctx: typer.Context,
|
||||||
|
revision: Optional[str] = typer.Argument(
|
||||||
|
None,
|
||||||
|
help="Revision id or relative revision (`-1`, `-2`)",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
migrator: Migrator = ctx.obj["migrator"]
|
||||||
|
|
||||||
|
migrator.rollback(migration_id=revision)
|
||||||
|
|
||||||
|
|
||||||
|
def squash_migrations(ctx: typer.Context):
|
||||||
|
migrator: Migrator = ctx.obj["migrator"]
|
||||||
|
|
||||||
|
migrator.squash_migrations(new_message="init")
|
||||||
|
|
||||||
|
|
||||||
|
def create_migration(
|
||||||
|
ctx: typer.Context,
|
||||||
|
message: Optional[str] = typer.Option(
|
||||||
|
None,
|
||||||
|
"-m", "--message",
|
||||||
|
help="Migration short message",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
migrator: Migrator = ctx.obj["migrator"]
|
||||||
|
|
||||||
|
migrator.create_migration(message=message)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cli() -> typer.Typer:
|
||||||
|
cli = typer.Typer(name="Migration")
|
||||||
|
|
||||||
|
cli.callback()(callback)
|
||||||
|
cli.command(name="apply")(apply_migrations)
|
||||||
|
cli.command(name="rollback")(rollback_migrations)
|
||||||
|
cli.command(name="create")(create_migration)
|
||||||
|
cli.command(name="squash")(squash_migrations)
|
||||||
|
cli.command(name="list")(show_migrations_list)
|
||||||
|
|
||||||
|
return cli
|
||||||
93
birthday_pool_bot/repositories/migrations/env.py
Normal file
93
birthday_pool_bot/repositories/migrations/env.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from birthday_pool_bot.repositories.tables import User
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
target_metadata = User.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
"""In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode."""
|
||||||
|
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
146
birthday_pool_bot/repositories/migrations/migrator.py
Normal file
146
birthday_pool_bot/repositories/migrations/migrator.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
from types import ModuleType
|
||||||
|
from typing import Generator, Iterable
|
||||||
|
|
||||||
|
from alembic import command as alembic_command
|
||||||
|
from alembic.config import Config as AlembicConfig
|
||||||
|
|
||||||
|
from birthday_pool_bot.repositories import RepositorySettings
|
||||||
|
|
||||||
|
|
||||||
|
class Migrator:
|
||||||
|
MIGRATION_FILENAME_TEMPLATE = (
|
||||||
|
"%%(year)d_%%(month)02d_%%(day)02d_%%(hour)02dh%%(minute)02dm%%(second)02ds_%%(slug)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, settings: RepositorySettings):
|
||||||
|
self._settings = settings
|
||||||
|
|
||||||
|
self._alembic_config = self._generate_alembic_config()
|
||||||
|
|
||||||
|
def _generate_alembic_config(self) -> AlembicConfig:
|
||||||
|
migrations_path = self.get_migrations_directory_path()
|
||||||
|
|
||||||
|
config = AlembicConfig()
|
||||||
|
config.set_main_option("script_location", str(migrations_path))
|
||||||
|
config.set_main_option("file_template", self.MIGRATION_FILENAME_TEMPLATE)
|
||||||
|
config.set_main_option("sqlalchemy.url", str(self._settings.dsn).replace("%", "%%"))
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def squash_migrations(
|
||||||
|
self,
|
||||||
|
new_migration_id: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
new_message = kwargs.get("new_message", None)
|
||||||
|
|
||||||
|
if new_migration_id is None:
|
||||||
|
last_migration_id = self.get_latest_migration()
|
||||||
|
new_migration_id = last_migration_id
|
||||||
|
|
||||||
|
self.rollback(migration_id="base")
|
||||||
|
self._delete_all_migrations()
|
||||||
|
new_migration_id = self.create_migration(
|
||||||
|
migration_id=new_migration_id,
|
||||||
|
message=new_message,
|
||||||
|
)
|
||||||
|
return new_migration_id
|
||||||
|
|
||||||
|
def migrate(self, migration_id: str | None = None, **kwargs) -> str | None:
|
||||||
|
alembic_revision = migration_id or "head"
|
||||||
|
alembic_command.upgrade(config=self._alembic_config, revision=alembic_revision)
|
||||||
|
|
||||||
|
return self.get_current_migration()
|
||||||
|
|
||||||
|
def rollback(self, migration_id: str | None = None, **kwargs) -> str | None:
|
||||||
|
alembic_revision = migration_id or "-1"
|
||||||
|
alembic_command.downgrade(config=self._alembic_config, revision=alembic_revision)
|
||||||
|
|
||||||
|
return self.get_current_migration()
|
||||||
|
|
||||||
|
def get_current_migration(self) -> str | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_migration(
|
||||||
|
self,
|
||||||
|
migration_id: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
message = kwargs.get("message", None)
|
||||||
|
|
||||||
|
alembic_command.revision(
|
||||||
|
self._alembic_config,
|
||||||
|
message=message,
|
||||||
|
rev_id=migration_id,
|
||||||
|
autogenerate=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_migration_id = self.get_latest_migration()
|
||||||
|
if last_migration_id is None:
|
||||||
|
raise RuntimeError("Migration not created")
|
||||||
|
|
||||||
|
return last_migration_id
|
||||||
|
|
||||||
|
def get_latest_migration(self) -> str | None:
|
||||||
|
last_migration_id = None
|
||||||
|
for migration_id in self.get_migrations():
|
||||||
|
last_migration_id = migration_id
|
||||||
|
return last_migration_id
|
||||||
|
|
||||||
|
def get_migrations(self) -> Generator[str, None, None]:
|
||||||
|
migrations_modules = self._get_migrations_modules()
|
||||||
|
sorted_migrations_modules = self._sort_migrations_modules(modules=migrations_modules)
|
||||||
|
for migration_module in sorted_migrations_modules:
|
||||||
|
yield migration_module.revision
|
||||||
|
|
||||||
|
def _get_migrations_modules(self) -> Iterable[ModuleType]:
|
||||||
|
migrations_path = self.get_migrations_directory_path()
|
||||||
|
versions_path = migrations_path / "versions"
|
||||||
|
|
||||||
|
for index, migration_module_path in enumerate(versions_path.glob("*.py")):
|
||||||
|
migration_module_name = f"migration_{index:08}"
|
||||||
|
migration_module_spec = importlib.util.spec_from_file_location(
|
||||||
|
name=migration_module_name,
|
||||||
|
location=migration_module_path,
|
||||||
|
)
|
||||||
|
if migration_module_spec is None or migration_module_spec.loader is None:
|
||||||
|
raise ValueError(f"Cannot load migration module '{migration_module_path}'")
|
||||||
|
migration_module = importlib.util.module_from_spec(spec=migration_module_spec)
|
||||||
|
migration_module_spec.loader.exec_module(migration_module)
|
||||||
|
yield migration_module
|
||||||
|
|
||||||
|
def _sort_migrations_modules(self, modules: Iterable[ModuleType]) -> list[ModuleType]:
|
||||||
|
next_module_map = {}
|
||||||
|
first_module = None
|
||||||
|
for module in modules:
|
||||||
|
if module.down_revision is None:
|
||||||
|
if first_module is not None:
|
||||||
|
raise ValueError((
|
||||||
|
"Found multiple first migrations: "
|
||||||
|
f"'{module.revision}' and '{first_module.revision}'"
|
||||||
|
))
|
||||||
|
first_module = module
|
||||||
|
else:
|
||||||
|
next_module_map[module.down_revision] = module
|
||||||
|
if first_module is None:
|
||||||
|
raise ValueError("Doesn't found first migration")
|
||||||
|
|
||||||
|
current_module = first_module
|
||||||
|
sorted_modules = [current_module]
|
||||||
|
while next_module_map:
|
||||||
|
current_module = next_module_map.pop(current_module.revision)
|
||||||
|
sorted_modules.append(current_module)
|
||||||
|
|
||||||
|
return sorted_modules
|
||||||
|
|
||||||
|
def _delete_all_migrations(self):
|
||||||
|
migrations_path = self.get_migrations_directory_path()
|
||||||
|
versions_path = migrations_path / "versions"
|
||||||
|
for migration_file_path in versions_path.glob("*.py"):
|
||||||
|
migration_file_path.unlink()
|
||||||
|
|
||||||
|
def get_migrations_directory_path(self) -> pathlib.Path:
|
||||||
|
directory_path = pathlib.Path(__file__).parent
|
||||||
|
return directory_path
|
||||||
28
birthday_pool_bot/repositories/migrations/script.py.mako
Normal file
28
birthday_pool_bot/repositories/migrations/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""init
|
||||||
|
|
||||||
|
Revision ID: c1060d90df61
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-01-13 10:06:43.435312
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c1060d90df61"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table("users",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column("birthday", sa.Date(), nullable=True),
|
||||||
|
sa.Column("phone", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column("telegram_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("gift_payment_data", sa.JSON(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("id"),
|
||||||
|
sa.UniqueConstraint("phone", name="uq__users__phone"),
|
||||||
|
sa.UniqueConstraint("telegram_id", name="uq__users__telegram_id",)
|
||||||
|
)
|
||||||
|
op.create_index("ix__users__phone", "users", ["phone"], unique=False)
|
||||||
|
op.create_index("ix__users__telegram_id", "users", ["telegram_id"], unique=False)
|
||||||
|
op.create_table("pools",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("owner_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("birthday_user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
sa.Column("payment_data", sa.JSON(), nullable=False),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"owner_id <> birthday_user_id",
|
||||||
|
name="ck__pools__owner_not_birthday_user",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["birthday_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix__pools__birthday_user_id", "pools", ["birthday_user_id"], unique=False)
|
||||||
|
op.create_index("ix__pools__owner_id", "pools", ["owner_id"], unique=False)
|
||||||
|
op.create_table("subscriptions",
|
||||||
|
sa.Column("from_user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("to_user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column("pool_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"from_user_id <> to_user_id",
|
||||||
|
name="ck__subscriptions__from_user_not_to_user",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["from_user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["pool_id"], ["pools.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["to_user_id"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("from_user_id", "to_user_id"),
|
||||||
|
sa.UniqueConstraint("from_user_id", "to_user_id", name="uq__subscriptions__from_to_user"),
|
||||||
|
)
|
||||||
|
op.create_index("ix__subscriptions__from_user_id", "subscriptions", ["from_user_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"ix__subscriptions__from_user_id__to_user_id",
|
||||||
|
"subscriptions",
|
||||||
|
["from_user_id", "to_user_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index("ix__subscriptions__name", "subscriptions", ["name"], unique=False)
|
||||||
|
op.create_index("ix__subscriptions__pool_id", "subscriptions", ["pool_id"], unique=False)
|
||||||
|
op.create_index("ix__subscriptions__to_user_id", "subscriptions", ["to_user_id"], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index("ix__subscriptions__to_user_id", table_name="subscriptions")
|
||||||
|
op.drop_index("ix__subscriptions__pool_id", table_name="subscriptions")
|
||||||
|
op.drop_index("ix__subscriptions__name", table_name="subscriptions")
|
||||||
|
op.drop_index("ix__subscriptions__from_user_id__to_user_id", table_name="subscriptions")
|
||||||
|
op.drop_index("ix__subscriptions__from_user_id", table_name="subscriptions")
|
||||||
|
op.drop_table("subscriptions")
|
||||||
|
op.drop_index("ix__pools__owner_id", table_name="pools")
|
||||||
|
op.drop_index("ix__pools__birthday_user_id", table_name="pools")
|
||||||
|
op.drop_table("pools")
|
||||||
|
op.drop_index("ix__users__telegram_id", table_name="users")
|
||||||
|
op.drop_index("ix__users__phone", table_name="users")
|
||||||
|
op.drop_table("users")
|
||||||
|
# ### end Alembic commands ###
|
||||||
13
birthday_pool_bot/repositories/repositories/__init__.py
Normal file
13
birthday_pool_bot/repositories/repositories/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from .pools import PoolsRepository
|
||||||
|
from .subscriptions import SubscriptionsRepository
|
||||||
|
from .users import UsersRepository
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
# pools
|
||||||
|
"PoolsRepository",
|
||||||
|
# subscriptions
|
||||||
|
"SubscriptionsRepository",
|
||||||
|
# users
|
||||||
|
"UsersRepository",
|
||||||
|
)
|
||||||
41
birthday_pool_bot/repositories/repositories/pools.py
Normal file
41
birthday_pool_bot/repositories/repositories/pools.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from pydantic_filters import OffsetPagination
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from birthday_pool_bot.dto import Pool, PoolFilter
|
||||||
|
from birthday_pool_bot.repositories.base import BaseRepository
|
||||||
|
from birthday_pool_bot.repositories.tables import BaseSQLModel, Pool as DBPool
|
||||||
|
|
||||||
|
|
||||||
|
class PoolsRepository(BaseRepository[Pool, PoolFilter]):
|
||||||
|
def get_db_table(self) -> type[BaseSQLModel]:
|
||||||
|
return DBPool
|
||||||
|
|
||||||
|
async def get_pools_by_birthday_user_id_count(self, birthday_user_id: uuid.UUID) -> int:
|
||||||
|
filter_ = PoolFilter(birthday_user_id={birthday_user_id})
|
||||||
|
return await self.get_items_count(filter_=filter_)
|
||||||
|
|
||||||
|
async def get_pools_by_birthday_user_id(self, birthday_user_id: uuid.UUID) -> list[Pool]:
|
||||||
|
filter_ = PoolFilter(birthday_user_id={birthday_user_id})
|
||||||
|
return [pool async for pool in self.get_items(filter_=filter_)]
|
||||||
|
|
||||||
|
async def get_pool_by_id(
|
||||||
|
self,
|
||||||
|
pool_id: uuid.UUID,
|
||||||
|
with_owner: bool = False,
|
||||||
|
) -> Pool | None:
|
||||||
|
filter_ = PoolFilter(id={pool_id})
|
||||||
|
pagination = OffsetPagination(limit=1)
|
||||||
|
options = []
|
||||||
|
if with_owner:
|
||||||
|
options.append(joinedload(DBPool.owner))
|
||||||
|
|
||||||
|
pools_generator = self.get_items(
|
||||||
|
filter_=filter_,
|
||||||
|
pagination=pagination,
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
async for pool in pools_generator:
|
||||||
|
return pool
|
||||||
|
|
||||||
|
async def create_pool(self, pool: Pool) -> Pool:
|
||||||
|
return await self.create_item(item=pool)
|
||||||
135
birthday_pool_bot/repositories/repositories/subscriptions.py
Normal file
135
birthday_pool_bot/repositories/repositories/subscriptions.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic_filters import BasePagination, OffsetPagination
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
from sqlalchemy import delete, update, select
|
||||||
|
|
||||||
|
from birthday_pool_bot.dto import Subscription, SubscriptionFilter
|
||||||
|
from birthday_pool_bot.repositories.base import BaseRepository
|
||||||
|
from birthday_pool_bot.repositories.tables import (
|
||||||
|
BaseSQLModel,
|
||||||
|
Pool as DBPool,
|
||||||
|
Subscription as DBSubscription,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionsRepository(BaseRepository[Subscription, SubscriptionFilter]):
|
||||||
|
def get_db_table(self) -> type[BaseSQLModel]:
|
||||||
|
return DBSubscription
|
||||||
|
|
||||||
|
async def get_user_subscriptions_count(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> int:
|
||||||
|
filter_ = SubscriptionFilter(from_user_id={user_id})
|
||||||
|
return await self.get_items_count(filter_=filter_)
|
||||||
|
|
||||||
|
def get_user_subscriptions(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
pagination: BasePagination | None = None,
|
||||||
|
with_from_user: bool = False,
|
||||||
|
with_to_user: bool = False,
|
||||||
|
with_pool: bool = False,
|
||||||
|
with_pool_owner: bool = False,
|
||||||
|
) -> AsyncGenerator[Subscription, None]:
|
||||||
|
filter_ = SubscriptionFilter(from_user_id={user_id})
|
||||||
|
options = []
|
||||||
|
if with_from_user:
|
||||||
|
options.append(joinedload(DBSubscription.from_user))
|
||||||
|
if with_to_user:
|
||||||
|
options.append(joinedload(DBSubscription.to_user))
|
||||||
|
if with_pool:
|
||||||
|
options.append(joinedload(DBSubscription.pool))
|
||||||
|
if with_pool_owner:
|
||||||
|
options.append(joinedload(DBSubscription.pool).joinedload(DBPool.owner))
|
||||||
|
|
||||||
|
return self.get_items(
|
||||||
|
filter_=filter_,
|
||||||
|
pagination=pagination,
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_to_users_subscriptions(
|
||||||
|
self,
|
||||||
|
to_users_ids: Iterable[uuid.UUID],
|
||||||
|
with_from_user: bool = False,
|
||||||
|
with_to_user: bool = False,
|
||||||
|
with_pool: bool = False,
|
||||||
|
with_pool_owner: bool = False,
|
||||||
|
) -> list[Subscription]:
|
||||||
|
filter_ = SubscriptionFilter(to_user_id=set(to_users_ids))
|
||||||
|
options = []
|
||||||
|
if with_from_user:
|
||||||
|
options.append(joinedload(DBSubscription.from_user))
|
||||||
|
if with_to_user:
|
||||||
|
options.append(joinedload(DBSubscription.to_user))
|
||||||
|
if with_pool:
|
||||||
|
options.append(joinedload(DBSubscription.pool))
|
||||||
|
if with_pool_owner:
|
||||||
|
options.append(joinedload(DBSubscription.pool).joinedload(DBPool.owner))
|
||||||
|
|
||||||
|
subscriptions_generator = self.get_items(
|
||||||
|
filter_=filter_,
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [subscription async for subscription in subscriptions_generator]
|
||||||
|
|
||||||
|
async def get_subscription(
|
||||||
|
self,
|
||||||
|
from_user_id: uuid.UUID,
|
||||||
|
to_user_id: uuid.UUID,
|
||||||
|
with_from_user: bool = False,
|
||||||
|
with_to_user: bool = False,
|
||||||
|
with_pool: bool = False,
|
||||||
|
with_pool_owner: bool = False,
|
||||||
|
) -> Subscription | None:
|
||||||
|
filter_ = SubscriptionFilter(from_user_id={from_user_id}, to_user_id={to_user_id})
|
||||||
|
pagination = OffsetPagination(limit=1)
|
||||||
|
options = []
|
||||||
|
if with_from_user:
|
||||||
|
options.append(joinedload(DBSubscription.from_user))
|
||||||
|
if with_to_user:
|
||||||
|
options.append(joinedload(DBSubscription.to_user))
|
||||||
|
if with_pool:
|
||||||
|
options.append(joinedload(DBSubscription.pool))
|
||||||
|
if with_pool_owner:
|
||||||
|
options.append(joinedload(DBSubscription.pool).joinedload(DBPool.owner))
|
||||||
|
|
||||||
|
subscriptions_generator = self.get_items(
|
||||||
|
filter_=filter_,
|
||||||
|
pagination=pagination,
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
async for subscription in subscriptions_generator:
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
async def create_subscription(self, subscription: Subscription) -> Subscription:
|
||||||
|
return await self.create_item(subscription)
|
||||||
|
|
||||||
|
async def delete_subscription(self, from_user_id: uuid.UUID, to_user_id: uuid.UUID):
|
||||||
|
statement = select(DBPool.id).where(
|
||||||
|
DBPool.owner_id == from_user_id,
|
||||||
|
DBPool.birthday_user_id == to_user_id,
|
||||||
|
)
|
||||||
|
result = await self.session.exec(statement)
|
||||||
|
pool_id = result.one_or_none()
|
||||||
|
if pool_id is not None:
|
||||||
|
pool_id = pool_id[0]
|
||||||
|
statement = delete(DBPool).where(DBPool.id == pool_id)
|
||||||
|
await self.session.exec(statement)
|
||||||
|
|
||||||
|
if pool_id is not None:
|
||||||
|
statement = (
|
||||||
|
update(DBSubscription)
|
||||||
|
.where(DBSubscription.pool_id == pool_id)
|
||||||
|
.values(pool_id=None)
|
||||||
|
)
|
||||||
|
await self.session.exec(statement)
|
||||||
|
|
||||||
|
filter_ = SubscriptionFilter(
|
||||||
|
from_user_id={from_user_id},
|
||||||
|
to_user_id={to_user_id},
|
||||||
|
)
|
||||||
|
await self.delete_items(filter_=filter_)
|
||||||
154
birthday_pool_bot/repositories/repositories/users.py
Normal file
154
birthday_pool_bot/repositories/repositories/users.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
from datetime import date
|
||||||
|
from typing import Container, Sequence, TypeVar
|
||||||
|
|
||||||
|
from pydantic_filters import OffsetPagination
|
||||||
|
from sqlalchemy import Column, func, inspect, select
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
from birthday_pool_bot.dto import User, UserFilter
|
||||||
|
from birthday_pool_bot.repositories.base import BaseRepository
|
||||||
|
from birthday_pool_bot.repositories.tables import (
|
||||||
|
BaseSQLModel,
|
||||||
|
Pool as DBPool,
|
||||||
|
Subscription as DBSubscription,
|
||||||
|
User as DBUser,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UsersRepository(BaseRepository[User, UserFilter]):
|
||||||
|
def get_db_table(self) -> type[BaseSQLModel]:
|
||||||
|
return DBUser
|
||||||
|
|
||||||
|
async def get_user_by_id(self, user_id: uuid.UUID) -> User | None:
|
||||||
|
filter_ = UserFilter(id={user_id})
|
||||||
|
pagination = OffsetPagination(limit=1)
|
||||||
|
async for user in self.get_items(filter_=filter_, pagination=pagination):
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_user_by_telegram_id(self, telegram_id: int) -> User | None:
|
||||||
|
filter_ = UserFilter(telegram_id={telegram_id})
|
||||||
|
pagination = OffsetPagination(limit=1)
|
||||||
|
async for user in self.get_items(filter_=filter_, pagination=pagination):
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_user_by_phone(self, phone: str) -> User | None:
|
||||||
|
filter_ = UserFilter(phone={phone})
|
||||||
|
pagination = OffsetPagination(limit=1)
|
||||||
|
async for user in self.get_items(filter_=filter_, pagination=pagination):
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_users_by_ids(
|
||||||
|
self,
|
||||||
|
user_ids: Container[uuid.UUID],
|
||||||
|
pagination: BasePagination | None = None,
|
||||||
|
) -> list[User]:
|
||||||
|
filter_ = UserFilter(id=set(user_ids))
|
||||||
|
users_generator = self.get_items(filter_=filter_, pagination=pagination)
|
||||||
|
users = [user async for user in users_generator]
|
||||||
|
return users
|
||||||
|
|
||||||
|
async def get_users_by_primary_keys(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID | None = None,
|
||||||
|
telegram_id: int | None = None,
|
||||||
|
phone: str | None = None,
|
||||||
|
) -> list[User]:
|
||||||
|
filters = []
|
||||||
|
if user_id is not None:
|
||||||
|
filters.append(DBUser.id == user_id)
|
||||||
|
if telegram_id is not None:
|
||||||
|
filters.append(DBUser.telegram_id == telegram_id)
|
||||||
|
if phone is not None:
|
||||||
|
filters.append(DBUser.phone == phone)
|
||||||
|
if not filters:
|
||||||
|
return []
|
||||||
|
|
||||||
|
statement = select(DBUser).where(or_(filters))
|
||||||
|
result = await self.session.exec(statement)
|
||||||
|
|
||||||
|
return [db_user.to_item() for db_user in result.all()]
|
||||||
|
|
||||||
|
async def get_users_by_birthdays(
|
||||||
|
self,
|
||||||
|
birthday: date | None = None,
|
||||||
|
) -> list[User]:
|
||||||
|
statement = select(DBUser).where(
|
||||||
|
func.extract("month", DBUser.birthday) == birthday.month,
|
||||||
|
func.extract("day", DBUser.birthday) == birthday.day,
|
||||||
|
)
|
||||||
|
result = await self.session.execute(statement)
|
||||||
|
|
||||||
|
return [db_user.to_item() for (db_user,) in result.all()]
|
||||||
|
|
||||||
|
async def create_user(self, user: User) -> User:
|
||||||
|
return await self.create_item(item=user)
|
||||||
|
|
||||||
|
async def update_user(self, user: User) -> User:
|
||||||
|
db_user = self.get_db_table().from_item(item=user)
|
||||||
|
filter_ = UserFilter(id={user.id})
|
||||||
|
users_generator = self.update_items(filter_=filter_, **db_user.to_values())
|
||||||
|
async for user in users_generator:
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def merge_users(self, *users: User) -> User | None:
|
||||||
|
if not users:
|
||||||
|
return None
|
||||||
|
|
||||||
|
merged_user, *users = users
|
||||||
|
merged_user = merge(model=User, first_item=merged_user, items=users)
|
||||||
|
|
||||||
|
if users:
|
||||||
|
users_ids = {user.id for user in users}
|
||||||
|
for model in (DBPool, DBSubscription):
|
||||||
|
foreign_key_columns = get_model_foreign_keys_columns(
|
||||||
|
model=model,
|
||||||
|
table_name="users",
|
||||||
|
column_name="id",
|
||||||
|
)
|
||||||
|
for foreign_key_column in foreign_key_columns:
|
||||||
|
statement = (
|
||||||
|
update(model).values(**{foreign_key_column.key: merged_user.id})
|
||||||
|
.where(foreign_key_column.in_(users_ids))
|
||||||
|
)
|
||||||
|
await self.session.exec(statement)
|
||||||
|
|
||||||
|
filter_ = UserFilter(id={user.id for user in users})
|
||||||
|
await self.delete_items(filter_=filter_)
|
||||||
|
|
||||||
|
merged_user = await self.update_user(user=merged_user)
|
||||||
|
return merged_user
|
||||||
|
|
||||||
|
|
||||||
|
ModelType = TypeVar("ModelType")
|
||||||
|
|
||||||
|
|
||||||
|
def merge(
|
||||||
|
model: type[ModelType],
|
||||||
|
first_item: ModelType,
|
||||||
|
items: Sequence[ModelType],
|
||||||
|
) -> ModelType:
|
||||||
|
data = first_item.model_dump()
|
||||||
|
for item in items:
|
||||||
|
item_data = item.model_dump()
|
||||||
|
for field_name, value in data.items():
|
||||||
|
item_value = item_data[field_name]
|
||||||
|
data[field_name] = value or item_value
|
||||||
|
|
||||||
|
return model(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_foreign_keys_columns(
|
||||||
|
model: type[SQLModel],
|
||||||
|
table_name: str | None = None,
|
||||||
|
column_name: str | None = None,
|
||||||
|
) -> list[Column]:
|
||||||
|
mapper = inspect(model)
|
||||||
|
columns = []
|
||||||
|
|
||||||
|
for column in mapper.columns:
|
||||||
|
for foreign_key in column.foreign_keys:
|
||||||
|
table_name = table_name or foreign_key.column.table.name
|
||||||
|
column_name = column_name or foreign_key.column.name
|
||||||
|
if (foreign_key.column.table.name, foreign_key.column.name) == (table_name, column_name):
|
||||||
|
columns.append(column)
|
||||||
|
return columns
|
||||||
8
birthday_pool_bot/repositories/settings.py
Normal file
8
birthday_pool_bot/repositories/settings.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from pydantic import AnyUrl, BaseModel, PositiveInt
|
||||||
|
|
||||||
|
|
||||||
|
class RepositorySettings(BaseModel):
|
||||||
|
dsn: AnyUrl = "sqlite+aiosqlite:///db.sqlite3"
|
||||||
|
pool_size: PositiveInt = 5
|
||||||
|
pool_recycle: PositiveInt = 60 # in seconds: 1 minute
|
||||||
|
pool_timeout: PositiveInt = 60 # in seconds: 1 minute
|
||||||
221
birthday_pool_bot/repositories/tables.py
Normal file
221
birthday_pool_bot/repositories/tables.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import uuid
|
||||||
|
import enum
|
||||||
|
from datetime import date
|
||||||
|
from typing import Self, List
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlmodel import SQLModel, Field, Relationship
|
||||||
|
|
||||||
|
from birthday_pool_bot.dto import (
|
||||||
|
BankEnum,
|
||||||
|
PaymentData as DTOPaymentData,
|
||||||
|
Pool as DTOPool,
|
||||||
|
Subscription as DTOSubscription,
|
||||||
|
User as DTOUser,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseSQLModel(SQLModel):
|
||||||
|
@classmethod
|
||||||
|
def from_item(cls, item: BaseModel) -> Self:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def to_item(self) -> BaseModel:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def to_values(self) -> dict[str, Any]:
|
||||||
|
return {column.name: getattr(self, column.name) for column in self.__table__.columns}
|
||||||
|
|
||||||
|
|
||||||
|
class User(BaseSQLModel, table=True):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
sa.Index("ix__users__phone", "phone"),
|
||||||
|
sa.Index("ix__users__telegram_id", "telegram_id"),
|
||||||
|
sa.UniqueConstraint("phone", name="uq__users__phone"),
|
||||||
|
sa.UniqueConstraint("telegram_id", name="uq__users__telegram_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(
|
||||||
|
default_factory=uuid.uuid4,
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
sa_column_kwargs={"unique": True},
|
||||||
|
)
|
||||||
|
name: str | None = Field(nullable=True)
|
||||||
|
birthday: date | None = Field(nullable=True)
|
||||||
|
phone: str | None = Field(default=None, nullable=True)
|
||||||
|
telegram_id: int | None = Field(default=None, nullable=True)
|
||||||
|
gift_payment_data: dict | None = Field(
|
||||||
|
sa_column=sa.Column(sa.JSON, nullable=True),
|
||||||
|
default_factory=dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_item(cls, item: DTOUser) -> Self:
|
||||||
|
return cls(
|
||||||
|
id=item.id,
|
||||||
|
name=item.name,
|
||||||
|
birthday=item.birthday,
|
||||||
|
phone=item.phone,
|
||||||
|
telegram_id=item.telegram_id,
|
||||||
|
gift_payment_data=(
|
||||||
|
item.gift_payment_data.model_dump_json()
|
||||||
|
if item.gift_payment_data is not None else
|
||||||
|
None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_item(self) -> DTOUser:
|
||||||
|
return DTOUser(
|
||||||
|
id=self.id,
|
||||||
|
name=self.name,
|
||||||
|
birthday=self.birthday,
|
||||||
|
phone=self.phone,
|
||||||
|
telegram_id=self.telegram_id,
|
||||||
|
gift_payment_data=(
|
||||||
|
DTOPaymentData.model_validate_json(self.gift_payment_data)
|
||||||
|
if self.gift_payment_data is not None else
|
||||||
|
None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Pool(BaseSQLModel, table=True):
|
||||||
|
__tablename__ = "pools"
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
sa.Index("ix__pools__owner_id", "owner_id"),
|
||||||
|
sa.Index("ix__pools__birthday_user_id", "birthday_user_id"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"owner_id <> birthday_user_id",
|
||||||
|
name="ck__pools__owner_not_birthday_user",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(
|
||||||
|
default_factory=uuid.uuid4,
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
sa_column_kwargs={"unique": True},
|
||||||
|
)
|
||||||
|
owner_id: uuid.UUID = Field(
|
||||||
|
foreign_key="users.id", nullable=False,
|
||||||
|
)
|
||||||
|
birthday_user_id: uuid.UUID = Field(
|
||||||
|
foreign_key="users.id", nullable=False,
|
||||||
|
)
|
||||||
|
description: str | None = Field(nullable=True)
|
||||||
|
payment_data: dict = Field(
|
||||||
|
sa_column=sa.Column(sa.JSON, nullable=False),
|
||||||
|
default_factory=dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
owner: User = Relationship(
|
||||||
|
sa_relationship_kwargs={
|
||||||
|
"primaryjoin": "User.id == Pool.owner_id",
|
||||||
|
"lazy": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_item(cls, item: DTOPool) -> Self:
|
||||||
|
return cls(
|
||||||
|
id=item.id,
|
||||||
|
owner_id=item.owner_id,
|
||||||
|
birthday_user_id=item.birthday_user_id,
|
||||||
|
description=item.description,
|
||||||
|
payment_data=item.payment_data.model_dump_json(),
|
||||||
|
owner=None if item.owner is None else DTOUser.from_item(item.owner),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_item(self) -> DTOPool:
|
||||||
|
return DTOPool(
|
||||||
|
id=self.id,
|
||||||
|
owner_id=self.owner_id,
|
||||||
|
birthday_user_id=self.birthday_user_id,
|
||||||
|
description=self.description,
|
||||||
|
payment_data=DTOPaymentData.model_validate_json(self.payment_data),
|
||||||
|
owner=None if self.owner is None else self.owner.to_item()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription(BaseSQLModel, table=True):
|
||||||
|
__tablename__ = "subscriptions"
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
sa.Index("ix__subscriptions__from_user_id", "from_user_id"),
|
||||||
|
sa.Index("ix__subscriptions__to_user_id", "to_user_id"),
|
||||||
|
sa.Index("ix__subscriptions__name", "name"),
|
||||||
|
sa.Index("ix__subscriptions__pool_id", "pool_id"),
|
||||||
|
sa.Index(
|
||||||
|
"ix__subscriptions__from_user_id__to_user_id",
|
||||||
|
"from_user_id",
|
||||||
|
"to_user_id",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"from_user_id <> to_user_id",
|
||||||
|
name="ck__subscriptions__from_user_not_to_user",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"from_user_id",
|
||||||
|
"to_user_id",
|
||||||
|
name="uq__subscriptions__from_to_user",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
from_user_id: uuid.UUID = Field(
|
||||||
|
foreign_key="users.id", primary_key=True, nullable=False,
|
||||||
|
)
|
||||||
|
to_user_id: uuid.UUID = Field(
|
||||||
|
foreign_key="users.id", primary_key=True, nullable=False,
|
||||||
|
)
|
||||||
|
name: str = Field(nullable=False)
|
||||||
|
pool_id: uuid.UUID = Field(
|
||||||
|
foreign_key="pools.id",
|
||||||
|
nullable=True,
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
from_user: "User" = Relationship(
|
||||||
|
sa_relationship_kwargs={
|
||||||
|
"primaryjoin": "User.id == Subscription.from_user_id",
|
||||||
|
"lazy": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
to_user: "User" = Relationship(
|
||||||
|
sa_relationship_kwargs={
|
||||||
|
"primaryjoin": "User.id == Subscription.to_user_id",
|
||||||
|
"lazy": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pool: "Pool" = Relationship(
|
||||||
|
sa_relationship_kwargs={
|
||||||
|
"primaryjoin": "Pool.id == Subscription.pool_id",
|
||||||
|
"lazy": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_item(cls, item: DTOSubscription) -> Self:
|
||||||
|
return cls(
|
||||||
|
from_user_id=item.from_user_id,
|
||||||
|
to_user_id=item.to_user_id,
|
||||||
|
name=item.name,
|
||||||
|
pool_id=item.pool_id,
|
||||||
|
from_user=None if item.from_user is None else User.from_item(item.from_user),
|
||||||
|
to_user=None if item.to_user is None else User.from_item(item.to_user),
|
||||||
|
pool=None if item.pool is None else Pool.from_item(item.pool),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_item(self) -> DTOSubscription:
|
||||||
|
return DTOSubscription(
|
||||||
|
from_user_id=self.from_user_id,
|
||||||
|
to_user_id=self.to_user_id,
|
||||||
|
name=self.name,
|
||||||
|
pool_id=self.pool_id,
|
||||||
|
from_user=None if self.from_user is None else self.from_user.to_item(),
|
||||||
|
to_user=None if self.to_user is None else self.to_user.to_item(),
|
||||||
|
pool=None if self.pool is None else self.pool.to_item(),
|
||||||
|
)
|
||||||
26
birthday_pool_bot/service.py
Normal file
26
birthday_pool_bot/service.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import facet
|
||||||
|
|
||||||
|
from .notifications import NotificationsService
|
||||||
|
from .repositories import RepositoriesContainer
|
||||||
|
from .telegram_bot import get_telegram_bot_service
|
||||||
|
from .settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class BirthdayPoolBotService(facet.AsyncioServiceMixin):
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
self._repositories_container = RepositoriesContainer(settings=settings.repository)
|
||||||
|
self._telegram_bot = get_telegram_bot_service(
|
||||||
|
settings=settings.telegram_bot,
|
||||||
|
repositories_container=self._repositories_container,
|
||||||
|
)
|
||||||
|
self._notifications_service = NotificationsService(
|
||||||
|
settings=settings.notifications,
|
||||||
|
repositories_container=self._repositories_container,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> list[facet.AsyncioServiceMixin]:
|
||||||
|
return [
|
||||||
|
self._telegram_bot,
|
||||||
|
self._notifications_service,
|
||||||
|
]
|
||||||
21
birthday_pool_bot/settings.py
Normal file
21
birthday_pool_bot/settings.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from .notifications import NotificationsSettings
|
||||||
|
from .repositories import RepositorySettings
|
||||||
|
from .telegram_bot.polling import TelegramBotPollingSettings
|
||||||
|
from .telegram_bot.webhook import TelegramBotWebhookSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="BIRTHDAY_POOL_BOT__",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
repository: RepositorySettings = RepositorySettings()
|
||||||
|
telegram_bot: (
|
||||||
|
TelegramBotPollingSettings |
|
||||||
|
TelegramBotWebhookSettings
|
||||||
|
) = TelegramBotPollingSettings()
|
||||||
|
notifications: NotificationsSettings = NotificationsSettings()
|
||||||
11
birthday_pool_bot/telegram_bot/__init__.py
Normal file
11
birthday_pool_bot/telegram_bot/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from .cli import get_cli
|
||||||
|
from .fabric import get_telegram_bot_service
|
||||||
|
from .settings import TelegramBotSettings
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
# cli
|
||||||
|
"get_cli",
|
||||||
|
# settings
|
||||||
|
"TelegramBotSettings",
|
||||||
|
)
|
||||||
29
birthday_pool_bot/telegram_bot/base.py
Normal file
29
birthday_pool_bot/telegram_bot/base.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import aiogram
|
||||||
|
import facet
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
|
||||||
|
from birthday_pool_bot.repositories import RepositoriesContainer
|
||||||
|
from .ui import setup_dispatcher
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTelegramBotService(facet.AsyncioServiceMixin):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: TelegramBotSettings,
|
||||||
|
repositories_container: RepositoriesContainer,
|
||||||
|
):
|
||||||
|
self._settings = settings
|
||||||
|
self._repositories_container = repositories_container
|
||||||
|
self._dispatcher = aiogram.Dispatcher(storage=MemoryStorage())
|
||||||
|
self._bot = aiogram.Bot(token=settings.token)
|
||||||
|
|
||||||
|
setup_dispatcher(
|
||||||
|
dispatcher=self._dispatcher,
|
||||||
|
repositories_container=self._repositories_container,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
self.add_task(self.listen_events())
|
||||||
|
|
||||||
|
async def listen_events(self):
|
||||||
|
raise NotImplementedError
|
||||||
32
birthday_pool_bot/telegram_bot/cli.py
Normal file
32
birthday_pool_bot/telegram_bot/cli.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from birthday_pool_bot.repositories import RepositoriesContainer
|
||||||
|
from .fabric import get_telegram_bot_service
|
||||||
|
from .settings import TelegramBotSettings
|
||||||
|
|
||||||
|
|
||||||
|
def callback(ctx: typer.Context):
|
||||||
|
ctx.obj["settings"] = ctx.obj["settings"].telegram_bot
|
||||||
|
|
||||||
|
|
||||||
|
def run(ctx: typer.Context):
|
||||||
|
settings: TelegramBotSettings = ctx.obj["settings"]
|
||||||
|
repositories_container: RepositoriesContainer = ctx.obj["repositories_container"]
|
||||||
|
|
||||||
|
service = get_telegram_bot_service(
|
||||||
|
settings=settings,
|
||||||
|
repositories_container=repositories_container,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(service.run())
|
||||||
|
|
||||||
|
|
||||||
|
def get_cli() -> typer.Typer:
|
||||||
|
cli = typer.Typer()
|
||||||
|
|
||||||
|
cli.callback()(callback)
|
||||||
|
cli.command(name="run")(run)
|
||||||
|
|
||||||
|
return cli
|
||||||
6
birthday_pool_bot/telegram_bot/enums.py
Normal file
6
birthday_pool_bot/telegram_bot/enums.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotMethodEnum(enum.Enum):
|
||||||
|
WEBHOOK = "webhook"
|
||||||
|
POLLING = "polling"
|
||||||
26
birthday_pool_bot/telegram_bot/fabric.py
Normal file
26
birthday_pool_bot/telegram_bot/fabric.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from birthday_pool_bot.repositories import RepositoriesContainer
|
||||||
|
|
||||||
|
from .base import BaseTelegramBotService
|
||||||
|
from .enums import TelegramBotMethodEnum
|
||||||
|
from .polling import TelegramBotPollingService
|
||||||
|
from .settings import TelegramBotSettings
|
||||||
|
from .webhook import TelegramBotWebhookService
|
||||||
|
|
||||||
|
|
||||||
|
def get_telegram_bot_service(
|
||||||
|
settings: TelegramBotSettings,
|
||||||
|
repositories_container: RepositoriesContainer,
|
||||||
|
) -> BaseTelegramBotService:
|
||||||
|
match settings.method:
|
||||||
|
case TelegramBotMethodEnum.POLLING:
|
||||||
|
return TelegramBotPollingService(
|
||||||
|
settings=settings,
|
||||||
|
repositories_container=repositories_container,
|
||||||
|
)
|
||||||
|
case TelegramBotMethodEnum.WEBHOOK:
|
||||||
|
return TelegramBotWebhookService(
|
||||||
|
settings=settings,
|
||||||
|
repositories_container=repositories_container,
|
||||||
|
)
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"Cannot create telegram bot with method '{settings.method.value}'")
|
||||||
10
birthday_pool_bot/telegram_bot/polling/__init__.py
Normal file
10
birthday_pool_bot/telegram_bot/polling/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from .service import TelegramBotPollingService
|
||||||
|
from .settings import TelegramBotPollingSettings
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
# service
|
||||||
|
"TelegramBotPollingService",
|
||||||
|
# settings
|
||||||
|
"TelegramBotPollingSettings",
|
||||||
|
)
|
||||||
9
birthday_pool_bot/telegram_bot/polling/service.py
Normal file
9
birthday_pool_bot/telegram_bot/polling/service.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from birthday_pool_bot.telegram_bot.base import BaseTelegramBotService
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotPollingService(BaseTelegramBotService):
|
||||||
|
async def listen_events(self):
|
||||||
|
await self._dispatcher._polling( # pylint: disable=protected-access
|
||||||
|
bot=self._bot,
|
||||||
|
polling_timeout=self._settings.timeout,
|
||||||
|
)
|
||||||
14
birthday_pool_bot/telegram_bot/polling/settings.py
Normal file
14
birthday_pool_bot/telegram_bot/polling/settings.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import PositiveInt
|
||||||
|
|
||||||
|
from birthday_pool_bot.telegram_bot.enums import TelegramBotMethodEnum
|
||||||
|
from birthday_pool_bot.telegram_bot.settings import TelegramBotSettings
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotPollingSettings(TelegramBotSettings):
|
||||||
|
method: Literal[TelegramBotMethodEnum.POLLING.value] = (
|
||||||
|
TelegramBotMethodEnum.POLLING
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout: PositiveInt = 10 # in seconds
|
||||||
8
birthday_pool_bot/telegram_bot/settings.py
Normal file
8
birthday_pool_bot/telegram_bot/settings.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .enums import TelegramBotMethodEnum
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotSettings(BaseModel):
|
||||||
|
method: TelegramBotMethodEnum = TelegramBotMethodEnum.POLLING
|
||||||
|
token: str = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
7
birthday_pool_bot/telegram_bot/ui/__init__.py
Normal file
7
birthday_pool_bot/telegram_bot/ui/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from .dispatcher import setup_dispatcher
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
# dispatcher
|
||||||
|
"setup_dispatcher",
|
||||||
|
)
|
||||||
47
birthday_pool_bot/telegram_bot/ui/callback_data.py
Normal file
47
birthday_pool_bot/telegram_bot/ui/callback_data.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatorCallbackData(CallbackData, prefix=""):
|
||||||
|
page: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class MenuCallbackData(CallbackData, prefix="menu"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionsCallbackData(PaginatorCallbackData, prefix="subscriptions"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AddSubscriptionCallbackData(CallbackData, prefix="subscription_add"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmAnswerEnum(str, enum.Enum):
|
||||||
|
YES = "yes"
|
||||||
|
NO = "no"
|
||||||
|
|
||||||
|
|
||||||
|
class AddSubscriptionConfirmCallbackData(CallbackData, prefix="subscription_add_confirm"):
|
||||||
|
answer: ConfirmAnswerEnum
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionActionEnum(str, enum.Enum):
|
||||||
|
SHOW = "show"
|
||||||
|
DELETE = "delete"
|
||||||
|
EDIT = "edit"
|
||||||
|
BACK = "back"
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionCallbackData(CallbackData, prefix="subscription"):
|
||||||
|
to_user_id: uuid.UUID
|
||||||
|
action: SubscriptionActionEnum = SubscriptionActionEnum.SHOW
|
||||||
|
|
||||||
|
|
||||||
|
class AddSubscriptionPoolsCallbackData(PaginatorCallbackData, prefix="subscription_add_pool"):
|
||||||
|
to_user_id: uuid.UUID
|
||||||
26
birthday_pool_bot/telegram_bot/ui/constants.py
Normal file
26
birthday_pool_bot/telegram_bot/ui/constants.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from birthday_pool_bot.dto import BankEnum
|
||||||
|
|
||||||
|
|
||||||
|
BANKS_MAP = {
|
||||||
|
"Т-Банк": BankEnum.T_BANK,
|
||||||
|
"Сбер": BankEnum.SBER,
|
||||||
|
"Альфа-Банк": BankEnum.ALFA_BANK,
|
||||||
|
}
|
||||||
|
BANKS_TITLE_MAP = {value: key for key, value in BANKS_MAP.items()}
|
||||||
|
|
||||||
|
PROFILE_BUTTON = "Профиль"
|
||||||
|
SUBSCRIPTIONS_BUTTON = "Мои подписки"
|
||||||
|
|
||||||
|
SET_PROFILE_NAME = "Изменить имя"
|
||||||
|
SET_PROFILE_PHONE = "Изменить номер телефона"
|
||||||
|
SET_PROFILE_GIFT_PAYMENT_DATA_BUTTON = "Изменить банковские данные для подарка"
|
||||||
|
SET_PROFILE_BIRTHDATE_BUTTON = "Изменить день рождения"
|
||||||
|
SHARE_CONTACT_BUTTON = "Поделиться контактом"
|
||||||
|
CREATE_POOL_BUTTON = "Хочу создать сбор денег"
|
||||||
|
JOIN_EXISTING_POOL_BUTTON = "Хочу присоединиться к сборам"
|
||||||
|
DECLINE_POOL_BUTTON = "Не хочу участвовать в сборе денег"
|
||||||
|
USE_PROFILE_GIFT_PAYMENT_DATA = "Использовать данные из профиля"
|
||||||
|
SKIP_BUTTON = "Пропустить"
|
||||||
|
BACK_BUTTON = "Назад"
|
||||||
|
|
||||||
|
ERROR_MESSAGE = "Какой-то сбой, попробуйте ещё раз"
|
||||||
311
birthday_pool_bot/telegram_bot/ui/dispatcher.py
Normal file
311
birthday_pool_bot/telegram_bot/ui/dispatcher.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import aiogram
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram_dialog import DialogManager
|
||||||
|
from birthday_pool_bot.repositories import RepositoriesContainer
|
||||||
|
|
||||||
|
from . import constants, handlers
|
||||||
|
from .callback_data import (
|
||||||
|
AddSubscriptionCallbackData,
|
||||||
|
AddSubscriptionConfirmCallbackData,
|
||||||
|
ConfirmAnswerEnum,
|
||||||
|
MenuCallbackData,
|
||||||
|
SubscriptionActionEnum,
|
||||||
|
SubscriptionCallbackData,
|
||||||
|
SubscriptionsCallbackData,
|
||||||
|
)
|
||||||
|
from .middlewares import (
|
||||||
|
AuthMiddleware,
|
||||||
|
DependsMiddleware,
|
||||||
|
TypingMiddleware,
|
||||||
|
)
|
||||||
|
from .states import (
|
||||||
|
AddSubscriptionPoolState,
|
||||||
|
AddSubscriptionState,
|
||||||
|
MenuState,
|
||||||
|
SetProfileBirthdayState,
|
||||||
|
SetProfileGiftPaymentDataState,
|
||||||
|
SetProfileNameState,
|
||||||
|
SetProfilePhoneState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_dispatcher(
|
||||||
|
dispatcher: aiogram.Dispatcher,
|
||||||
|
repositories_container: RepositoriesContainer,
|
||||||
|
):
|
||||||
|
typing_middleware = TypingMiddleware()
|
||||||
|
auth_middleware = AuthMiddleware(users_repository=repositories_container.users)
|
||||||
|
users_repository_middleware = DependsMiddleware(
|
||||||
|
name="users_repository",
|
||||||
|
object_=repositories_container.users,
|
||||||
|
)
|
||||||
|
pools_repository_middleware = DependsMiddleware(
|
||||||
|
name="pools_repository",
|
||||||
|
object_=repositories_container.pools,
|
||||||
|
)
|
||||||
|
subscriptions_repository_middleware = DependsMiddleware(
|
||||||
|
name="subscriptions_repository",
|
||||||
|
object_=repositories_container.subscriptions,
|
||||||
|
)
|
||||||
|
middlewares = [
|
||||||
|
# typing_middleware,
|
||||||
|
auth_middleware,
|
||||||
|
users_repository_middleware,
|
||||||
|
pools_repository_middleware,
|
||||||
|
subscriptions_repository_middleware,
|
||||||
|
]
|
||||||
|
|
||||||
|
router = aiogram.Router()
|
||||||
|
for middleware in middlewares:
|
||||||
|
dispatcher.update.outer_middleware(middleware)
|
||||||
|
router.callback_query.middleware(middleware)
|
||||||
|
router.message.middleware(middleware)
|
||||||
|
|
||||||
|
router = setup_router(router=router)
|
||||||
|
dispatcher.include_router(router)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_router(router: aiogram.Router) -> aiogram.Router:
|
||||||
|
# Menu
|
||||||
|
router.message.register(handlers.menu_message_handler, Command("start"))
|
||||||
|
router.message.register(handlers.menu_message_handler, Command("menu"))
|
||||||
|
router.message.register(
|
||||||
|
handlers.profile_message_handler,
|
||||||
|
MenuState.MENU,
|
||||||
|
aiogram.F.text == constants.PROFILE_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.subscriptions_message_handler,
|
||||||
|
MenuState.MENU,
|
||||||
|
aiogram.F.text == constants.SUBSCRIPTIONS_BUTTON,
|
||||||
|
)
|
||||||
|
|
||||||
|
## Profile
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_profile_name_message_handler,
|
||||||
|
MenuState.PROFILE,
|
||||||
|
aiogram.F.text == constants.SET_PROFILE_NAME,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_profile_phone_message_handler,
|
||||||
|
MenuState.PROFILE,
|
||||||
|
aiogram.F.text == constants.SET_PROFILE_PHONE,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_profile_birthdate_message_handler,
|
||||||
|
MenuState.PROFILE,
|
||||||
|
aiogram.F.text == constants.SET_PROFILE_BIRTHDATE_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_profile_gift_payment_phone_message_handler,
|
||||||
|
MenuState.PROFILE,
|
||||||
|
aiogram.F.text == constants.SET_PROFILE_GIFT_PAYMENT_DATA_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.menu_message_handler,
|
||||||
|
MenuState.PROFILE,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
|
||||||
|
### Set profile name
|
||||||
|
router.message.register(
|
||||||
|
handlers.profile_message_handler,
|
||||||
|
SetProfileNameState.WAITING_FOR_NAME,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_profile_name_message_handler,
|
||||||
|
SetProfileNameState.WAITING_FOR_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
### Set profile phone
|
||||||
|
router.message.register(
|
||||||
|
handlers.profile_message_handler,
|
||||||
|
SetProfilePhoneState.WAITING_FOR_PHONE,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_profile_phone_message_handler,
|
||||||
|
SetProfilePhoneState.WAITING_FOR_PHONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
### Set profile birthday
|
||||||
|
router.message.register(
|
||||||
|
handlers.profile_message_handler,
|
||||||
|
SetProfileBirthdayState.WAITING_FOR_DATE,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_profile_birthdate_message_handler,
|
||||||
|
SetProfileBirthdayState.WAITING_FOR_DATE,
|
||||||
|
)
|
||||||
|
|
||||||
|
### Set profile gift payment data
|
||||||
|
router.message.register(
|
||||||
|
handlers.profile_message_handler,
|
||||||
|
SetProfileGiftPaymentDataState.WAITING_FOR_PHONE,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_profile_gift_payment_phone_message_handler,
|
||||||
|
SetProfileGiftPaymentDataState.WAITING_FOR_PHONE,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_profile_gift_payment_phone_message_handler,
|
||||||
|
SetProfileGiftPaymentDataState.WAITING_FOR_BANK,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_profile_gift_payment_bank_message_handler,
|
||||||
|
SetProfileGiftPaymentDataState.WAITING_FOR_BANK,
|
||||||
|
aiogram.F.text.in_(constants.BANKS_MAP),
|
||||||
|
)
|
||||||
|
|
||||||
|
## Subscriptions
|
||||||
|
router.callback_query.register(
|
||||||
|
handlers.subscription_callback_handler,
|
||||||
|
MenuState.SUBSCRIPTIONS,
|
||||||
|
SubscriptionCallbackData.filter(
|
||||||
|
aiogram.F.action == SubscriptionActionEnum.SHOW,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
router.callback_query.register(
|
||||||
|
handlers.subscriptions_callback_handler,
|
||||||
|
MenuState.SUBSCRIPTIONS,
|
||||||
|
SubscriptionsCallbackData.filter(),
|
||||||
|
)
|
||||||
|
router.callback_query.register(
|
||||||
|
handlers.add_subscription_callback_handler,
|
||||||
|
MenuState.SUBSCRIPTIONS,
|
||||||
|
AddSubscriptionCallbackData.filter(),
|
||||||
|
)
|
||||||
|
router.callback_query.register(
|
||||||
|
handlers.menu_callback_handler,
|
||||||
|
MenuState.SUBSCRIPTIONS,
|
||||||
|
MenuCallbackData.filter(),
|
||||||
|
)
|
||||||
|
|
||||||
|
### Subscription interaction
|
||||||
|
router.callback_query.register(
|
||||||
|
handlers.delete_subscription_callback,
|
||||||
|
MenuState.SUBSCRIPTIONS,
|
||||||
|
SubscriptionCallbackData.filter(
|
||||||
|
aiogram.F.action == SubscriptionActionEnum.DELETE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
router.callback_query.register(
|
||||||
|
handlers.subscriptions_callback_handler,
|
||||||
|
MenuState.SUBSCRIPTIONS,
|
||||||
|
SubscriptionCallbackData.filter(
|
||||||
|
aiogram.F.action == SubscriptionActionEnum.BACK,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
### Add new subscription
|
||||||
|
router.message.register(
|
||||||
|
handlers.add_subscription_message_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_PHONE,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_add_subscription_user_message_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_PHONE,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_add_subscription_user_message_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_DATE,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_add_subscription_user_birthday_message_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_DATE,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_add_subscription_user_message_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_NAME,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_add_subscription_name_message_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_NAME,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_add_subscription_pool_description_message_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_POOL_DECISION,
|
||||||
|
aiogram.F.text == constants.CREATE_POOL_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.show_add_subscription_pools_message_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_POOL_DECISION,
|
||||||
|
aiogram.F.text == constants.JOIN_EXISTING_POOL_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_add_subscription_confirmation_message_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_POOL_DECISION,
|
||||||
|
aiogram.F.text == constants.DECLINE_POOL_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_add_subscription_name_message_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_POOL_DECISION,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.callback_query.register(
|
||||||
|
handlers.confirm_add_subscription_callback_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_CONFIRMATION,
|
||||||
|
AddSubscriptionConfirmCallbackData.filter(
|
||||||
|
aiogram.F.answer == ConfirmAnswerEnum.YES,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
router.callback_query.register(
|
||||||
|
handlers.subscriptions_callback_handler,
|
||||||
|
AddSubscriptionState.WAITING_FOR_CONFIRMATION,
|
||||||
|
AddSubscriptionConfirmCallbackData.filter(
|
||||||
|
aiogram.F.answer == ConfirmAnswerEnum.NO,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
#### Add new subscription pool
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_add_subscription_pool_payment_phone_message_handler,
|
||||||
|
AddSubscriptionPoolState.WAITING_FOR_DESCRIPTION,
|
||||||
|
aiogram.F.text == constants.SKIP_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_add_subscription_pool_decision_message_handler,
|
||||||
|
AddSubscriptionPoolState.WAITING_FOR_DESCRIPTION,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_add_subscription_pool_description_message_handler,
|
||||||
|
AddSubscriptionPoolState.WAITING_FOR_DESCRIPTION,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_add_subscription_pool_payment_data_from_profile_message_handler,
|
||||||
|
AddSubscriptionPoolState.WAITING_FOR_PAYMENT_PHONE,
|
||||||
|
aiogram.F.text == constants.USE_PROFILE_GIFT_PAYMENT_DATA,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.ask_add_subscription_pool_description_message_handler,
|
||||||
|
AddSubscriptionPoolState.WAITING_FOR_PAYMENT_PHONE,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_add_subscription_pool_payment_phone_message_handler,
|
||||||
|
AddSubscriptionPoolState.WAITING_FOR_PAYMENT_PHONE,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_add_subscription_pool_payment_phone_message_handler,
|
||||||
|
AddSubscriptionPoolState.WAITING_FOR_PAYMENT_BANK,
|
||||||
|
aiogram.F.text == constants.BACK_BUTTON,
|
||||||
|
)
|
||||||
|
router.message.register(
|
||||||
|
handlers.set_add_subscription_pool_payment_bank_message_handler,
|
||||||
|
AddSubscriptionPoolState.WAITING_FOR_PAYMENT_BANK,
|
||||||
|
)
|
||||||
|
|
||||||
|
#### Choose pool for subscription
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
router.message.register(handlers.fallback_message_handler)
|
||||||
|
|
||||||
|
return router
|
||||||
6
birthday_pool_bot/telegram_bot/ui/exceptions.py
Normal file
6
birthday_pool_bot/telegram_bot/ui/exceptions.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class TelegramBotUIError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FlowInternalError(TelegramBotUIError):
|
||||||
|
pass
|
||||||
894
birthday_pool_bot/telegram_bot/ui/handlers.py
Normal file
894
birthday_pool_bot/telegram_bot/ui/handlers.py
Normal file
@@ -0,0 +1,894 @@
|
|||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from aiogram import types, F, Router
|
||||||
|
from aiogram.enums import MessageOriginType, ParseMode
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
|
||||||
|
from pydantic_core import PydanticCustomError
|
||||||
|
from pydantic_filters import PagePagination
|
||||||
|
from pydantic_extra_types.phone_numbers import PhoneNumber
|
||||||
|
|
||||||
|
from birthday_pool_bot.dto import (
|
||||||
|
BankEnum,
|
||||||
|
PaymentData,
|
||||||
|
Pool,
|
||||||
|
Subscription,
|
||||||
|
SubscriptionFilter,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from birthday_pool_bot.repositories.repositories import (
|
||||||
|
PoolsRepository,
|
||||||
|
SubscriptionsRepository,
|
||||||
|
UsersRepository,
|
||||||
|
)
|
||||||
|
from . import constants, logic
|
||||||
|
from .callback_data import (
|
||||||
|
AddSubscriptionCallbackData,
|
||||||
|
AddSubscriptionConfirmCallbackData,
|
||||||
|
ConfirmAnswerEnum,
|
||||||
|
MenuCallbackData,
|
||||||
|
SubscriptionCallbackData,
|
||||||
|
SubscriptionsCallbackData,
|
||||||
|
)
|
||||||
|
from .exceptions import FlowInternalError
|
||||||
|
from .states import (
|
||||||
|
AddSubscriptionPoolState,
|
||||||
|
AddSubscriptionState,
|
||||||
|
MenuState,
|
||||||
|
SetProfileBirthdayState,
|
||||||
|
SetProfileGiftPaymentDataState,
|
||||||
|
SetProfileNameState,
|
||||||
|
SetProfilePhoneState,
|
||||||
|
)
|
||||||
|
from .utils import get_telegram_user_full_name, parse_date
|
||||||
|
|
||||||
|
|
||||||
|
async def fallback_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
|
||||||
|
await logic.show_menu(message=message)
|
||||||
|
await state.clear()
|
||||||
|
await state.set_state(MenuState.MENU)
|
||||||
|
|
||||||
|
|
||||||
|
async def menu_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.show_menu(message=message)
|
||||||
|
await state.set_state(MenuState.MENU)
|
||||||
|
|
||||||
|
|
||||||
|
async def menu_callback_handler(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.show_menu(message=callback_query.message)
|
||||||
|
await state.set_state(MenuState.MENU)
|
||||||
|
|
||||||
|
|
||||||
|
async def profile_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
):
|
||||||
|
await logic.show_profile(message=message, user=user)
|
||||||
|
await state.set_state(MenuState.PROFILE)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_profile_name_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.ask_profile_name(message=message)
|
||||||
|
await state.set_state(SetProfileNameState.WAITING_FOR_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_profile_name_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
):
|
||||||
|
user.name = message.text
|
||||||
|
async with users_repository.transaction():
|
||||||
|
user = await users_repository.update_user(user=user)
|
||||||
|
|
||||||
|
await logic.show_profile(message=message, user=user)
|
||||||
|
await state.set_state(MenuState.PROFILE)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_profile_phone_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.ask_profile_phone(message=message)
|
||||||
|
await state.set_state(SetProfilePhoneState.WAITING_FOR_PHONE)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_profile_phone_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
):
|
||||||
|
if message.contact is None:
|
||||||
|
await message.reply(
|
||||||
|
text=(
|
||||||
|
"Некорректный ответ, пожалуйста, отправьте свой контакт нажатием на кнопку "
|
||||||
|
"`Поделиться контактом`"
|
||||||
|
),
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
)
|
||||||
|
await logic.ask_profile_phone(message=message)
|
||||||
|
await state.set_state(SetProfilePhoneState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
phone_number = message.contact.phone_number
|
||||||
|
try:
|
||||||
|
PhoneNumber._validate(phone_number, None)
|
||||||
|
except PydanticCustomError:
|
||||||
|
await message.reply(
|
||||||
|
text="Некорректный номер телефон, попробуйте ещё раз",
|
||||||
|
)
|
||||||
|
await state.set_state(SetProfilePhoneState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
user.phone = phone_number
|
||||||
|
async with users_repository.transaction():
|
||||||
|
user = await users_repository.update_user(user=user)
|
||||||
|
|
||||||
|
await logic.show_profile(message=message, user=user)
|
||||||
|
await state.set_state(MenuState.PROFILE)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_profile_birthdate_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.ask_profile_birthdate(message=message)
|
||||||
|
await state.set_state(SetProfileBirthdayState.WAITING_FOR_DATE)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_profile_birthdate_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
):
|
||||||
|
birthday = parse_date(message=message)
|
||||||
|
if birthday is None:
|
||||||
|
await logic.ask_profile_birthdate(message=message)
|
||||||
|
await state.set_state(SetProfileBirthdayState.WAITING_FOR_DATE)
|
||||||
|
return
|
||||||
|
|
||||||
|
user.birthday = birthday
|
||||||
|
async with users_repository.transaction():
|
||||||
|
await users_repository.update_user(user=user)
|
||||||
|
|
||||||
|
await logic.show_profile(message=message, user=user)
|
||||||
|
await state.set_state(MenuState.PROFILE)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_profile_gift_payment_phone_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.ask_profile_gift_payment_phone(message=message)
|
||||||
|
await state.set_state(SetProfileGiftPaymentDataState.WAITING_FOR_PHONE)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_profile_gift_payment_phone_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
phone_number = None
|
||||||
|
if message.contact is not None:
|
||||||
|
phone_number = message.contact.phone_number
|
||||||
|
elif message.text is not None:
|
||||||
|
phone_number = message.text
|
||||||
|
else:
|
||||||
|
await logic.ask_profile_gift_payment_phone(message=message)
|
||||||
|
await state.set_state(SetProfileGiftPaymentDataState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
PhoneNumber._validate(phone_number, None)
|
||||||
|
except PydanticCustomError:
|
||||||
|
await message.reply(
|
||||||
|
text="Некорректный номер телефон, попробуйте ещё раз",
|
||||||
|
)
|
||||||
|
await state.set_state(SetProfileGiftPaymentDataState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(gift_payment_phone=phone_number)
|
||||||
|
|
||||||
|
await logic.ask_profile_gift_payment_bank(
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
await state.set_state(SetProfileGiftPaymentDataState.WAITING_FOR_BANK)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_profile_gift_payment_bank_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.ask_profile_gift_payment_bank(message=message)
|
||||||
|
await state.set_state(SetProfileGiftPaymentDataState.WAITING_FOR_BANK)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_profile_gift_payment_bank_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
):
|
||||||
|
data = await state.get_data()
|
||||||
|
if "gift_payment_phone" not in data:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_profile_gift_payment_phone(message=message)
|
||||||
|
await state.set_state(SetProfileGiftPaymentDataState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
phone = data["gift_payment_phone"]
|
||||||
|
bank_name = message.text
|
||||||
|
if bank_name not in constants.BANKS_MAP:
|
||||||
|
await message.reply(text="Неизвестный банк, попробуйте ещё раз")
|
||||||
|
await logic.ask_profile_gift_payment_bank(message=message)
|
||||||
|
await state.set_state(SetProfileGiftPaymentDataState.WAITING_FOR_BANK)
|
||||||
|
|
||||||
|
bank = constants.BANKS_MAP[bank_name]
|
||||||
|
user.gift_payment_data = PaymentData(
|
||||||
|
phone=phone,
|
||||||
|
bank=bank,
|
||||||
|
)
|
||||||
|
async with users_repository.transaction():
|
||||||
|
user = await users_repository.update_user(user=user)
|
||||||
|
|
||||||
|
await logic.show_profile(message=message, user=user)
|
||||||
|
await state.set_state(MenuState.PROFILE)
|
||||||
|
|
||||||
|
|
||||||
|
async def subscriptions_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
subscriptions_repository: SubscriptionsRepository,
|
||||||
|
):
|
||||||
|
await logic.show_subscriptions(
|
||||||
|
user=user,
|
||||||
|
subscriptions_repository=subscriptions_repository,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
await state.set_state(MenuState.SUBSCRIPTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
async def subscriptions_callback_handler(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
subscriptions_repository: SubscriptionsRepository,
|
||||||
|
):
|
||||||
|
await logic.show_subscriptions(
|
||||||
|
user=user,
|
||||||
|
subscriptions_repository=subscriptions_repository,
|
||||||
|
message=callback_query.message,
|
||||||
|
)
|
||||||
|
await state.set_state(MenuState.SUBSCRIPTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
async def subscription_callback_handler(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
subscriptions_repository: SubscriptionsRepository,
|
||||||
|
):
|
||||||
|
callback_data = SubscriptionCallbackData.unpack(callback_query.data)
|
||||||
|
|
||||||
|
await logic.show_subscription(
|
||||||
|
from_user_id=user.id,
|
||||||
|
to_user_id=callback_data.to_user_id,
|
||||||
|
subscriptions_repository=subscriptions_repository,
|
||||||
|
callback_query=callback_query,
|
||||||
|
)
|
||||||
|
await state.set_state(MenuState.SUBSCRIPTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_subscription_callback(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
subscriptions_repository: SubscriptionsRepository,
|
||||||
|
):
|
||||||
|
callback_data = SubscriptionCallbackData.unpack(callback_query.data)
|
||||||
|
|
||||||
|
await logic.delete_subscription(
|
||||||
|
from_user_id=user.id,
|
||||||
|
to_user_id=callback_data.to_user_id,
|
||||||
|
subscriptions_repository=subscriptions_repository,
|
||||||
|
callback_query=callback_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
await logic.show_subscriptions(
|
||||||
|
user=user,
|
||||||
|
subscriptions_repository=subscriptions_repository,
|
||||||
|
message=callback_query.message,
|
||||||
|
)
|
||||||
|
await state.set_state(MenuState.SUBSCRIPTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_subscription_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_subscription_callback_handler(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.ask_add_subscription_user(callback_query=callback_query)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_user_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_add_subscription_user_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
subscriptions_repository: SubscriptionsRepository,
|
||||||
|
):
|
||||||
|
user_name, user_phone, user_telegram_id = None, None, None
|
||||||
|
|
||||||
|
async with users_repository.transaction():
|
||||||
|
if message.contact is not None:
|
||||||
|
subscription_users = await users_repository.get_users_by_primary_keys(
|
||||||
|
phone=message.contact.phone_number,
|
||||||
|
telegram_id=message.contact.user_id,
|
||||||
|
)
|
||||||
|
subscription_user = await users_repository.merge_users(*subscription_users)
|
||||||
|
user_name = get_telegram_user_full_name(message.contact)
|
||||||
|
user_phone = message.contact.phone_number
|
||||||
|
user_telegram_id = message.contact.user_id
|
||||||
|
elif message.forward_origin is not None:
|
||||||
|
if message.forward_origin.type == MessageOriginType.HIDDEN_USER:
|
||||||
|
await message.reply(
|
||||||
|
text="Пользователь скрыл свои данные, попробуйте отправить номер телефона или его контакт",
|
||||||
|
)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
if message.forward_origin.type != MessageOriginType.USER:
|
||||||
|
await message.reply(
|
||||||
|
text="Это сообщение не от пользователя, попробуйте ещё раз",
|
||||||
|
)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
user_name = get_telegram_user_full_name(message.forward_origin.sender_user)
|
||||||
|
user_telegram_id = message.forward_origin.sender_user.id
|
||||||
|
subscription_user = await users_repository.get_user_by_telegram_id(
|
||||||
|
telegram_id=user_telegram_id,
|
||||||
|
)
|
||||||
|
elif message.text:
|
||||||
|
user_phone = message.text
|
||||||
|
try:
|
||||||
|
PhoneNumber._validate(user_phone, None)
|
||||||
|
except PydanticCustomError:
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
subscription_user = await users_repository.get_user_by_phone(phone=user_phone)
|
||||||
|
|
||||||
|
if subscription_user is None:
|
||||||
|
subscription_user = User(
|
||||||
|
name=user_name,
|
||||||
|
phone=user_phone,
|
||||||
|
telegram_id=user_telegram_id,
|
||||||
|
)
|
||||||
|
async with users_repository.transaction():
|
||||||
|
subscription_user = await users_repository.create_user(user=subscription_user)
|
||||||
|
|
||||||
|
if subscription_user.id == user.id:
|
||||||
|
await message.reply("Нельзя подписаться на самого себя")
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
async with subscriptions_repository.transaction():
|
||||||
|
subscription = await subscriptions_repository.get_subscription(
|
||||||
|
from_user_id=user.id,
|
||||||
|
to_user_id=subscription_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if subscription is not None:
|
||||||
|
await message.reply(text="У вас уже есть подписка на этого человека")
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
subscription_user_id=subscription_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if subscription_user.birthday is None:
|
||||||
|
await logic.ask_add_subscription_user_birthday(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_DATE)
|
||||||
|
return
|
||||||
|
|
||||||
|
await logic.ask_add_subscription_name(
|
||||||
|
message=message,
|
||||||
|
users_repository=users_repository,
|
||||||
|
subscription_user_id=subscription_user.id,
|
||||||
|
)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_user_birthday_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.ask_add_subscription_user_birthday(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_DATE)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_add_subscription_user_birthday_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
):
|
||||||
|
birthday = parse_date(message=message)
|
||||||
|
if birthday is None:
|
||||||
|
await logic.ask_add_subscription_user_birthday(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_DATE)
|
||||||
|
return
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
subscription_user_id = state_data.get("subscription_user_id")
|
||||||
|
if subscription_user_id is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
async with users_repository.transaction():
|
||||||
|
subscription_user = await users_repository.get_user_by_id(user_id=subscription_user_id)
|
||||||
|
if subscription_user is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
subscription_user.birthday = birthday
|
||||||
|
await users_repository.update_user(user=subscription_user)
|
||||||
|
|
||||||
|
await logic.ask_add_subscription_name(
|
||||||
|
message=message,
|
||||||
|
users_repository=users_repository,
|
||||||
|
subscription_user_id=subscription_user.id,
|
||||||
|
)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_name_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
):
|
||||||
|
state_data = await state.get_data()
|
||||||
|
subscription_user_id = state_data.get("subscription_user_id")
|
||||||
|
if subscription_user_id is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await logic.ask_add_subscription_name(
|
||||||
|
message=message,
|
||||||
|
users_repository=users_repository,
|
||||||
|
subscription_user_id=subscription_user_id,
|
||||||
|
)
|
||||||
|
except FlowInternalError:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_add_subscription_name_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
pools_repository: PoolsRepository,
|
||||||
|
subscriptions_repository: SubscriptionsRepository,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
):
|
||||||
|
state_data = await state.get_data()
|
||||||
|
subscription_user_id = state_data.get("subscription_user_id")
|
||||||
|
if subscription_user_id is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
async with users_repository.transaction():
|
||||||
|
subscription_user = await users_repository.get_user_by_id(user_id=subscription_user_id)
|
||||||
|
if subscription_user is None or subscription_user.birthday is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(subscription_name=message.text)
|
||||||
|
await logic.ask_add_subscription_pool_decision(
|
||||||
|
message=message,
|
||||||
|
pools_repository=pools_repository,
|
||||||
|
users_repository=users_repository,
|
||||||
|
subscription_user_id=subscription_user_id,
|
||||||
|
)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_POOL_DECISION)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_pool_decision_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
pools_repository: PoolsRepository,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
):
|
||||||
|
state_data = await state.get_data()
|
||||||
|
subscription_user_id = state_data.get("subscription_user_id")
|
||||||
|
if subscription_user_id is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await logic.ask_add_subscription_pool_decision(
|
||||||
|
message=message,
|
||||||
|
pools_repository=pools_repository,
|
||||||
|
users_repository=users_repository,
|
||||||
|
subscription_user_id=subscription_user_id,
|
||||||
|
)
|
||||||
|
except FlowInternalError:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_POOL_DECISION)
|
||||||
|
|
||||||
|
|
||||||
|
async def show_add_subscription_pools_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
pools_repository: PoolsRepository,
|
||||||
|
):
|
||||||
|
state_data = await state.get_data()
|
||||||
|
subscription_user_id = state_data.get("subscription_user_id")
|
||||||
|
if subscription_user_id is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await logic.show_add_subscription_pools(
|
||||||
|
user=user,
|
||||||
|
pools_repository=pools_repository,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
except FlowInternalError:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
await logic.ask_add_subscription_pool_description(message=message)
|
||||||
|
await state.set_state(AddSubscriptionPoolState.WAITING_FOR_DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_pool_description_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.ask_add_subscription_pool_description(message=message)
|
||||||
|
await state.set_state(AddSubscriptionPoolState.WAITING_FOR_DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_add_subscription_pool_description_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
description = message.text
|
||||||
|
await state.update_data(subscription_pool_description=description)
|
||||||
|
|
||||||
|
await logic.ask_add_subscription_pool_payment_phone(message=message)
|
||||||
|
await state.set_state(AddSubscriptionPoolState.WAITING_FOR_PAYMENT_PHONE)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_pool_payment_phone_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
):
|
||||||
|
await logic.ask_add_subscription_pool_payment_phone(message=message, user=user)
|
||||||
|
await state.set_state(AddSubscriptionPoolState.WAITING_FOR_PAYMENT_PHONE)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_add_subscription_pool_payment_phone_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
phone_number = None
|
||||||
|
if message.contact is not None:
|
||||||
|
phone_number = message.contact.phone_number
|
||||||
|
elif message.text is not None:
|
||||||
|
phone_number = message.text
|
||||||
|
else:
|
||||||
|
await message.reply(text="Некорректный номер телефона, попробуйте ещё раз")
|
||||||
|
await logic.ask_add_subscription_pool_payment_phone(message=message)
|
||||||
|
await state.set_state(AddSubscriptionPoolState.WAITING_FOR_PAYMENT_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
PhoneNumber._validate(phone_number, None)
|
||||||
|
except PydanticCustomError:
|
||||||
|
await message.reply(
|
||||||
|
text="Некорректный номер телефона, попробуйте ещё раз",
|
||||||
|
)
|
||||||
|
await state.set_state(AddSubscriptionPoolState.WAITING_FOR_PAYMENT_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(subscription_pool_phone=phone_number)
|
||||||
|
|
||||||
|
await logic.ask_add_subscription_pool_payment_bank(message=message)
|
||||||
|
await state.set_state(AddSubscriptionPoolState.WAITING_FOR_PAYMENT_BANK)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_add_subscription_pool_payment_data_from_profile_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
pools_repository: PoolsRepository,
|
||||||
|
):
|
||||||
|
if user.gift_payment_data is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_pool_payment_phone(message=message, user=user)
|
||||||
|
await state.set_state(AddSubscriptionPoolState.WAITING_FOR_PAYMENT_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
subscription_user_id = state_data.get("subscription_user_id")
|
||||||
|
subscription_name = state_data.get("subscription_name")
|
||||||
|
subscription_pool_description = state_data.get("subscription_pool_description")
|
||||||
|
if not all((subscription_user_id, subscription_name)):
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
subscription_pool_phone=user.gift_payment_data.phone,
|
||||||
|
subscription_pool_bank=user.gift_payment_data.bank,
|
||||||
|
)
|
||||||
|
await logic.ask_add_subscription_confirmation(
|
||||||
|
message=message,
|
||||||
|
users_repository=users_repository,
|
||||||
|
pools_repository=pools_repository,
|
||||||
|
subscription_name=subscription_name,
|
||||||
|
subscription_user_id=subscription_user_id,
|
||||||
|
subscription_pool_description=subscription_pool_description,
|
||||||
|
subscription_pool_phone=user.gift_payment_data.phone,
|
||||||
|
subscription_pool_bank=user.gift_payment_data.bank,
|
||||||
|
)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_CONFIRMATION)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_pool_payment_bank_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await logic.ask_add_subscription_pool_payment_bank(message=message)
|
||||||
|
await state.set_state(AddSubscriptionPoolState.WAITING_FOR_PAYMENT_BANK)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_add_subscription_pool_payment_bank_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
pools_repository: PoolsRepository,
|
||||||
|
):
|
||||||
|
state_data = await state.get_data()
|
||||||
|
subscription_user_id = state_data.get("subscription_user_id")
|
||||||
|
subscription_name = state_data.get("subscription_name")
|
||||||
|
subscription_pool_description = state_data.get("subscription_pool_description")
|
||||||
|
subscription_pool_phone = state_data.get("subscription_pool_phone")
|
||||||
|
if not all((subscription_user_id, subscription_name)):
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
if subscription_pool_phone is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_pool_decision(message=message)
|
||||||
|
await state.set_state(AddSubscriptionPoolState.WAITING_FOR_DESCRIPTION)
|
||||||
|
return
|
||||||
|
|
||||||
|
async with users_repository.transaction():
|
||||||
|
subscription_user = await users_repository.get_user_by_id(
|
||||||
|
user_id=subscription_user_id,
|
||||||
|
)
|
||||||
|
if subscription_user is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
bank_name = message.text
|
||||||
|
if bank_name not in constants.BANKS_MAP:
|
||||||
|
await message.reply(text="Неизвестный банк, попробуйте ещё раз")
|
||||||
|
await logic.ask_profile_gift_payment_bank(message=message)
|
||||||
|
await state.set_state(SetProfileGiftPaymentDataState.WAITING_FOR_BANK)
|
||||||
|
|
||||||
|
bank = constants.BANKS_MAP[bank_name]
|
||||||
|
await state.update_data(subscription_pool_bank=bank)
|
||||||
|
|
||||||
|
await logic.ask_add_subscription_confirmation(
|
||||||
|
message=message,
|
||||||
|
users_repository=users_repository,
|
||||||
|
pools_repository=pools_repository,
|
||||||
|
subscription_name=subscription_name,
|
||||||
|
subscription_user_id=subscription_user_id,
|
||||||
|
subscription_pool_description=subscription_pool_description,
|
||||||
|
subscription_pool_phone=subscription_pool_phone,
|
||||||
|
subscription_pool_bank=bank,
|
||||||
|
)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_CONFIRMATION)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_confirmation_message_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
pools_repository: PoolsRepository,
|
||||||
|
):
|
||||||
|
state_data = await state.get_data()
|
||||||
|
subscription_user_id = state_data.get("subscription_user_id")
|
||||||
|
subscription_name = state_data.get("subscription_name")
|
||||||
|
subscription_pool_id = state_data.get("subscription_pool_id")
|
||||||
|
subscription_pool_description = state_data.get("subscription_pool_description")
|
||||||
|
subscription_pool_phone = state_data.get("subscription_pool_phone")
|
||||||
|
subscription_pool_bank = state_data.get("subscription_pool_bank")
|
||||||
|
if not all((subscription_user_id, subscription_name)):
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
async with users_repository.transaction():
|
||||||
|
subscription_user = await users_repository.get_user_by_id(
|
||||||
|
user_id=subscription_user_id,
|
||||||
|
)
|
||||||
|
if subscription_user is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await logic.ask_add_subscription_confirmation(
|
||||||
|
message=message,
|
||||||
|
users_repository=users_repository,
|
||||||
|
pools_repository=pools_repository,
|
||||||
|
subscription_name=subscription_name,
|
||||||
|
subscription_user_id=subscription_user_id,
|
||||||
|
subscription_pool_id=subscription_pool_id,
|
||||||
|
subscription_pool_description=subscription_pool_description,
|
||||||
|
subscription_pool_phone=subscription_pool_phone,
|
||||||
|
subscription_pool_bank=subscription_pool_bank,
|
||||||
|
)
|
||||||
|
except FlowInternalError:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_CONFIRMATION)
|
||||||
|
|
||||||
|
|
||||||
|
async def confirm_add_subscription_callback_handler(
|
||||||
|
callback_query: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
pools_repository: PoolsRepository,
|
||||||
|
subscriptions_repository: SubscriptionsRepository,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
):
|
||||||
|
state_data = await state.get_data()
|
||||||
|
subscription_user_id = state_data.get("subscription_user_id")
|
||||||
|
subscription_name = state_data.get("subscription_name")
|
||||||
|
subscription_pool_id = state_data.get("subscription_pool_id")
|
||||||
|
subscription_pool_description = state_data.get("subscription_pool_description")
|
||||||
|
subscription_pool_phone = state_data.get("subscription_pool_phone")
|
||||||
|
subscription_pool_bank = state_data.get("subscription_pool_bank")
|
||||||
|
if not all((subscription_user_id, subscription_name)):
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
async with users_repository.transaction():
|
||||||
|
subscription_user = await users_repository.get_user_by_id(
|
||||||
|
user_id=subscription_user_id,
|
||||||
|
)
|
||||||
|
if subscription_user is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
subscription_pool = None
|
||||||
|
if subscription_pool_id is not None:
|
||||||
|
async with pools_repository.transaction():
|
||||||
|
subscription_pool = await pools_repository.get_pool_by_id(
|
||||||
|
pool_id=subscription_pool_id,
|
||||||
|
)
|
||||||
|
if subscription_pool is None:
|
||||||
|
await message.reply(text=constants.ERROR_MESSAGE)
|
||||||
|
await logic.ask_add_subscription_user(message=message)
|
||||||
|
await state.set_state(AddSubscriptionState.WAITING_FOR_PHONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
if subscription_pool is None and all((subscription_pool_phone, subscription_pool_bank)):
|
||||||
|
subscription_pool = Pool(
|
||||||
|
owner_id=user.id,
|
||||||
|
birthday_user_id=subscription_user.id,
|
||||||
|
description=subscription_pool_description,
|
||||||
|
payment_data=PaymentData(
|
||||||
|
phone=subscription_pool_phone,
|
||||||
|
bank=subscription_pool_bank,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async with pools_repository.transaction():
|
||||||
|
subscription_pool = await pools_repository.create_pool(pool=subscription_pool)
|
||||||
|
subscription_pool_id = subscription_pool.id
|
||||||
|
|
||||||
|
subscription = Subscription(
|
||||||
|
from_user_id=user.id,
|
||||||
|
to_user_id=subscription_user_id,
|
||||||
|
name=subscription_name,
|
||||||
|
pool_id=subscription_pool_id,
|
||||||
|
)
|
||||||
|
async with subscriptions_repository.transaction():
|
||||||
|
await subscriptions_repository.create_subscription(
|
||||||
|
subscription=subscription,
|
||||||
|
)
|
||||||
|
|
||||||
|
await logic.show_subscriptions(
|
||||||
|
user=user,
|
||||||
|
subscriptions_repository=subscriptions_repository,
|
||||||
|
message=callback_query.message,
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
await state.set_state(MenuState.SUBSCRIPTIONS)
|
||||||
683
birthday_pool_bot/telegram_bot/ui/logic.py
Normal file
683
birthday_pool_bot/telegram_bot/ui/logic.py
Normal file
@@ -0,0 +1,683 @@
|
|||||||
|
from aiogram import types
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
|
||||||
|
from pydantic_filters import PagePagination
|
||||||
|
|
||||||
|
from birthday_pool_bot.dto import User
|
||||||
|
from birthday_pool_bot.repositories.repositories import UsersRepository
|
||||||
|
from . import constants
|
||||||
|
from .callback_data import (
|
||||||
|
AddSubscriptionCallbackData,
|
||||||
|
AddSubscriptionConfirmCallbackData,
|
||||||
|
ConfirmAnswerEnum,
|
||||||
|
MenuCallbackData,
|
||||||
|
SubscriptionActionEnum,
|
||||||
|
SubscriptionCallbackData,
|
||||||
|
SubscriptionsCallbackData,
|
||||||
|
)
|
||||||
|
from .exceptions import FlowInternalError
|
||||||
|
|
||||||
|
|
||||||
|
async def show_menu(message: types.Message) -> None:
|
||||||
|
text = (
|
||||||
|
"Привет! Я – Birthday Pool Bot. "
|
||||||
|
"Я помогаю собирать подарки на дни рождения.\n"
|
||||||
|
)
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
keyboard.button(text=constants.PROFILE_BUTTON)
|
||||||
|
keyboard.button(text=constants.SUBSCRIPTIONS_BUTTON)
|
||||||
|
keyboard.adjust(1)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def show_profile(
|
||||||
|
message: types.Message,
|
||||||
|
user: User,
|
||||||
|
):
|
||||||
|
birthday_str = user.birthday.strftime("%d.%m.%Y") if user.birthday else 'Не указано'
|
||||||
|
text = (
|
||||||
|
f"👤 *Имя*: {user.name or 'Не указано'}\n\n"
|
||||||
|
f"📱 *Телефон*: {user.phone}\n\n"
|
||||||
|
f"🎂 *День рождения*: {birthday_str}\n\n"
|
||||||
|
)
|
||||||
|
if user.gift_payment_data is not None:
|
||||||
|
bank_title = constants.BANKS_TITLE_MAP[user.gift_payment_data.bank]
|
||||||
|
text += (
|
||||||
|
"💳️ *Получать подарки на*:\n"
|
||||||
|
f"🏦 _Банк_: {bank_title}\n"
|
||||||
|
f"📱 _Телефон_: {user.gift_payment_data.phone}\n\n"
|
||||||
|
)
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
keyboard.button(text=constants.SET_PROFILE_NAME)
|
||||||
|
keyboard.button(text=constants.SET_PROFILE_PHONE)
|
||||||
|
keyboard.button(text=constants.SET_PROFILE_BIRTHDATE_BUTTON)
|
||||||
|
keyboard.button(text=constants.SET_PROFILE_GIFT_PAYMENT_DATA_BUTTON)
|
||||||
|
keyboard.button(text=constants.BACK_BUTTON)
|
||||||
|
keyboard.adjust(2)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_profile_name(message: types.Message):
|
||||||
|
text = "👤 Напишите своё имя"
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
keyboard.button(text=constants.BACK_BUTTON)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_profile_phone(message: types.Message):
|
||||||
|
text = "📱 Отправь свой контакт для подтверждения телефона"
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
keyboard.button(text=constants.SHARE_CONTACT_BUTTON, request_contact=True)
|
||||||
|
keyboard.button(text=constants.BACK_BUTTON)
|
||||||
|
keyboard.adjust(1)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_profile_gift_payment_phone(message: types.Message):
|
||||||
|
text = (
|
||||||
|
"*Введите номер телефона для получения подарков*\n"
|
||||||
|
"Пример: `+79123456789`\n\n"
|
||||||
|
"ИЛИ\n\n"
|
||||||
|
"*Просто поделитесь своим контактом*"
|
||||||
|
)
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
keyboard.button(text=constants.SHARE_CONTACT_BUTTON, request_contact=True)
|
||||||
|
keyboard.button(text=constants.BACK_BUTTON)
|
||||||
|
keyboard.adjust(1)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_profile_gift_payment_bank(
|
||||||
|
message: types.Message,
|
||||||
|
):
|
||||||
|
text = "🏦 Выберите банк для приёма платежей"
|
||||||
|
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
for title, type_ in constants.BANKS_MAP.items():
|
||||||
|
keyboard.button(text=title)
|
||||||
|
keyboard.adjust(3)
|
||||||
|
keyboard.row(types.KeyboardButton(text=constants.BACK_BUTTON))
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_profile_birthdate(message: types.Message):
|
||||||
|
text = (
|
||||||
|
"🎂 Отправьте свою дату рождения в формате dd.mm.yyyy\n"
|
||||||
|
"Пример: `26.05.1995`."
|
||||||
|
)
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
keyboard.button(text=constants.BACK_BUTTON)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def show_subscriptions(
|
||||||
|
user: User,
|
||||||
|
subscriptions_repository: SubscriptionsRepository,
|
||||||
|
message: types.Message | None = None,
|
||||||
|
callback_query: types.CallbackQuery | None = None,
|
||||||
|
):
|
||||||
|
page = 1
|
||||||
|
per_page = 5
|
||||||
|
if callback_query is not None:
|
||||||
|
callback_data = SubscriptionsCallbackData.unpack(callback_query.data)
|
||||||
|
page = callback_data.page
|
||||||
|
|
||||||
|
async with subscriptions_repository.transaction():
|
||||||
|
total = await subscriptions_repository.get_user_subscriptions_count(user_id=user.id)
|
||||||
|
pages_count = total // per_page + int(bool(total % per_page))
|
||||||
|
subscriptions = [
|
||||||
|
subscription
|
||||||
|
async for subscription in subscriptions_repository.get_user_subscriptions(
|
||||||
|
user_id=user.id,
|
||||||
|
pagination=PagePagination(page=page, per_page=per_page),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
text = "Мои подписки:" if subscriptions else "Нет подписок"
|
||||||
|
keyboard = InlineKeyboardBuilder()
|
||||||
|
for subscription in subscriptions:
|
||||||
|
keyboard.button(
|
||||||
|
text=subscription.name,
|
||||||
|
callback_data=SubscriptionCallbackData(
|
||||||
|
to_user_id=subscription.to_user_id,
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
navigation_row = []
|
||||||
|
if page > 1:
|
||||||
|
navigation_row.append(types.InlineKeyboardButton(
|
||||||
|
text="<",
|
||||||
|
callback_data=SubscriptionsCallbackData(page=page - 1).pack(),
|
||||||
|
))
|
||||||
|
if pages_count > 1:
|
||||||
|
navigation_row.append(types.InlineKeyboardButton(
|
||||||
|
text=f"{page}/{pages_count}",
|
||||||
|
callback_data="null",
|
||||||
|
))
|
||||||
|
if page < pages_count:
|
||||||
|
navigation_row.append(types.InlineKeyboardButton(
|
||||||
|
text=">",
|
||||||
|
callback_data=SubscriptionsCallbackData(page=page + 1).pack(),
|
||||||
|
))
|
||||||
|
keyboard.row(*navigation_row)
|
||||||
|
keyboard.row(
|
||||||
|
types.InlineKeyboardButton(
|
||||||
|
text="Добавить",
|
||||||
|
callback_data=AddSubscriptionCallbackData().pack(),
|
||||||
|
),
|
||||||
|
types.InlineKeyboardButton(
|
||||||
|
text="Назад",
|
||||||
|
callback_data=MenuCallbackData().pack(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
reply_markup = keyboard.as_markup()
|
||||||
|
|
||||||
|
if message is not None:
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
elif callback_query is not None:
|
||||||
|
await callback_query.message.edit_text(text=text)
|
||||||
|
await callback_query.message.edit_reply_markup(
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def show_subscription(
|
||||||
|
from_user_id: uuid.UUID,
|
||||||
|
to_user_id: uuid.UUID,
|
||||||
|
subscriptions_repository: SubscriptionsRepository,
|
||||||
|
callback_query: types.CallbackQuery | None = None,
|
||||||
|
message: types.Message | None = None,
|
||||||
|
):
|
||||||
|
async with subscriptions_repository.transaction():
|
||||||
|
subscription = await subscriptions_repository.get_subscription(
|
||||||
|
from_user_id=from_user_id,
|
||||||
|
to_user_id=to_user_id,
|
||||||
|
with_to_user=True,
|
||||||
|
with_pool=True,
|
||||||
|
with_pool_owner=True,
|
||||||
|
)
|
||||||
|
if subscription is None:
|
||||||
|
raise FlowInternalError()
|
||||||
|
|
||||||
|
birthday_str = subscription.to_user.birthday.strftime("%d.%m.%Y")
|
||||||
|
text = (
|
||||||
|
f"*Имя*: {subscription.name}\n\n"
|
||||||
|
f"🎂 *День рождения*: {birthday_str}\n\n"
|
||||||
|
)
|
||||||
|
if subscription.pool is not None:
|
||||||
|
if subscription.pool.owner_id == from_user_id:
|
||||||
|
text += "Вы собираете деньги\n\n"
|
||||||
|
else:
|
||||||
|
text += "Вы участвуете в сборе денег\n\n"
|
||||||
|
keyboard = InlineKeyboardBuilder()
|
||||||
|
# keyboard.button(
|
||||||
|
# text="Изменить",
|
||||||
|
# callback_data=SubscriptionCallbackData(
|
||||||
|
# to_user_id=subscription.to_user_id,
|
||||||
|
# action=SubscriptionActionEnum.EDIT,
|
||||||
|
# ).pack(),
|
||||||
|
# )
|
||||||
|
keyboard.button(
|
||||||
|
text="Удалить",
|
||||||
|
callback_data=SubscriptionCallbackData(
|
||||||
|
to_user_id=subscription.to_user_id,
|
||||||
|
action=SubscriptionActionEnum.DELETE,
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
keyboard.button(
|
||||||
|
text="Назад",
|
||||||
|
callback_data=SubscriptionsCallbackData().pack(),
|
||||||
|
)
|
||||||
|
keyboard.adjust(2)
|
||||||
|
reply_markup = keyboard.as_markup()
|
||||||
|
|
||||||
|
if message is not None:
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
elif callback_query is not None:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
)
|
||||||
|
await callback_query.message.edit_reply_markup(reply_markup=reply_markup)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_subscription(
|
||||||
|
from_user_id: uuid.UUID,
|
||||||
|
to_user_id: uuid.UUID,
|
||||||
|
subscriptions_repository: SubscriptionsRepository,
|
||||||
|
callback_query: types.CallbackQuery | None = None,
|
||||||
|
message: types.Message | None = None,
|
||||||
|
):
|
||||||
|
async with subscriptions_repository.transaction():
|
||||||
|
await subscriptions_repository.delete_subscription(
|
||||||
|
from_user_id=from_user_id,
|
||||||
|
to_user_id=to_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if message is not None:
|
||||||
|
await message.reply(text="Подписка успешно удалена")
|
||||||
|
elif callback_query is not None:
|
||||||
|
await callback_query.answer(text="Подписка успешно удалена")
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_user(
|
||||||
|
callback_query: types.CallbackQuery | None = None,
|
||||||
|
message: types.Message | None = None,
|
||||||
|
):
|
||||||
|
text = (
|
||||||
|
"*Введите номер телефона будущего именниника*\n"
|
||||||
|
"Пример: `+79123456789`\n\n"
|
||||||
|
"ИЛИ\n\n"
|
||||||
|
"*Перешлите сообщение пользователя*\n\n"
|
||||||
|
"ИЛИ\n\n"
|
||||||
|
"*Просто поделитесь его контактом*"
|
||||||
|
)
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
keyboard.button(text=constants.BACK_BUTTON)
|
||||||
|
keyboard.adjust(1)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if message is not None:
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
elif callback_query is not None:
|
||||||
|
await callback_query.message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_user_birthday(
|
||||||
|
message: types.Message,
|
||||||
|
):
|
||||||
|
text = (
|
||||||
|
"Отправьте дату рождения пользователя в формате dd.mm.yyyy\n"
|
||||||
|
"Пример: `26.05.1995`."
|
||||||
|
)
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
keyboard.button(text=constants.BACK_BUTTON)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_name(
|
||||||
|
message: types.Message,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
subscription_user_id: uuid.UUID,
|
||||||
|
):
|
||||||
|
async with users_repository.transaction():
|
||||||
|
subscription_user = await users_repository.get_user_by_id(user_id=subscription_user_id)
|
||||||
|
if subscription_user is None or subscription_user.birthday is None:
|
||||||
|
raise FlowInternalError()
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"Введите имя человека, на которого хотите подписаться\n\n"
|
||||||
|
)
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
if subscription_user.name is not None:
|
||||||
|
text += (
|
||||||
|
"ИЛИ\n\n"
|
||||||
|
f"Используйте имя из профиля пользователя: `{subscription_user.name}`\n\n"
|
||||||
|
)
|
||||||
|
keyboard.button(text=subscription_user.name)
|
||||||
|
keyboard.button(text=constants.BACK_BUTTON)
|
||||||
|
keyboard.adjust(1)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_pool_decision(
|
||||||
|
message: types.Message,
|
||||||
|
pools_repository: PoolsRepository,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
subscription_user_id: uuid.UUID,
|
||||||
|
):
|
||||||
|
async with users_repository.transaction():
|
||||||
|
subscription_user = await users_repository.get_user_by_id(user_id=subscription_user_id)
|
||||||
|
if subscription_user is None or subscription_user.birthday is None:
|
||||||
|
raise FlowInternalError()
|
||||||
|
|
||||||
|
async with pools_repository.transaction():
|
||||||
|
pools_count = await pools_repository.get_pools_by_birthday_user_id_count(
|
||||||
|
birthday_user_id=subscription_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = "Хотите ли вы участвовать в сборе денег для этого пользователя?"
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
keyboard.button(text=constants.CREATE_POOL_BUTTON)
|
||||||
|
if pools_count > 0:
|
||||||
|
keyboard.button(text=constants.JOIN_EXISTING_POOL_BUTTON)
|
||||||
|
keyboard.button(text=constants.DECLINE_POOL_BUTTON)
|
||||||
|
keyboard.button(text=constants.BACK_BUTTON)
|
||||||
|
keyboard.adjust(1)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def show_add_subscription_pools(
|
||||||
|
user: User,
|
||||||
|
pools_repository: PoolsRepository,
|
||||||
|
message: types.Message | None = None,
|
||||||
|
callback_query: types.CallbackQuery | None = None,
|
||||||
|
):
|
||||||
|
page = 1
|
||||||
|
per_page = 5
|
||||||
|
if callback_query is not None:
|
||||||
|
callback_data = SubscriptionsCallbackData.unpack(callback_query.data)
|
||||||
|
page = callback_data.page
|
||||||
|
|
||||||
|
async with subscriptions_repository.transaction():
|
||||||
|
total = await subscriptions_repository.get_user_subscriptions_count(user_id=user.id)
|
||||||
|
pages_count = total // per_page + int(bool(total % per_page))
|
||||||
|
subscriptions = [
|
||||||
|
subscription
|
||||||
|
async for subscription in subscriptions_repository.get_user_subscriptions(
|
||||||
|
user_id=user.id,
|
||||||
|
pagination=PagePagination(page=page, per_page=per_page),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
text = "Сборы:" if subscriptions else "Нет сборов"
|
||||||
|
keyboard = InlineKeyboardBuilder()
|
||||||
|
for subscription in subscriptions:
|
||||||
|
keyboard.button(
|
||||||
|
text=subscription.name,
|
||||||
|
callback_data=SubscriptionCallbackData(
|
||||||
|
to_user_id=subscription.to_user_id,
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
navigation_row = []
|
||||||
|
if page > 1:
|
||||||
|
navigation_row.append(types.InlineKeyboardButton(
|
||||||
|
text="<",
|
||||||
|
callback_data=SubscriptionsCallbackData(page=page - 1).pack(),
|
||||||
|
))
|
||||||
|
if pages_count > 1:
|
||||||
|
navigation_row.append(types.InlineKeyboardButton(
|
||||||
|
text=f"{page}/{pages_count}",
|
||||||
|
callback_data="null",
|
||||||
|
))
|
||||||
|
if page < pages_count:
|
||||||
|
navigation_row.append(types.InlineKeyboardButton(
|
||||||
|
text=">",
|
||||||
|
callback_data=SubscriptionsCallbackData(page=page + 1).pack(),
|
||||||
|
))
|
||||||
|
keyboard.row(*navigation_row)
|
||||||
|
keyboard.row(
|
||||||
|
types.InlineKeyboardButton(
|
||||||
|
text="Добавить",
|
||||||
|
callback_data=AddSubscriptionCallbackData().pack(),
|
||||||
|
),
|
||||||
|
types.InlineKeyboardButton(
|
||||||
|
text="Назад",
|
||||||
|
callback_data=MenuCallbackData().pack(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
reply_markup = keyboard.as_markup()
|
||||||
|
|
||||||
|
if message is not None:
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
elif callback_query is not None:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
)
|
||||||
|
await callback_query.message.edit_reply_markup(
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_pool_description(
|
||||||
|
message: types.Message,
|
||||||
|
):
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
keyboard.button(text=constants.SKIP_BUTTON)
|
||||||
|
keyboard.button(text=constants.BACK_BUTTON)
|
||||||
|
keyboard.adjust(1)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text="Введи описание для сбора",
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_pool_payment_phone(
|
||||||
|
message: types.Message,
|
||||||
|
user: User,
|
||||||
|
):
|
||||||
|
text = (
|
||||||
|
"*Введите номер телефона, на который будете принимать сбор денег*\n\n"
|
||||||
|
"ИЛИ\n\n"
|
||||||
|
"*Отправьте свой контакт*\n\n"
|
||||||
|
)
|
||||||
|
if user.gift_payment_data is not None:
|
||||||
|
bank_title = constants.BANKS_TITLE_MAP[user.gift_payment_data.bank]
|
||||||
|
text += (
|
||||||
|
"ИЛИ\n\n"
|
||||||
|
"*Используйте данные из своего профиля*:\n"
|
||||||
|
f"📱 _Телефон_: {user.gift_payment_data.phone}\n"
|
||||||
|
f"🏦 _Банк_: {bank_title}\n\n"
|
||||||
|
)
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
keyboard.button(text=constants.SHARE_CONTACT_BUTTON, request_contact=True)
|
||||||
|
if user.gift_payment_data is not None:
|
||||||
|
keyboard.button(text=constants.USE_PROFILE_GIFT_PAYMENT_DATA)
|
||||||
|
keyboard.button(text=constants.BACK_BUTTON)
|
||||||
|
keyboard.adjust(1)
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_pool_payment_bank(
|
||||||
|
message: types.Message,
|
||||||
|
):
|
||||||
|
text = "Выберите банк для приёма платежей"
|
||||||
|
|
||||||
|
keyboard = ReplyKeyboardBuilder()
|
||||||
|
for title, type_ in constants.BANKS_MAP.items():
|
||||||
|
keyboard.button(text=title)
|
||||||
|
keyboard.adjust(3)
|
||||||
|
keyboard.row(types.KeyboardButton(text=constants.BACK_BUTTON))
|
||||||
|
reply_markup = keyboard.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_add_subscription_confirmation(
|
||||||
|
message: types.Message,
|
||||||
|
users_repository: UsersRepository,
|
||||||
|
pools_repository: PoolsRepository,
|
||||||
|
subscription_name: str,
|
||||||
|
subscription_user_id: uuid.UUID,
|
||||||
|
subscription_pool_id: uuid.UUID | None = None,
|
||||||
|
subscription_pool_description: str | None = None,
|
||||||
|
subscription_pool_phone: str | None = None,
|
||||||
|
subscription_pool_bank: BankEnum | None = None,
|
||||||
|
):
|
||||||
|
async with users_repository.transaction():
|
||||||
|
subscription_user = await users_repository.get_user_by_id(
|
||||||
|
user_id=subscription_user_id,
|
||||||
|
)
|
||||||
|
if subscription_user is None:
|
||||||
|
raise FlowInternalError()
|
||||||
|
|
||||||
|
subscription_pool = None
|
||||||
|
if subscription_pool_id is not None:
|
||||||
|
async with pools_repository.transaction():
|
||||||
|
subscription_pool = await pools_repository.get_pool_by_id(
|
||||||
|
pool_id=subscription_pool_id,
|
||||||
|
with_owner=True,
|
||||||
|
)
|
||||||
|
if subscription_pool is None:
|
||||||
|
raise FlowInternalError()
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"Вы хотите подписаться на пользователя {subscription_name}?\n\n"
|
||||||
|
f"🎂 *День рождения*: {subscription_user.birthday.strftime('%d.%m.%Y')}\n\n"
|
||||||
|
)
|
||||||
|
if all((subscription_pool_description, subscription_pool_phone, subscription_pool_bank)):
|
||||||
|
bank_title = constants.BANKS_TITLE_MAP[subscription_pool_bank]
|
||||||
|
text += (
|
||||||
|
"💳️ *Вы собираете деньги на*:\n"
|
||||||
|
f"📱 _Телефон_: {subscription_pool_phone}\n"
|
||||||
|
f"🏦 _Банк_: {bank_title}\n\n"
|
||||||
|
)
|
||||||
|
if subscription_pool_description:
|
||||||
|
text += (
|
||||||
|
"_Описание_:\n"
|
||||||
|
"```\n"
|
||||||
|
f"{subscription_pool_description}"
|
||||||
|
"```\n\n"
|
||||||
|
)
|
||||||
|
elif subscription_pool is not None:
|
||||||
|
text += "💳️ ️️️*Вы участвуете в сборе денег*\n\n"
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardBuilder()
|
||||||
|
keyboard.button(
|
||||||
|
text="Да",
|
||||||
|
callback_data=AddSubscriptionConfirmCallbackData(
|
||||||
|
answer=ConfirmAnswerEnum.YES,
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
keyboard.button(
|
||||||
|
text="Нет",
|
||||||
|
callback_data=AddSubscriptionConfirmCallbackData(
|
||||||
|
answer=ConfirmAnswerEnum.NO,
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
keyboard.adjust(2)
|
||||||
|
reply_markup = keyboard.as_markup()
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
text=text,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
13
birthday_pool_bot/telegram_bot/ui/middlewares/__init__.py
Normal file
13
birthday_pool_bot/telegram_bot/ui/middlewares/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from .auth import AuthMiddleware
|
||||||
|
from .depends import DependsMiddleware
|
||||||
|
from .typing import TypingMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
# auth
|
||||||
|
"AuthMiddleware",
|
||||||
|
# depends
|
||||||
|
"DependsMiddleware",
|
||||||
|
# typing
|
||||||
|
"TypingMiddleware",
|
||||||
|
)
|
||||||
47
birthday_pool_bot/telegram_bot/ui/middlewares/auth.py
Normal file
47
birthday_pool_bot/telegram_bot/ui/middlewares/auth.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
import aiogram
|
||||||
|
from aiogram.types import TelegramObject, User as TelegramUser
|
||||||
|
|
||||||
|
from birthday_pool_bot.dto import User
|
||||||
|
from birthday_pool_bot.repositories.repositories import UsersRepository
|
||||||
|
from birthday_pool_bot.telegram_bot.ui.utils import get_telegram_user_full_name
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMiddleware(aiogram.BaseMiddleware):
|
||||||
|
def __init__(self, users_repository: UsersRepository):
|
||||||
|
self._users_repository = users_repository
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: TelegramObject,
|
||||||
|
data: dict[str, Any],
|
||||||
|
):
|
||||||
|
telegram_user = self.get_telegram_user(event=event)
|
||||||
|
if telegram_user is None:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
async with self._users_repository.transaction():
|
||||||
|
user = await self._users_repository.get_user_by_telegram_id(
|
||||||
|
telegram_id=telegram_user.id,
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
user = User(
|
||||||
|
name=get_telegram_user_full_name(user=telegram_user),
|
||||||
|
telegram_id=telegram_user.id,
|
||||||
|
)
|
||||||
|
user = await self._users_repository.create_user(user=user)
|
||||||
|
|
||||||
|
data["user"] = user
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
def get_telegram_user(self, event: TelegramObject) -> TelegramUser | None:
|
||||||
|
if hasattr(event, "from_user") and isinstance(event.from_user, TelegramUser):
|
||||||
|
return event.from_user
|
||||||
|
|
||||||
|
if hasattr(event, "user") and isinstance(event.user, TelegramUser):
|
||||||
|
return event.user
|
||||||
|
|
||||||
|
if hasattr(event, "new_chat_member") and event.new_chat_member:
|
||||||
|
return event.new_chat_member.user
|
||||||
19
birthday_pool_bot/telegram_bot/ui/middlewares/depends.py
Normal file
19
birthday_pool_bot/telegram_bot/ui/middlewares/depends.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
import aiogram
|
||||||
|
from aiogram.types import TelegramObject
|
||||||
|
|
||||||
|
|
||||||
|
class DependsMiddleware(aiogram.BaseMiddleware):
|
||||||
|
def __init__(self, name: str, object_: Any):
|
||||||
|
self._name = name
|
||||||
|
self._object = object_
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: TelegramObject,
|
||||||
|
data: dict[str, Any],
|
||||||
|
):
|
||||||
|
data[self._name] = self._object
|
||||||
|
return await handler(event, data)
|
||||||
36
birthday_pool_bot/telegram_bot/ui/middlewares/typing.py
Normal file
36
birthday_pool_bot/telegram_bot/ui/middlewares/typing.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
import aiogram
|
||||||
|
from aiogram import types
|
||||||
|
|
||||||
|
|
||||||
|
class TypingMiddleware(aiogram.BaseMiddleware):
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[types.TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: types.TelegramObject,
|
||||||
|
data: dict[str, Any],
|
||||||
|
):
|
||||||
|
if not isinstance(event, types.Message):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
bot = data.get("bot")
|
||||||
|
chat_id = event.chat.id
|
||||||
|
async with send_typing_action(bot=bot, chat_id=chat_id):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def send_typing_action(bot: aiogram.Bot, chat_id: int):
|
||||||
|
async def background_task():
|
||||||
|
while True:
|
||||||
|
await bot.send_chat_action(chat_id=chat_id, action="typing")
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
task = asyncio.create_task(background_task())
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
38
birthday_pool_bot/telegram_bot/ui/states.py
Normal file
38
birthday_pool_bot/telegram_bot/ui/states.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
class MenuState(StatesGroup):
|
||||||
|
MENU = State()
|
||||||
|
PROFILE = State()
|
||||||
|
SUBSCRIPTIONS = State()
|
||||||
|
|
||||||
|
|
||||||
|
class SetProfileNameState(StatesGroup):
|
||||||
|
WAITING_FOR_NAME = State()
|
||||||
|
|
||||||
|
|
||||||
|
class SetProfilePhoneState(StatesGroup):
|
||||||
|
WAITING_FOR_PHONE = State()
|
||||||
|
|
||||||
|
|
||||||
|
class SetProfileBirthdayState(StatesGroup):
|
||||||
|
WAITING_FOR_DATE = State()
|
||||||
|
|
||||||
|
|
||||||
|
class SetProfileGiftPaymentDataState(StatesGroup):
|
||||||
|
WAITING_FOR_PHONE = State()
|
||||||
|
WAITING_FOR_BANK = State()
|
||||||
|
|
||||||
|
|
||||||
|
class AddSubscriptionState(StatesGroup):
|
||||||
|
WAITING_FOR_PHONE = State()
|
||||||
|
WAITING_FOR_DATE = State()
|
||||||
|
WAITING_FOR_NAME = State()
|
||||||
|
WAITING_FOR_POOL_DECISION = State()
|
||||||
|
WAITING_FOR_CONFIRMATION = State()
|
||||||
|
|
||||||
|
|
||||||
|
class AddSubscriptionPoolState(StatesGroup):
|
||||||
|
WAITING_FOR_DESCRIPTION = State()
|
||||||
|
WAITING_FOR_PAYMENT_PHONE = State()
|
||||||
|
WAITING_FOR_PAYMENT_BANK = State()
|
||||||
25
birthday_pool_bot/telegram_bot/ui/utils.py
Normal file
25
birthday_pool_bot/telegram_bot/ui/utils.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
|
from aiogram import types
|
||||||
|
from aiogram.types import User as TelegramUser
|
||||||
|
|
||||||
|
|
||||||
|
BIRTHDATE_REGEXP = re.compile("^(?P<day>\d{2})\.(?P<month>\d{2})\.(?P<year>\d{4})$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(message: types.Message) -> datetime.date:
|
||||||
|
if (re_match := BIRTHDATE_REGEXP.match(message.text)) is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
return datetime.date(
|
||||||
|
year=int(re_match.group("year")),
|
||||||
|
month=int(re_match.group("month")),
|
||||||
|
day=int(re_match.group("day")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_telegram_user_full_name(user: TelegramUser) -> str:
|
||||||
|
name_parts = (user.first_name, user.last_name)
|
||||||
|
return " ".join(filter(None, name_parts))
|
||||||
|
|
||||||
10
birthday_pool_bot/telegram_bot/webhook/__init__.py
Normal file
10
birthday_pool_bot/telegram_bot/webhook/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from .service import TelegramBotWebhookService
|
||||||
|
from .settings import TelegramBotWebhookSettings
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
# service
|
||||||
|
"TelegramBotWebhookService",
|
||||||
|
# settings
|
||||||
|
"TelegramBotWebhookSettings",
|
||||||
|
)
|
||||||
49
birthday_pool_bot/telegram_bot/webhook/service.py
Normal file
49
birthday_pool_bot/telegram_bot/webhook/service.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiogram
|
||||||
|
import fastapi
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from birthday_pool_bot.telegram_bot.base import BaseTelegramBotService
|
||||||
|
|
||||||
|
|
||||||
|
class UvicornServer(uvicorn.Server):
|
||||||
|
def install_signal_handlers(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotWebhookService(BaseTelegramBotService):
|
||||||
|
async def listen_events(self):
|
||||||
|
await self._bot.set_webhook(
|
||||||
|
url=self._settings.url,
|
||||||
|
secret_token=self._settings.secret_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.get_server().serve()
|
||||||
|
|
||||||
|
def get_server(self) -> uvicorn.Server:
|
||||||
|
app = fastapi.FastAPI(
|
||||||
|
root_url=self._settings.root_url,
|
||||||
|
root_path=self._settings.root_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.post(self._webhook_path)(self.handler)
|
||||||
|
|
||||||
|
config = uvicorn.Config(app=app, host="0.0.0.0", port=self._port)
|
||||||
|
return UvicornServer(config)
|
||||||
|
|
||||||
|
async def handler(
|
||||||
|
self,
|
||||||
|
update: dict[str, Any],
|
||||||
|
x_telegram_bot_api_secret_token: str | None = fastapi.Header(None),
|
||||||
|
):
|
||||||
|
if x_telegram_bot_api_secret_token != self._secret_token:
|
||||||
|
raise fastapi.HTTPException(
|
||||||
|
status_code=fastapi.status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Forbidden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._dispatcher.feed_webhook_update(
|
||||||
|
bot=self._bot,
|
||||||
|
update=aiogram.types.Update(**update),
|
||||||
|
)
|
||||||
15
birthday_pool_bot/telegram_bot/webhook/settings.py
Normal file
15
birthday_pool_bot/telegram_bot/webhook/settings.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import AnyHttpUrl, conint, constr
|
||||||
|
|
||||||
|
from birthday_pool_bot.telegram_bot.enums import TelegramBotMethodEnum
|
||||||
|
from birthday_pool_bot.telegram_bot.settings import TelegramBotSettings
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotWebhookSettings(TelegramBotSettings):
|
||||||
|
method: Literal[TelegramBotMethodEnum.WEBHOOK.value] = TelegramBotMethodEnum.WEBHOOK
|
||||||
|
|
||||||
|
root_url: AnyHttpUrl
|
||||||
|
root_path: str = "/"
|
||||||
|
port: conint(ge=1, le=65535) = 8000
|
||||||
|
secret_access_key: constr(pattern=r"^\w{32}$") = "webhooksuperpupersecretaccesskey"
|
||||||
8
docker-compose.yaml
Normal file
8
docker-compose.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
telegram-bot:
|
||||||
|
notifications:
|
||||||
|
database:
|
||||||
|
|
||||||
|
volumes:
|
||||||
46
pyproject.toml
Normal file
46
pyproject.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
[project]
|
||||||
|
name = "birthday_pool_bot"
|
||||||
|
version = "0.0.0"
|
||||||
|
description = "Service for organize gathering birthday gifts"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"alembic>=1.17.2",
|
||||||
|
"apscheduler>=3.11.2",
|
||||||
|
"facet>=0.10.1",
|
||||||
|
"pydantic>=2.12.5",
|
||||||
|
"pydantic-extra-types[phonenumbers]>=2.10.6",
|
||||||
|
"pydantic-filters",
|
||||||
|
"pydantic-settings>=2.12.0",
|
||||||
|
"rich>=14.2.0",
|
||||||
|
"sqlalchemy>=2.0.44",
|
||||||
|
"sqlmodel>=0.0.27",
|
||||||
|
"typer>=0.20.0",
|
||||||
|
"aiogram>=3.23.0",
|
||||||
|
"uvicorn>=0.38.0",
|
||||||
|
"fastapi>=0.124.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
sqlite = [
|
||||||
|
"aiosqlite>=0.21.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
pydantic-filters = { git = "https://github.com/OlegYurchik/pydantic-filters", rev = "2ca8b822d59feaf5f19f36b570974d314ba5e330" }
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
include = ["birthday_pool_bot"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
include = ["birthday_pool_bot"]
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "hug"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
Reference in New Issue
Block a user