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 --- compose.yaml | 13 + 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 +- libs/__init__.py | 3 +- libs/storage.py | 75 ------ libs/user.py | 30 ++- models/__init__.py | 3 +- models/callback_data.py | 41 +++- models/user.py | 33 +++ pyproject.toml | 1 + settings.py | 3 - shared.py | 17 +- uv.lock | 50 ++++ 19 files changed, 782 insertions(+), 208 deletions(-) delete mode 100644 handlers/admin/add_user.py create mode 100644 handlers/admin/users.py delete mode 100644 libs/storage.py diff --git a/compose.yaml b/compose.yaml index 11f092d..45d5c13 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,6 +5,19 @@ services: - TOKEN=${TOKEN} volumes: - storage:/app/storage + develop: + watch: + - action: sync+restart + path: . + target: /app + + redis: + image: "redis:8" + restart: "always" + hostname: "redis" + volumes: + - redis:/data volumes: storage: + redis: 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]}" diff --git a/libs/__init__.py b/libs/__init__.py index 55e6b19..48677db 100644 --- a/libs/__init__.py +++ b/libs/__init__.py @@ -1,7 +1,6 @@ -from . import fsm, invoice, msg, storage, user +from . import fsm, invoice, msg, user __all__ = [ - "storage", "fsm", "msg", "user", diff --git a/libs/storage.py b/libs/storage.py deleted file mode 100644 index 6220cfc..0000000 --- a/libs/storage.py +++ /dev/null @@ -1,75 +0,0 @@ -from pathlib import Path -from typing import Any, Mapping - -from aiofiles import open as open -from aiogram.fsm.state import State -from aiogram.fsm.storage.base import ( - BaseStorage, - DefaultKeyBuilder, - KeyBuilder, - StateType, - StorageKey, -) -from pydantic import TypeAdapter -from pydantic.main import BaseModel - - -class Record(BaseModel): - data: dict[str, Any] = {} - state: str | None = None - - -class JsonStorage(BaseStorage): - file_path: Path - records: dict[str, Record] - records_adapter: TypeAdapter - key_builder: KeyBuilder - - def __init__(self, file_path: Path, key_builder: KeyBuilder | None = None) -> None: - self.file_path = file_path - self.records = {} - self.records_adapter = TypeAdapter(dict[str, Record]) - self.key_builder = DefaultKeyBuilder() if key_builder is None else key_builder - - async def read(self) -> None: - async with open(self.file_path, "rb") as file: - json = await file.read() - self.records = self.records_adapter.validate_json(json) - - async def flush(self) -> None: - async with open(self.file_path, "wb") as file: - json = self.records_adapter.dump_json(self.records) - await file.write(json) - - async def get_record(self, key: StorageKey) -> Record: - await self.read() - record_key = self.key_builder.build(key) - if record_key not in self.records: - self.records[record_key] = Record() - return self.records[record_key] - - async def set_state(self, key: StorageKey, state: StateType = None) -> None: - record = await self.get_record(key) - record.state = state.state if isinstance(state, State) else state - await self.flush() - - async def get_state(self, key: StorageKey) -> str | None: - record = await self.get_record(key) - return record.state - - async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: - if not isinstance(data, dict): - raise TypeError( - f"Data must be a dict or dict-like object, got {type(data).__name__}", - data, - ) - record = await self.get_record(key) - record.data = data.copy() - await self.flush() - - async def get_data(self, key: StorageKey) -> dict[str, Any]: - record = await self.get_record(key) - return record.data - - async def close(self) -> None: - await self.flush() diff --git a/libs/user.py b/libs/user.py index b201ce9..d145da6 100644 --- a/libs/user.py +++ b/libs/user.py @@ -1,5 +1,29 @@ -from aiogram.types import Chat, User +from aiogram import Bot +from models import UserCache +from shared import redis_users -def mention(user: User | Chat) -> str: - return f'{user.full_name}' + +async def set_user_cache(user_cache: UserCache) -> None: + await redis_users.set( + str(user_cache.id), + user_cache.model_dump_json( + exclude_defaults=True, + exclude_none=True, + ), + ) + + +async def get_user_cache(user_id: int) -> UserCache | None: + user_cache = await redis_users.get(str(user_id)) + if user_cache is None: + return None + return UserCache.model_validate_json(user_cache) + + +async def load_user_cache(bot: Bot, user_id: int) -> UserCache: + user_cache = await get_user_cache(user_id) + if user_cache is None: + user_cache = UserCache.from_chat(await bot.get_chat(user_id)) + await set_user_cache(user_cache) + return user_cache diff --git a/models/__init__.py b/models/__init__.py index f26ee74..c458f30 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,7 +1,7 @@ # isort: off from .base import BaseTable from .rich_text import RichText -from .user import User, UserRole +from .user import UserCache, User, UserRole from .invoce import Invoice from .payment import Payment, PaymentStatus, ReceiptFile, ReceiptFileType from .announcement import Announcement @@ -10,6 +10,7 @@ from . import callback_data __all__ = [ "BaseTable", + "UserCache", "User", "UserRole", "Invoice", diff --git a/models/callback_data.py b/models/callback_data.py index 5ee38bd..b1e7e56 100644 --- a/models/callback_data.py +++ b/models/callback_data.py @@ -1,6 +1,6 @@ from aiogram.filters.callback_data import CallbackData -from models import PaymentStatus +from models import PaymentStatus, UserRole class PayInvoiceClb(CallbackData, prefix="pay_invoice"): @@ -37,3 +37,42 @@ class PaymentPageClb(CallbackData, prefix="payment.p"): class PaymentItemClb(CallbackData, prefix="payment.i"): page: int payment_id: int + + +class UserPageClb(CallbackData, prefix="user.p"): + page: int + + +class UserItemClb(CallbackData, prefix="user.i"): + page: int + user_id: int + + +class UserAddClb(CallbackData, prefix="user.a"): + pass + + +class UserRoleClb(CallbackData, prefix="user.e.r"): + page: int + user_id: int + + +class UserRoleSetClb(CallbackData, prefix="user.e.r.s"): + page: int + user_id: int + role: UserRole + + +class UserDeleteClb(CallbackData, prefix="user.e.d"): + page: int + user_id: int + + +class UserDeleteConfirmClb(CallbackData, prefix="user.e.d.c"): + page: int + user_id: int + + +class UserVpnLinkClb(CallbackData, prefix="user.e.v"): + page: int + user_id: int diff --git a/models/user.py b/models/user.py index 690083c..896ff3b 100644 --- a/models/user.py +++ b/models/user.py @@ -1,11 +1,44 @@ from datetime import datetime from enum import IntEnum +from typing import Self +from aiogram.types import Chat +from aiogram.types import User as TgUser +from pydantic import BaseModel from sqlalchemy.orm import Mapped, mapped_column from models import BaseTable +class UserCache(BaseModel): + id: int + username: str | None = None + full_name: str + + @classmethod + def from_chat(cls, chat: Chat) -> Self: + return cls( + id=chat.id, + username=chat.username, + full_name=chat.full_name, + ) + + @classmethod + def from_user(cls, user: TgUser) -> Self: + return cls( + id=user.id, + username=user.username, + full_name=user.full_name, + ) + + @property + def mention(self) -> str: + if self.username is not None: + return f'{self.full_name}' + else: + return f'{self.full_name}' + + class UserRole(IntEnum): REGULAR = 0 ADMIN = 1 diff --git a/pyproject.toml b/pyproject.toml index 29f1c60..1a3e036 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,5 +12,6 @@ dependencies = [ "magic-filter>=1.0.12", "pydantic>=2.12.5", "pydantic-settings>=2.13.1", + "redis[hiredis]>=7.4.0", "sqlalchemy[asyncio]>=2.0.48", ] diff --git a/settings.py b/settings.py index 04ba431..4371bab 100644 --- a/settings.py +++ b/settings.py @@ -14,11 +14,8 @@ storage_path.mkdir(parents=True, exist_ok=True) database_path = storage_path / "database.db" database_url = f"sqlite+aiosqlite:///{database_path}" -json_storage_path = storage_path / "storage.json" - __all__ = [ "database_path", "database_url", - "json_storage_path", "Env", ] diff --git a/shared.py b/shared.py index 63d00c3..19ecd9e 100644 --- a/shared.py +++ b/shared.py @@ -2,14 +2,25 @@ from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.client.session.aiohttp import AiohttpSession from aiogram.enums import ParseMode +from aiogram.fsm.storage.base import DefaultKeyBuilder +from aiogram.fsm.storage.redis import RedisStorage +from redis.asyncio.client import Redis -from libs.storage import JsonStorage -from settings import Env, json_storage_path +from settings import Env env = Env() # ty:ignore[missing-argument] # pyright: ignore[reportCallIssue] + +redis_users = Redis(host="redis", db=0) + bot = Bot( token=env.token, session=AiohttpSession(proxy=env.proxy), default=DefaultBotProperties(parse_mode=ParseMode.HTML), ) -dp = Dispatcher(storage=JsonStorage(json_storage_path)) + +dp = Dispatcher( + storage=RedisStorage( + Redis(host="redis", db=1), + DefaultKeyBuilder(), + ) +) diff --git a/uv.lock b/uv.lock index 090a19e..084c552 100644 --- a/uv.lock +++ b/uv.lock @@ -231,6 +231,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, ] +[[package]] +name = "hiredis" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/97/d6/9bef6dc3052c168c93fbf7e6c0f2b12c45f0f741a2d30fd919096774343a/hiredis-3.3.1.tar.gz", hash = "sha256:da6f0302360e99d32bc2869772692797ebadd536e1b826d0103c72ba49d38698", size = 89101, upload-time = "2026-03-16T15:21:08.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/72/0450d6b449da58120c5497346eb707738f8f67b9e60c28a8ef90133fc81f/hiredis-3.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:439f9a5cc8f9519ce208a24cdebfa0440fef26aa682a40ba2c92acb10a53f5e0", size = 82112, upload-time = "2026-03-16T15:20:02.865Z" }, + { url = "https://files.pythonhosted.org/packages/22/c0/0be33a29bcd463e6cbb0282515dd4d0cdfe33c30c7afc6d4d8c460e23266/hiredis-3.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3724f0e58c6ff76fd683429945491de71324ab1bc0ad943a8d68cb0932d24075", size = 46238, upload-time = "2026-03-16T15:20:03.896Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/f999854bfaf3bcbee0f797f24706c182ecfaca825f6a582f6281a6aa97e0/hiredis-3.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29fe35e3c6fe03204e75c86514f452591957a1e06b05d86e10d795455b71c355", size = 41891, upload-time = "2026-03-16T15:20:04.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/cd9ab90fec3a301d864d8ab6167aea387add8e2287969d89cbcd45d6b0e0/hiredis-3.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d42f3a13290f89191568fc113d95a3d2c8759cdd8c3672f021d8b7436f909e75", size = 170485, upload-time = "2026-03-16T15:20:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9a/1ddf9ea236a292963146cbaf6722abeb9d503ca47d821267bb8b3b81c4f7/hiredis-3.3.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2afc675b831f7552da41116fffffca4340f387dc03f56d6ec0c7895ab0b59a10", size = 182030, upload-time = "2026-03-16T15:20:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b8/e070a1dbf8a1bbb8814baa0b00836fbe3f10c7af8e11f942cc739c64e062/hiredis-3.3.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4106201cd052d9eabe3cb7b5a24b0fe37307792bda4fcb3cf6ddd72f697828e8", size = 180543, upload-time = "2026-03-16T15:20:09.096Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bb/b5f4f98e44626e2446cd8a52ce6cb1fc1c99786b6e2db3bf09cea97b90cd/hiredis-3.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8887bf0f31e4b550bd988c8863b527b6587d200653e9375cd91eea2b944b7424", size = 172356, upload-time = "2026-03-16T15:20:10.245Z" }, + { url = "https://files.pythonhosted.org/packages/ef/93/73a77b54ba94e82f76d02563c588d8a062513062675f483a033a43015f2c/hiredis-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ac7697365dbe45109273b34227fee6826b276ead9a4a007e0877e1d3f0fcf21", size = 166433, upload-time = "2026-03-16T15:20:11.789Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c2/1b2dcbe5dc53a46a8cb05bed67d190a7e30bad2ad1f727ebe154dfeededd/hiredis-3.3.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2b6da6e07359107c653a809b3cff2d9ccaeedbafe33c6f16434aef6f53ce4a2b", size = 177220, upload-time = "2026-03-16T15:20:12.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/09/f4314cf096552568b5ea785ceb60c424771f4d35a76c410ad39d258f74bc/hiredis-3.3.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ce334915f5d31048f76a42c607bf26687cf045eb1bc852b7340f09729c6a64fc", size = 170475, upload-time = "2026-03-16T15:20:14.519Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/3f56e438efc8fc27ed4a3dbad58c0280061466473ec35d8f86c90c841a84/hiredis-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee11fd431f83d8a5b29d370b9d79a814d3218d30113bdcd44657e9bdf715fc92", size = 167913, upload-time = "2026-03-16T15:20:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/56/34/053e5ee91d6dc478faac661996d1fd4886c5acb7a1b5ac30e7d3c794bb51/hiredis-3.3.1-cp314-cp314-win32.whl", hash = "sha256:e0356561b4a97c83b9ee3de657a41b8d1a1781226853adaf47b550bb988fda6f", size = 21167, upload-time = "2026-03-16T15:20:17.013Z" }, + { url = "https://files.pythonhosted.org/packages/ea/33/06776c641d17881a9031e337e81b3b934c38c2adbb83c85062d6b5f83b72/hiredis-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:80aba5f85d6227faee628ae28d1c3b69c661806a0636548ac56c68782606454f", size = 23000, upload-time = "2026-03-16T15:20:17.966Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5a/94f9a505b2ff5376d4a05fb279b69d89bafa7219dd33f6944026e3e56f80/hiredis-3.3.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:907f7b5501a534030738f0f27459a612d2266fd0507b007bb8f3e6de08167920", size = 83039, upload-time = "2026-03-16T15:20:19.316Z" }, + { url = "https://files.pythonhosted.org/packages/93/ae/d3752a8f03a1fca43d402389d2a2d234d3db54c4d1f07f26c1041ca3c5de/hiredis-3.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:de94b409f49eb6a588ebdd5872e826caec417cd77c17af0fb94f2128427f1a2a", size = 46703, upload-time = "2026-03-16T15:20:20.401Z" }, + { url = "https://files.pythonhosted.org/packages/9f/76/e32c868a2fa23cd82bacaffd38649d938173244a0e717ec1c0c76874dbdd/hiredis-3.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79cd03e7ff550c17758a7520bf437c156d3d4c8bb74214deeafa69cda49c85a4", size = 42379, upload-time = "2026-03-16T15:20:21.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f6/d687d36a74ce6cf448826cf2e8edfc1eb37cc965308f74eb696aa97c69df/hiredis-3.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ffa7ba2e2da1f806f3181b9730b3e87ba9dbfec884806725d4584055ba3faa6", size = 180311, upload-time = "2026-03-16T15:20:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/db/ac/f520dc0066a62a15aa920c7dd0a2028c213f4862d5f901409ae92ee5d785/hiredis-3.3.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ee37fe8cf081b72dea72f96a0ee604f492ec02252eb77dc26ff6eec3f997b580", size = 190488, upload-time = "2026-03-16T15:20:24.357Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f5/ae10fff82d0f291e90c41bf10a5d6543a96aae00cccede01bf2b6f7e178d/hiredis-3.3.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9bfdeff778d3f7ff449ca5922ab773899e7d31e26a576028b06a5e9cf0ed8c34", size = 189210, upload-time = "2026-03-16T15:20:25.51Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8f/5be4344e542aa8d349a03d05486c59d9ca26f69c749d11e114bf34b84d50/hiredis-3.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:027ce4fabfeff5af5b9869d5524770877f9061d118bc36b85703ae3faf5aad8e", size = 180971, upload-time = "2026-03-16T15:20:26.631Z" }, + { url = "https://files.pythonhosted.org/packages/41/a2/29e230226ec2a31f13f8a832fbafe366e263f3b090553ebe49bb4581a7bd/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dcea8c3f53674ae68e44b12e853b844a1d315250ca6677b11ec0c06aff85e86c", size = 175314, upload-time = "2026-03-16T15:20:27.848Z" }, + { url = "https://files.pythonhosted.org/packages/89/2e/bf241707ad86b9f3ebfbc7ab89e19d5ec243ff92ca77644a383622e8740b/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0b5ff2f643f4b452b0597b7fe6aa35d398cb31d8806801acfafb1558610ea2aa", size = 185652, upload-time = "2026-03-16T15:20:29.364Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/b39170d8bcccd01febd45af4ac6b43ff38e134a868e2ec167a82a036fb35/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3586c8a5f56d34b9dddaaa9e76905f31933cac267251006adf86ec0eef7d0400", size = 179033, upload-time = "2026-03-16T15:20:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3a/4fe39a169115434f911abff08ff485b9b6201c168500e112b3f6a8110c0a/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a110d19881ca78a88583d3b07231e7c6864864f5f1f3491b638863ea45fa8708", size = 176126, upload-time = "2026-03-16T15:20:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/44/99/c1d0b0bc4f9e9150e24beb0dca2e186e32d5e749d0022e0d26453749ed51/hiredis-3.3.1-cp314-cp314t-win32.whl", hash = "sha256:98fd5b39410e9d69e10e90d0330e35650becaa5dd2548f509b9598f1f3c6124d", size = 22028, upload-time = "2026-03-16T15:20:33.33Z" }, + { url = "https://files.pythonhosted.org/packages/35/d6/191e6741addc97bcf5e755661f8c82f0fd0aa35f07ece56e858da689b57e/hiredis-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ab1f646ff531d70bfd25f01e60708dfa3d105eb458b7dedd9fe9a443039fd809", size = 23811, upload-time = "2026-03-16T15:20:34.292Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -461,6 +495,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" }, ] +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + +[package.optional-dependencies] +hiredis = [ + { name = "hiredis" }, +] + [[package]] name = "sqlalchemy" version = "2.0.49" @@ -526,6 +574,7 @@ dependencies = [ { name = "magic-filter" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "redis", extra = ["hiredis"] }, { name = "sqlalchemy", extra = ["asyncio"] }, ] @@ -539,6 +588,7 @@ requires-dist = [ { name = "magic-filter", specifier = ">=1.0.12" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, + { name = "redis", extras = ["hiredis"], specifier = ">=7.4.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.48" }, ] -- cgit v1.3