From 75e99ca0712a2c09230e5c6f8d093dc526cc717d Mon Sep 17 00:00:00 2001 From: Tolmachev Igor Date: Mon, 20 Apr 2026 20:56:35 +0300 Subject: Add users command --- handlers/admin/__init__.py | 4 +- handlers/admin/add_user.py | 99 -------- handlers/admin/users.py | 557 +++++++++++++++++++++++++++++++++++++++++++ handlers/middleware.py | 24 ++ handlers/user/info.py | 4 +- handlers/user/invoices.py | 16 +- handlers/user/pay_invoice.py | 5 +- handlers/user/payments.py | 12 +- 8 files changed, 601 insertions(+), 120 deletions(-) delete mode 100644 handlers/admin/add_user.py create mode 100644 handlers/admin/users.py (limited to 'handlers') diff --git a/handlers/admin/__init__.py b/handlers/admin/__init__.py index 4593ad7..bc603c0 100644 --- a/handlers/admin/__init__.py +++ b/handlers/admin/__init__.py @@ -4,7 +4,7 @@ from aiogram.filters import MagicData # isort: off from . import new_announcement from . import new_invoice -from . import add_user +from . import users from . import payment_status # isort: on @@ -15,6 +15,6 @@ router.callback_query.filter(MagicData(F.user.is_admin())) router.include_routers( new_announcement.router, new_invoice.router, - add_user.router, + users.router, payment_status.router, ) diff --git a/handlers/admin/add_user.py b/handlers/admin/add_user.py deleted file mode 100644 index 1d19834..0000000 --- a/handlers/admin/add_user.py +++ /dev/null @@ -1,99 +0,0 @@ -from datetime import UTC, datetime - -from aiogram import F, Router -from aiogram.enums.button_style import ButtonStyle -from aiogram.filters import Command -from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import State, StatesGroup -from aiogram.types import ( - KeyboardButton, - KeyboardButtonRequestUsers, - Message, - ReplyKeyboardMarkup, - ReplyKeyboardRemove, -) -from pydantic import BaseModel -from sqlalchemy.ext.asyncio import AsyncSession - -from libs.fsm import edit_data, get_data, set_data -from models import User - -router = Router(name="add_user") - - -class AddUserStates(StatesGroup): - user_id = State() - vpn_link = State() - - -class AddUserData(BaseModel): - user_id: int | None = None - - -CANCEL_BUTTON = "Отменить добавление" - - -@router.message(Command("add_user")) -async def command(msg: Message, state: FSMContext) -> None: - await msg.answer( - "Выберете пользователя которого хотите добавить.", - reply_markup=ReplyKeyboardMarkup( - keyboard=[ - [ - KeyboardButton( - text="Выбрать пользователя", - style=ButtonStyle.PRIMARY, - request_users=KeyboardButtonRequestUsers(request_id=0), - ), - ], - [ - KeyboardButton(text=CANCEL_BUTTON, style=ButtonStyle.DANGER), - ], - ], - resize_keyboard=True, - ), - ) - await set_data(state, AddUserData(user_id=None)) - await state.set_state(AddUserStates.user_id) - - -@router.message(AddUserStates(), F.text == CANCEL_BUTTON) -async def cancel(msg: Message, state: FSMContext) -> None: - await msg.answer( - "Добавление пользователей отменено", - reply_markup=ReplyKeyboardRemove(), - ) - await state.clear() - - -@router.message(AddUserStates.user_id) -async def set_user_id(msg: Message, state: FSMContext) -> None: - if msg.users_shared is None: - await msg.answer("Вы должны воспользоваться кнопкой ниже.") - return - - async with edit_data(state, AddUserData) as data: - data.user_id = msg.users_shared.users[0].user_id - - await msg.answer("Укажите ссылку для доступа к VPN") - await state.set_state(AddUserStates.vpn_link) - - -@router.message(AddUserStates.vpn_link) -async def set_vpn_link( - msg: Message, - state: FSMContext, - session: AsyncSession, -) -> None: - if msg.text is None: - await msg.answer("Вы должны указать ссылку отправив текстовое сообщение.") - return - - data = await get_data(state, AddUserData) - assert data.user_id is not None - - session.add(User(id=data.user_id, vpn_link=msg.text, datetime=datetime.now(UTC))) - await session.flush() - - await msg.answer("Пользователь добавлен.", reply_markup=ReplyKeyboardRemove()) - await state.clear() diff --git a/handlers/admin/users.py b/handlers/admin/users.py new file mode 100644 index 0000000..88dfd89 --- /dev/null +++ b/handlers/admin/users.py @@ -0,0 +1,557 @@ +from datetime import UTC, datetime +from math import ceil + +from aiogram import Bot, F, Router +from aiogram.enums import ButtonStyle +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import ( + CallbackQuery, + InlineKeyboardButton, + InlineKeyboardMarkup, + KeyboardButton, + KeyboardButtonRequestUsers, + Message, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, +) +from pydantic import BaseModel +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.functions import count + +from libs.fsm import edit_data, get_data, set_data +from libs.user import load_user_cache +from models import Payment, User, UserRole +from models.callback_data import ( + UserAddClb, + UserDeleteClb, + UserDeleteConfirmClb, + UserItemClb, + UserPageClb, + UserRoleClb, + UserRoleSetClb, + UserVpnLinkClb, +) + +router = Router(name="users") +PAGE_SIZE = 5 +ROLE_ICON = { + UserRole.ADMIN: "👑", + UserRole.REGULAR: "👤", +} +ROLE_NAME = { + UserRole.ADMIN: "Администратор", + UserRole.REGULAR: "Пользователь", +} + +LIST_TEXT = "Список пользователей:" +ADD_CANCEL_BUTTON = "Отменить добавление" +EDIT_CANCEL_BUTTON = "Отменить изменение" + + +class AddUserStates(StatesGroup): + user_id = State() + vpn_link = State() + + +class AddUserData(BaseModel): + user_id: int | None = None + + +class EditVpnLinkStates(StatesGroup): + vpn_link = State() + + +class EditVpnLinkData(BaseModel): + page: int + user_id: int + + +async def get_list_markup( + page: int, + bot: Bot, + session: AsyncSession, +) -> InlineKeyboardMarkup: + total = await session.scalar(select(count()).select_from(User)) + assert total is not None + total_pages = max(1, ceil(total / PAGE_SIZE)) + page = max(0, min(page, total_pages - 1)) + + users = await session.scalars( + select(User).offset(PAGE_SIZE * page).limit(PAGE_SIZE).order_by(User.id.desc()) + ) + + user_buttons = [] + for u in users: + user_cache = await load_user_cache(bot, u.id) + user_buttons.append( + [ + InlineKeyboardButton( + text=f"{ROLE_ICON[u.role]} {user_cache.full_name}", + callback_data=UserItemClb(page=page, user_id=u.id).pack(), + ) + ] + ) + + page_buttons = [] + if page > 0: + page_buttons.append( + InlineKeyboardButton( + text="◀️", + callback_data=UserPageClb(page=page - 1).pack(), + ) + ) + if page < total_pages - 1: + page_buttons.append( + InlineKeyboardButton( + text="▶️", + callback_data=UserPageClb(page=page + 1).pack(), + ) + ) + + add_row = [ + InlineKeyboardButton( + text="➕ Добавить пользователя", + callback_data=UserAddClb().pack(), + ) + ] + + return InlineKeyboardMarkup(inline_keyboard=[*user_buttons, page_buttons, add_row]) + + +def build_item_markup(user_id: int, page: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="Поменять роль", + callback_data=UserRoleClb(page=page, user_id=user_id).pack(), + ), + ], + [ + InlineKeyboardButton( + text="Изменить VPN ссылку", + callback_data=UserVpnLinkClb(page=page, user_id=user_id).pack(), + ), + ], + [ + InlineKeyboardButton( + text="Удалить пользователя", + style=ButtonStyle.DANGER, + callback_data=UserDeleteClb(page=page, user_id=user_id).pack(), + ), + ], + [ + InlineKeyboardButton( + text="Назад к списку", + callback_data=UserPageClb(page=page).pack(), + ), + ], + ] + ) + + +def build_role_markup(user_id: int, page: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=f"{ROLE_ICON[UserRole.REGULAR]} {ROLE_NAME[UserRole.REGULAR]}", + callback_data=UserRoleSetClb( + page=page, + user_id=user_id, + role=UserRole.REGULAR, + ).pack(), + ), + ], + [ + InlineKeyboardButton( + text=f"{ROLE_ICON[UserRole.ADMIN]} {ROLE_NAME[UserRole.ADMIN]}", + callback_data=UserRoleSetClb( + page=page, + user_id=user_id, + role=UserRole.ADMIN, + ).pack(), + ), + ], + [ + InlineKeyboardButton( + text="Назад", + callback_data=UserItemClb(page=page, user_id=user_id).pack(), + ), + ], + ] + ) + + +def build_delete_markup(user_id: int, page: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="❌ Удалить", + style=ButtonStyle.DANGER, + callback_data=UserDeleteConfirmClb( + page=page, + user_id=user_id, + ).pack(), + ), + ], + [ + InlineKeyboardButton( + text="Назад", + callback_data=UserItemClb(page=page, user_id=user_id).pack(), + ), + ], + ] + ) + + +async def get_item_text(bot: Bot, target: User) -> str: + user_cache = await load_user_cache(bot, target.id) + return ( + f"Пользователь: {user_cache.mention}\n" + f"Роль: {ROLE_NAME[target.role]}\n" + f"VPN ссылка: {target.vpn_link}" + ) + + +@router.message(Command("users")) +async def command(msg: Message, bot: Bot, session: AsyncSession) -> None: + await msg.answer( + LIST_TEXT, + reply_markup=await get_list_markup(0, bot, session), + ) + + +@router.callback_query(UserPageClb.filter()) +async def page( + clb: CallbackQuery, + bot: Bot, + callback_data: UserPageClb, + session: AsyncSession, +) -> None: + assert isinstance(clb.message, Message) + await clb.message.edit_text( + LIST_TEXT, + reply_markup=await get_list_markup(callback_data.page, bot, session), + ) + await clb.answer() + + +@router.callback_query(UserItemClb.filter()) +async def item( + clb: CallbackQuery, + bot: Bot, + callback_data: UserItemClb, + session: AsyncSession, +) -> None: + assert isinstance(clb.message, Message) + target = await session.get(User, callback_data.user_id) + if target is None: + await clb.answer("Пользователь не найден.", show_alert=True) + return + + await clb.message.edit_text( + await get_item_text(bot, target), + reply_markup=build_item_markup(target.id, callback_data.page), + ) + await clb.answer() + + +@router.callback_query(UserRoleClb.filter()) +async def role_menu( + clb: CallbackQuery, + callback_data: UserRoleClb, + session: AsyncSession, + user: User, +) -> None: + assert isinstance(clb.message, Message) + if user.id == callback_data.user_id: + await clb.answer("Нельзя поменять свою роль.", show_alert=True) + return + + target = await session.get(User, callback_data.user_id) + if target is None: + await clb.answer("Пользователь не найден.", show_alert=True) + return + + await clb.message.edit_text( + f"Выберете новую роль для пользователя (текущая: {ROLE_NAME[target.role]}):", + reply_markup=build_role_markup(target.id, callback_data.page), + ) + await clb.answer() + + +@router.callback_query(UserRoleSetClb.filter()) +async def role_set( + clb: CallbackQuery, + bot: Bot, + callback_data: UserRoleSetClb, + session: AsyncSession, + user: User, +) -> None: + assert isinstance(clb.message, Message) + if user.id == callback_data.user_id: + await clb.answer("Нельзя поменять свою роль.", show_alert=True) + return + + target = await session.get(User, callback_data.user_id) + if target is None: + await clb.answer("Пользователь не найден.", show_alert=True) + return + + if target.role != callback_data.role: + target.role = callback_data.role + await session.flush() + + await clb.message.edit_text( + await get_item_text(bot, target), + reply_markup=build_item_markup(target.id, callback_data.page), + ) + await clb.answer(f"Роль: {ROLE_NAME[target.role]}.") + + +@router.callback_query(UserDeleteClb.filter()) +async def delete_menu( + clb: CallbackQuery, + bot: Bot, + callback_data: UserDeleteClb, + session: AsyncSession, + user: User, +) -> None: + assert isinstance(clb.message, Message) + if user.id == callback_data.user_id: + await clb.answer("Нельзя удалить себя.", show_alert=True) + return + + target = await session.get(User, callback_data.user_id) + if target is None: + await clb.answer("Пользователь не найден.", show_alert=True) + return + + user_cache = await load_user_cache(bot, target.id) + await clb.message.edit_text( + f"Удалить пользователя {user_cache.mention}?", + reply_markup=build_delete_markup(target.id, callback_data.page), + ) + await clb.answer() + + +@router.callback_query(UserDeleteConfirmClb.filter()) +async def delete_confirm( + clb: CallbackQuery, + bot: Bot, + callback_data: UserDeleteConfirmClb, + session: AsyncSession, + user: User, +) -> None: + assert isinstance(clb.message, Message) + if user.id == callback_data.user_id: + await clb.answer("Нельзя удалить себя.", show_alert=True) + return + + target = await session.get(User, callback_data.user_id) + if target is None: + await clb.answer("Пользователь не найден.", show_alert=True) + return + + await session.execute(delete(Payment).where(Payment.user_id == target.id)) + await session.delete(target) + await session.flush() + + await clb.message.edit_text( + LIST_TEXT, + reply_markup=await get_list_markup(callback_data.page, bot, session), + ) + await clb.answer("Пользователь удалён.") + + +@router.callback_query(UserVpnLinkClb.filter()) +async def vpn_link_button( + clb: CallbackQuery, + bot: Bot, + state: FSMContext, + callback_data: UserVpnLinkClb, + session: AsyncSession, +) -> None: + assert isinstance(clb.message, Message) + target = await session.get(User, callback_data.user_id) + if target is None: + await clb.answer("Пользователь не найден.", show_alert=True) + return + + await clb.message.delete() + await bot.send_message( + clb.from_user.id, + "Укажите новую ссылку для доступа к VPN.", + reply_markup=ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text=EDIT_CANCEL_BUTTON, style=ButtonStyle.DANGER)] + ], + resize_keyboard=True, + ), + ) + await set_data( + state, + EditVpnLinkData(page=callback_data.page, user_id=callback_data.user_id), + ) + await state.set_state(EditVpnLinkStates.vpn_link) + await clb.answer() + + +async def send_item(bot: Bot, chat_id: int, target: User, page: int) -> None: + await bot.send_message( + chat_id, + await get_item_text(bot, target), + reply_markup=build_item_markup(target.id, page), + ) + + +@router.message(EditVpnLinkStates.vpn_link, F.text == EDIT_CANCEL_BUTTON) +async def vpn_link_cancel( + msg: Message, + bot: Bot, + state: FSMContext, + session: AsyncSession, +) -> None: + data = await get_data(state, EditVpnLinkData) + await state.clear() + + await msg.answer( + "Изменение VPN ссылки отменено.", + reply_markup=ReplyKeyboardRemove(), + ) + + target = await session.get(User, data.user_id) + if target is None: + return + await send_item(bot, msg.chat.id, target, data.page) + + +@router.message(EditVpnLinkStates.vpn_link) +async def vpn_link_set( + msg: Message, + bot: Bot, + state: FSMContext, + session: AsyncSession, +) -> None: + if msg.text is None: + await msg.answer("Вы должны указать ссылку отправив текстовое сообщение.") + return + + data = await get_data(state, EditVpnLinkData) + target = await session.get(User, data.user_id) + if target is None: + await msg.answer( + "Пользователь не найден.", + reply_markup=ReplyKeyboardRemove(), + ) + await state.clear() + return + + target.vpn_link = msg.text + await session.flush() + await state.clear() + + await msg.answer("VPN ссылка обновлена.", reply_markup=ReplyKeyboardRemove()) + await send_item(bot, msg.chat.id, target, data.page) + + +@router.callback_query(UserAddClb.filter()) +async def add_button( + clb: CallbackQuery, + bot: Bot, + state: FSMContext, +) -> None: + assert isinstance(clb.message, Message) + await clb.message.delete() + await bot.send_message( + clb.from_user.id, + "Выберете пользователя которого хотите добавить.", + reply_markup=ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton( + text="Выбрать пользователя", + style=ButtonStyle.PRIMARY, + request_users=KeyboardButtonRequestUsers(request_id=0), + ), + ], + [KeyboardButton(text=ADD_CANCEL_BUTTON, style=ButtonStyle.DANGER)], + ], + resize_keyboard=True, + ), + ) + await set_data(state, AddUserData(user_id=None)) + await state.set_state(AddUserStates.user_id) + await clb.answer() + + +@router.message(AddUserStates(), F.text == ADD_CANCEL_BUTTON) +async def add_cancel( + msg: Message, + bot: Bot, + state: FSMContext, + session: AsyncSession, +) -> None: + await msg.answer( + "Добавление пользователя отменено.", + reply_markup=ReplyKeyboardRemove(), + ) + + await msg.answer( + LIST_TEXT, + reply_markup=await get_list_markup(0, bot, session), + ) + await state.clear() + + +@router.message(AddUserStates.user_id) +async def add_set_user_id(msg: Message, state: FSMContext) -> None: + if msg.users_shared is None: + await msg.answer("Вы должны воспользоваться кнопкой ниже.") + return + + async with edit_data(state, AddUserData) as data: + data.user_id = msg.users_shared.users[0].user_id + + await msg.answer( + "Укажите ссылку для доступа к VPN.", + reply_markup=ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text=ADD_CANCEL_BUTTON, style=ButtonStyle.DANGER)] + ], + resize_keyboard=True, + ), + ) + await state.set_state(AddUserStates.vpn_link) + + +@router.message(AddUserStates.vpn_link) +async def add_set_vpn_link( + msg: Message, + bot: Bot, + state: FSMContext, + session: AsyncSession, +) -> None: + if msg.text is None: + await msg.answer("Вы должны указать ссылку отправив текстовое сообщение.") + return + + data = await get_data(state, AddUserData) + assert data.user_id is not None + + session.add(User(id=data.user_id, vpn_link=msg.text, datetime=datetime.now(UTC))) + await session.flush() + + await msg.answer("Пользователь добавлен.", reply_markup=ReplyKeyboardRemove()) + + target = await session.get(User, data.user_id) + if target is None: + return + + await send_item(bot, msg.chat.id, target, 0) + await state.clear() diff --git a/handlers/middleware.py b/handlers/middleware.py index 87a117a..376f4a2 100644 --- a/handlers/middleware.py +++ b/handlers/middleware.py @@ -5,7 +5,9 @@ from aiogram.types import CallbackQuery, Message, TelegramObject from sqlalchemy.ext.asyncio import AsyncSession from database import sessions +from libs.user import set_user_cache from models import User +from models.user import UserCache class InjectSessionMiddleware(BaseMiddleware): @@ -54,3 +56,25 @@ class UserAccessMiddleware(BaseMiddleware): data["user"] = user return await handler(event, data) + + +class UserCacheMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + if not isinstance(event, (Message, CallbackQuery)): + raise TypeError( + f"UserAccessMiddleware doesn't support event with type: {type(event).__name__}", + event, + ) + + if isinstance(event, Message): + user_cache = UserCache.from_chat(event.chat) + else: + user_cache = UserCache.from_user(event.from_user) + + await set_user_cache(user_cache) + return await handler(event, data) diff --git a/handlers/user/info.py b/handlers/user/info.py index 9776e7e..587925f 100644 --- a/handlers/user/info.py +++ b/handlers/user/info.py @@ -47,8 +47,8 @@ ADMIN_COMMANDS = COMMANDS + [ description="Создать новый счёт на оплату", ), BotCommand( - command="add_user", - description="Создать нового пользователя", + command="users", + description="Управление пользователями", ), BotCommand( command="suggest_list", diff --git a/handlers/user/invoices.py b/handlers/user/invoices.py index cc071bb..15785fb 100644 --- a/handlers/user/invoices.py +++ b/handlers/user/invoices.py @@ -18,7 +18,7 @@ from libs.invoice import ( get_payment_status, ) from libs.msg import eclipse_text -from libs.user import mention +from libs.user import load_user_cache from models import Invoice, PaymentStatus, User from models.callback_data import InvoiceItemClb, InvoicePageClb, PayInvoiceClb @@ -166,14 +166,14 @@ async def item( ] ) - await clb.message.edit_text(text_template.format("..."), reply_markup=reply_markup) user_status = [] for user_id, s in invoice_payments.user_status.items(): - chat = await bot.get_chat(user_id) - user_status.append(f"{PAYMENT_STATUS[s]} - {mention(chat)}") - await clb.message.edit_text( - text_template.format("\n".join(user_status)), - reply_markup=reply_markup, - ) + user_cache = await load_user_cache(bot, user_id) + user_status.append(f"{PAYMENT_STATUS[s]} - {user_cache.mention}") + + await clb.message.edit_text( + text_template.format("\n".join(user_status)), + reply_markup=reply_markup, + ) await clb.answer() diff --git a/handlers/user/pay_invoice.py b/handlers/user/pay_invoice.py index db75f47..d7809cd 100644 --- a/handlers/user/pay_invoice.py +++ b/handlers/user/pay_invoice.py @@ -19,7 +19,6 @@ from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from libs.fsm import get_data, set_data -from libs.user import mention from models import ( Invoice, Payment, @@ -27,6 +26,7 @@ from models import ( ReceiptFile, ReceiptFileType, User, + UserCache, UserRole, ) from models.callback_data import PayInvoiceClb, PaymentStatusClb @@ -112,6 +112,7 @@ async def receipt( bot: Bot, state: FSMContext, session: AsyncSession, + user_cache: UserCache, ) -> None: if msg.document is not None: receipt_file = ReceiptFile( @@ -173,7 +174,7 @@ async def receipt( try: await bot.send_message( admin_id, - f"Новое подтверждение оплаты:\nПользователь: {mention(msg.chat)}", + f"Новое подтверждение оплаты:\nПользователь: {user_cache.mention}", ) await receipt_file.send(bot, admin_id, reply_markup=reply_markup) except TelegramAPIError as e: diff --git a/handlers/user/payments.py b/handlers/user/payments.py index 87ea236..e15a882 100644 --- a/handlers/user/payments.py +++ b/handlers/user/payments.py @@ -15,13 +15,13 @@ from sqlalchemy.sql.functions import count from libs.invoice import get_payment_status from libs.msg import eclipse_text -from libs.user import mention +from libs.user import load_user_cache from models import Invoice, Payment, PaymentStatus, User from models.callback_data import ( + PayInvoiceClb, PaymentItemClb, PaymentPageClb, PaymentStatusClb, - PayInvoiceClb, ) router = Router(name="payments") @@ -213,7 +213,7 @@ async def item( invoice = await session.get(Invoice, payment.invoice_id) assert invoice is not None - chat = await bot.get_chat(payment.user_id) + user_cache = await load_user_cache(bot, payment.user_id) status_buttons = [] if payment.status != PaymentStatus.ACCEPTED: @@ -242,12 +242,10 @@ async def item( text="Назад к выбору", callback_data=PaymentPageClb(page=callback_data.page).pack(), ) - reply_markup = InlineKeyboardMarkup( - inline_keyboard=[status_buttons, [back_button]] - ) + reply_markup = InlineKeyboardMarkup(inline_keyboard=[status_buttons, [back_button]]) caption = ( - f"Платёж от {mention(chat)}\n" + f"Платёж от {user_cache.mention}\n" f"Счёт: {eclipse_text(invoice.message.text, 30)}\n" f"Дата: {payment.datetime.strftime('%d %b %y г.')}\n" f"Статус: {PAYMENT_STATUS[payment.status]}" -- cgit v1.3