diff options
| author | Tolmachev Igor <me@igorek.dev> | 2026-04-20 20:56:35 +0300 |
|---|---|---|
| committer | Tolmachev Igor <me@igorek.dev> | 2026-04-20 20:56:35 +0300 |
| commit | 75e99ca0712a2c09230e5c6f8d093dc526cc717d (patch) | |
| tree | f3f00494364a82b866f093651cb9a08030135c4e | |
| parent | f186fca0f1aa9bbe5eab7613f229df527b2ab774 (diff) | |
| download | vpn_manager_bot-75e99ca0712a2c09230e5c6f8d093dc526cc717d.tar.gz vpn_manager_bot-75e99ca0712a2c09230e5c6f8d093dc526cc717d.zip | |
Add users command
| -rw-r--r-- | compose.yaml | 13 | ||||
| -rw-r--r-- | handlers/admin/__init__.py | 4 | ||||
| -rw-r--r-- | handlers/admin/add_user.py | 99 | ||||
| -rw-r--r-- | handlers/admin/users.py | 557 | ||||
| -rw-r--r-- | handlers/middleware.py | 24 | ||||
| -rw-r--r-- | handlers/user/info.py | 4 | ||||
| -rw-r--r-- | handlers/user/invoices.py | 16 | ||||
| -rw-r--r-- | handlers/user/pay_invoice.py | 5 | ||||
| -rw-r--r-- | handlers/user/payments.py | 12 | ||||
| -rw-r--r-- | libs/__init__.py | 3 | ||||
| -rw-r--r-- | libs/storage.py | 75 | ||||
| -rw-r--r-- | libs/user.py | 30 | ||||
| -rw-r--r-- | models/__init__.py | 3 | ||||
| -rw-r--r-- | models/callback_data.py | 41 | ||||
| -rw-r--r-- | models/user.py | 33 | ||||
| -rw-r--r-- | pyproject.toml | 1 | ||||
| -rw-r--r-- | settings.py | 3 | ||||
| -rw-r--r-- | shared.py | 17 | ||||
| -rw-r--r-- | uv.lock | 50 |
19 files changed, 782 insertions, 208 deletions
diff --git a/compose.yaml b/compose.yaml index 11f092d..45d5c13 100644 --- a/compose.yaml +++ b/compose.yaml | |||
| @@ -5,6 +5,19 @@ services: | |||
| 5 | - TOKEN=${TOKEN} | 5 | - TOKEN=${TOKEN} |
| 6 | volumes: | 6 | volumes: |
| 7 | - storage:/app/storage | 7 | - storage:/app/storage |
| 8 | develop: | ||
| 9 | watch: | ||
| 10 | - action: sync+restart | ||
| 11 | path: . | ||
| 12 | target: /app | ||
| 13 | |||
| 14 | redis: | ||
| 15 | image: "redis:8" | ||
| 16 | restart: "always" | ||
| 17 | hostname: "redis" | ||
| 18 | volumes: | ||
| 19 | - redis:/data | ||
| 8 | 20 | ||
| 9 | volumes: | 21 | volumes: |
| 10 | storage: | 22 | storage: |
| 23 | 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 | |||
| 4 | # isort: off | 4 | # isort: off |
| 5 | from . import new_announcement | 5 | from . import new_announcement |
| 6 | from . import new_invoice | 6 | from . import new_invoice |
| 7 | from . import add_user | 7 | from . import users |
| 8 | from . import payment_status | 8 | from . import payment_status |
| 9 | # isort: on | 9 | # isort: on |
| 10 | 10 | ||
| @@ -15,6 +15,6 @@ router.callback_query.filter(MagicData(F.user.is_admin())) | |||
| 15 | router.include_routers( | 15 | router.include_routers( |
| 16 | new_announcement.router, | 16 | new_announcement.router, |
| 17 | new_invoice.router, | 17 | new_invoice.router, |
| 18 | add_user.router, | 18 | users.router, |
| 19 | payment_status.router, | 19 | payment_status.router, |
| 20 | ) | 20 | ) |
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 @@ | |||
| 1 | from datetime import UTC, datetime | ||
| 2 | |||
| 3 | from aiogram import F, Router | ||
| 4 | from aiogram.enums.button_style import ButtonStyle | ||
| 5 | from aiogram.filters import Command | ||
| 6 | from aiogram.fsm.context import FSMContext | ||
| 7 | from aiogram.fsm.state import State, StatesGroup | ||
| 8 | from aiogram.types import ( | ||
| 9 | KeyboardButton, | ||
| 10 | KeyboardButtonRequestUsers, | ||
| 11 | Message, | ||
| 12 | ReplyKeyboardMarkup, | ||
| 13 | ReplyKeyboardRemove, | ||
| 14 | ) | ||
| 15 | from pydantic import BaseModel | ||
| 16 | from sqlalchemy.ext.asyncio import AsyncSession | ||
| 17 | |||
| 18 | from libs.fsm import edit_data, get_data, set_data | ||
| 19 | from models import User | ||
| 20 | |||
| 21 | router = Router(name="add_user") | ||
| 22 | |||
| 23 | |||
| 24 | class AddUserStates(StatesGroup): | ||
| 25 | user_id = State() | ||
| 26 | vpn_link = State() | ||
| 27 | |||
| 28 | |||
| 29 | class AddUserData(BaseModel): | ||
| 30 | user_id: int | None = None | ||
| 31 | |||
| 32 | |||
| 33 | CANCEL_BUTTON = "Отменить добавление" | ||
| 34 | |||
| 35 | |||
| 36 | @router.message(Command("add_user")) | ||
| 37 | async def command(msg: Message, state: FSMContext) -> None: | ||
| 38 | await msg.answer( | ||
| 39 | "Выберете пользователя которого хотите добавить.", | ||
| 40 | reply_markup=ReplyKeyboardMarkup( | ||
| 41 | keyboard=[ | ||
| 42 | [ | ||
| 43 | KeyboardButton( | ||
| 44 | text="Выбрать пользователя", | ||
| 45 | style=ButtonStyle.PRIMARY, | ||
| 46 | request_users=KeyboardButtonRequestUsers(request_id=0), | ||
| 47 | ), | ||
| 48 | ], | ||
| 49 | [ | ||
| 50 | KeyboardButton(text=CANCEL_BUTTON, style=ButtonStyle.DANGER), | ||
| 51 | ], | ||
| 52 | ], | ||
| 53 | resize_keyboard=True, | ||
| 54 | ), | ||
| 55 | ) | ||
| 56 | await set_data(state, AddUserData(user_id=None)) | ||
| 57 | await state.set_state(AddUserStates.user_id) | ||
| 58 | |||
| 59 | |||
| 60 | @router.message(AddUserStates(), F.text == CANCEL_BUTTON) | ||
| 61 | async def cancel(msg: Message, state: FSMContext) -> None: | ||
| 62 | await msg.answer( | ||
| 63 | "Добавление пользователей отменено", | ||
| 64 | reply_markup=ReplyKeyboardRemove(), | ||
| 65 | ) | ||
| 66 | await state.clear() | ||
| 67 | |||
| 68 | |||
| 69 | @router.message(AddUserStates.user_id) | ||
| 70 | async def set_user_id(msg: Message, state: FSMContext) -> None: | ||
| 71 | if msg.users_shared is None: | ||
| 72 | await msg.answer("Вы должны воспользоваться кнопкой ниже.") | ||
| 73 | return | ||
| 74 | |||
| 75 | async with edit_data(state, AddUserData) as data: | ||
| 76 | data.user_id = msg.users_shared.users[0].user_id | ||
| 77 | |||
| 78 | await msg.answer("Укажите ссылку для доступа к VPN") | ||
| 79 | await state.set_state(AddUserStates.vpn_link) | ||
| 80 | |||
| 81 | |||
| 82 | @router.message(AddUserStates.vpn_link) | ||
| 83 | async def set_vpn_link( | ||
| 84 | msg: Message, | ||
| 85 | state: FSMContext, | ||
| 86 | session: AsyncSession, | ||
| 87 | ) -> None: | ||
| 88 | if msg.text is None: | ||
| 89 | await msg.answer("Вы должны указать ссылку отправив текстовое сообщение.") | ||
| 90 | return | ||
| 91 | |||
| 92 | data = await get_data(state, AddUserData) | ||
| 93 | assert data.user_id is not None | ||
| 94 | |||
| 95 | session.add(User(id=data.user_id, vpn_link=msg.text, datetime=datetime.now(UTC))) | ||
| 96 | await session.flush() | ||
| 97 | |||
| 98 | await msg.answer("Пользователь добавлен.", reply_markup=ReplyKeyboardRemove()) | ||
| 99 | 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 @@ | |||
| 1 | from datetime import UTC, datetime | ||
| 2 | from math import ceil | ||
| 3 | |||
| 4 | from aiogram import Bot, F, Router | ||
| 5 | from aiogram.enums import ButtonStyle | ||
| 6 | from aiogram.filters import Command | ||
| 7 | from aiogram.fsm.context import FSMContext | ||
| 8 | from aiogram.fsm.state import State, StatesGroup | ||
| 9 | from aiogram.types import ( | ||
| 10 | CallbackQuery, | ||
| 11 | InlineKeyboardButton, | ||
| 12 | InlineKeyboardMarkup, | ||
| 13 | KeyboardButton, | ||
| 14 | KeyboardButtonRequestUsers, | ||
| 15 | Message, | ||
| 16 | ReplyKeyboardMarkup, | ||
| 17 | ReplyKeyboardRemove, | ||
| 18 | ) | ||
| 19 | from pydantic import BaseModel | ||
| 20 | from sqlalchemy import delete, select | ||
| 21 | from sqlalchemy.ext.asyncio import AsyncSession | ||
| 22 | from sqlalchemy.sql.functions import count | ||
| 23 | |||
| 24 | from libs.fsm import edit_data, get_data, set_data | ||
| 25 | from libs.user import load_user_cache | ||
| 26 | from models import Payment, User, UserRole | ||
| 27 | from models.callback_data import ( | ||
| 28 | UserAddClb, | ||
| 29 | UserDeleteClb, | ||
| 30 | UserDeleteConfirmClb, | ||
| 31 | UserItemClb, | ||
| 32 | UserPageClb, | ||
| 33 | UserRoleClb, | ||
| 34 | UserRoleSetClb, | ||
| 35 | UserVpnLinkClb, | ||
| 36 | ) | ||
| 37 | |||
| 38 | router = Router(name="users") | ||
| 39 | PAGE_SIZE = 5 | ||
| 40 | ROLE_ICON = { | ||
| 41 | UserRole.ADMIN: "👑", | ||
| 42 | UserRole.REGULAR: "👤", | ||
| 43 | } | ||
| 44 | ROLE_NAME = { | ||
| 45 | UserRole.ADMIN: "Администратор", | ||
| 46 | UserRole.REGULAR: "Пользователь", | ||
| 47 | } | ||
| 48 | |||
| 49 | LIST_TEXT = "Список пользователей:" | ||
| 50 | ADD_CANCEL_BUTTON = "Отменить добавление" | ||
| 51 | EDIT_CANCEL_BUTTON = "Отменить изменение" | ||
| 52 | |||
| 53 | |||
| 54 | class AddUserStates(StatesGroup): | ||
| 55 | user_id = State() | ||
| 56 | vpn_link = State() | ||
| 57 | |||
| 58 | |||
| 59 | class AddUserData(BaseModel): | ||
| 60 | user_id: int | None = None | ||
| 61 | |||
| 62 | |||
| 63 | class EditVpnLinkStates(StatesGroup): | ||
| 64 | vpn_link = State() | ||
| 65 | |||
| 66 | |||
| 67 | class EditVpnLinkData(BaseModel): | ||
| 68 | page: int | ||
| 69 | user_id: int | ||
| 70 | |||
| 71 | |||
| 72 | async def get_list_markup( | ||
| 73 | page: int, | ||
| 74 | bot: Bot, | ||
| 75 | session: AsyncSession, | ||
| 76 | ) -> InlineKeyboardMarkup: | ||
| 77 | total = await session.scalar(select(count()).select_from(User)) | ||
| 78 | assert total is not None | ||
| 79 | total_pages = max(1, ceil(total / PAGE_SIZE)) | ||
| 80 | page = max(0, min(page, total_pages - 1)) | ||
| 81 | |||
| 82 | users = await session.scalars( | ||
| 83 | select(User).offset(PAGE_SIZE * page).limit(PAGE_SIZE).order_by(User.id.desc()) | ||
| 84 | ) | ||
| 85 | |||
| 86 | user_buttons = [] | ||
| 87 | for u in users: | ||
| 88 | user_cache = await load_user_cache(bot, u.id) | ||
| 89 | user_buttons.append( | ||
| 90 | [ | ||
| 91 | InlineKeyboardButton( | ||
| 92 | text=f"{ROLE_ICON[u.role]} {user_cache.full_name}", | ||
| 93 | callback_data=UserItemClb(page=page, user_id=u.id).pack(), | ||
| 94 | ) | ||
| 95 | ] | ||
| 96 | ) | ||
| 97 | |||
| 98 | page_buttons = [] | ||
| 99 | if page > 0: | ||
| 100 | page_buttons.append( | ||
| 101 | InlineKeyboardButton( | ||
| 102 | text="◀️", | ||
| 103 | callback_data=UserPageClb(page=page - 1).pack(), | ||
| 104 | ) | ||
| 105 | ) | ||
| 106 | if page < total_pages - 1: | ||
| 107 | page_buttons.append( | ||
| 108 | InlineKeyboardButton( | ||
| 109 | text="▶️", | ||
| 110 | callback_data=UserPageClb(page=page + 1).pack(), | ||
| 111 | ) | ||
| 112 | ) | ||
| 113 | |||
| 114 | add_row = [ | ||
| 115 | InlineKeyboardButton( | ||
| 116 | text="➕ Добавить пользователя", | ||
| 117 | callback_data=UserAddClb().pack(), | ||
| 118 | ) | ||
| 119 | ] | ||
| 120 | |||
| 121 | return InlineKeyboardMarkup(inline_keyboard=[*user_buttons, page_buttons, add_row]) | ||
| 122 | |||
| 123 | |||
| 124 | def build_item_markup(user_id: int, page: int) -> InlineKeyboardMarkup: | ||
| 125 | return InlineKeyboardMarkup( | ||
| 126 | inline_keyboard=[ | ||
| 127 | [ | ||
| 128 | InlineKeyboardButton( | ||
| 129 | text="Поменять роль", | ||
| 130 | callback_data=UserRoleClb(page=page, user_id=user_id).pack(), | ||
| 131 | ), | ||
| 132 | ], | ||
| 133 | [ | ||
| 134 | InlineKeyboardButton( | ||
| 135 | text="Изменить VPN ссылку", | ||
| 136 | callback_data=UserVpnLinkClb(page=page, user_id=user_id).pack(), | ||
| 137 | ), | ||
| 138 | ], | ||
| 139 | [ | ||
| 140 | InlineKeyboardButton( | ||
| 141 | text="Удалить пользователя", | ||
| 142 | style=ButtonStyle.DANGER, | ||
| 143 | callback_data=UserDeleteClb(page=page, user_id=user_id).pack(), | ||
| 144 | ), | ||
| 145 | ], | ||
| 146 | [ | ||
| 147 | InlineKeyboardButton( | ||
| 148 | text="Назад к списку", | ||
| 149 | callback_data=UserPageClb(page=page).pack(), | ||
| 150 | ), | ||
| 151 | ], | ||
| 152 | ] | ||
| 153 | ) | ||
| 154 | |||
| 155 | |||
| 156 | def build_role_markup(user_id: int, page: int) -> InlineKeyboardMarkup: | ||
| 157 | return InlineKeyboardMarkup( | ||
| 158 | inline_keyboard=[ | ||
| 159 | [ | ||
| 160 | InlineKeyboardButton( | ||
| 161 | text=f"{ROLE_ICON[UserRole.REGULAR]} {ROLE_NAME[UserRole.REGULAR]}", | ||
| 162 | callback_data=UserRoleSetClb( | ||
| 163 | page=page, | ||
| 164 | user_id=user_id, | ||
| 165 | role=UserRole.REGULAR, | ||
| 166 | ).pack(), | ||
| 167 | ), | ||
| 168 | ], | ||
| 169 | [ | ||
| 170 | InlineKeyboardButton( | ||
| 171 | text=f"{ROLE_ICON[UserRole.ADMIN]} {ROLE_NAME[UserRole.ADMIN]}", | ||
| 172 | callback_data=UserRoleSetClb( | ||
| 173 | page=page, | ||
| 174 | user_id=user_id, | ||
| 175 | role=UserRole.ADMIN, | ||
| 176 | ).pack(), | ||
| 177 | ), | ||
| 178 | ], | ||
| 179 | [ | ||
| 180 | InlineKeyboardButton( | ||
| 181 | text="Назад", | ||
| 182 | callback_data=UserItemClb(page=page, user_id=user_id).pack(), | ||
| 183 | ), | ||
| 184 | ], | ||
| 185 | ] | ||
| 186 | ) | ||
| 187 | |||
| 188 | |||
| 189 | def build_delete_markup(user_id: int, page: int) -> InlineKeyboardMarkup: | ||
| 190 | return InlineKeyboardMarkup( | ||
| 191 | inline_keyboard=[ | ||
| 192 | [ | ||
| 193 | InlineKeyboardButton( | ||
| 194 | text="❌ Удалить", | ||
| 195 | style=ButtonStyle.DANGER, | ||
| 196 | callback_data=UserDeleteConfirmClb( | ||
| 197 | page=page, | ||
| 198 | user_id=user_id, | ||
| 199 | ).pack(), | ||
| 200 | ), | ||
| 201 | ], | ||
| 202 | [ | ||
| 203 | InlineKeyboardButton( | ||
| 204 | text="Назад", | ||
| 205 | callback_data=UserItemClb(page=page, user_id=user_id).pack(), | ||
| 206 | ), | ||
| 207 | ], | ||
| 208 | ] | ||
| 209 | ) | ||
| 210 | |||
| 211 | |||
| 212 | async def get_item_text(bot: Bot, target: User) -> str: | ||
| 213 | user_cache = await load_user_cache(bot, target.id) | ||
| 214 | return ( | ||
| 215 | f"Пользователь: {user_cache.mention}\n" | ||
| 216 | f"Роль: {ROLE_NAME[target.role]}\n" | ||
| 217 | f"VPN ссылка: {target.vpn_link}" | ||
| 218 | ) | ||
| 219 | |||
| 220 | |||
| 221 | @router.message(Command("users")) | ||
| 222 | async def command(msg: Message, bot: Bot, session: AsyncSession) -> None: | ||
| 223 | await msg.answer( | ||
| 224 | LIST_TEXT, | ||
| 225 | reply_markup=await get_list_markup(0, bot, session), | ||
| 226 | ) | ||
| 227 | |||
| 228 | |||
| 229 | @router.callback_query(UserPageClb.filter()) | ||
| 230 | async def page( | ||
| 231 | clb: CallbackQuery, | ||
| 232 | bot: Bot, | ||
| 233 | callback_data: UserPageClb, | ||
| 234 | session: AsyncSession, | ||
| 235 | ) -> None: | ||
| 236 | assert isinstance(clb.message, Message) | ||
| 237 | await clb.message.edit_text( | ||
| 238 | LIST_TEXT, | ||
| 239 | reply_markup=await get_list_markup(callback_data.page, bot, session), | ||
| 240 | ) | ||
| 241 | await clb.answer() | ||
| 242 | |||
| 243 | |||
| 244 | @router.callback_query(UserItemClb.filter()) | ||
| 245 | async def item( | ||
| 246 | clb: CallbackQuery, | ||
| 247 | bot: Bot, | ||
| 248 | callback_data: UserItemClb, | ||
| 249 | session: AsyncSession, | ||
| 250 | ) -> None: | ||
| 251 | assert isinstance(clb.message, Message) | ||
| 252 | target = await session.get(User, callback_data.user_id) | ||
| 253 | if target is None: | ||
| 254 | await clb.answer("Пользователь не найден.", show_alert=True) | ||
| 255 | return | ||
| 256 | |||
| 257 | await clb.message.edit_text( | ||
| 258 | await get_item_text(bot, target), | ||
| 259 | reply_markup=build_item_markup(target.id, callback_data.page), | ||
| 260 | ) | ||
| 261 | await clb.answer() | ||
| 262 | |||
| 263 | |||
| 264 | @router.callback_query(UserRoleClb.filter()) | ||
| 265 | async def role_menu( | ||
| 266 | clb: CallbackQuery, | ||
| 267 | callback_data: UserRoleClb, | ||
| 268 | session: AsyncSession, | ||
| 269 | user: User, | ||
| 270 | ) -> None: | ||
| 271 | assert isinstance(clb.message, Message) | ||
| 272 | if user.id == callback_data.user_id: | ||
| 273 | await clb.answer("Нельзя поменять свою роль.", show_alert=True) | ||
| 274 | return | ||
| 275 | |||
| 276 | target = await session.get(User, callback_data.user_id) | ||
| 277 | if target is None: | ||
| 278 | await clb.answer("Пользователь не найден.", show_alert=True) | ||
| 279 | return | ||
| 280 | |||
| 281 | await clb.message.edit_text( | ||
| 282 | f"Выберете новую роль для пользователя (текущая: {ROLE_NAME[target.role]}):", | ||
| 283 | reply_markup=build_role_markup(target.id, callback_data.page), | ||
| 284 | ) | ||
| 285 | await clb.answer() | ||
| 286 | |||
| 287 | |||
| 288 | @router.callback_query(UserRoleSetClb.filter()) | ||
| 289 | async def role_set( | ||
| 290 | clb: CallbackQuery, | ||
| 291 | bot: Bot, | ||
| 292 | callback_data: UserRoleSetClb, | ||
| 293 | session: AsyncSession, | ||
| 294 | user: User, | ||
| 295 | ) -> None: | ||
| 296 | assert isinstance(clb.message, Message) | ||
| 297 | if user.id == callback_data.user_id: | ||
| 298 | await clb.answer("Нельзя поменять свою роль.", show_alert=True) | ||
| 299 | return | ||
| 300 | |||
| 301 | target = await session.get(User, callback_data.user_id) | ||
| 302 | if target is None: | ||
| 303 | await clb.answer("Пользователь не найден.", show_alert=True) | ||
| 304 | return | ||
| 305 | |||
| 306 | if target.role != callback_data.role: | ||
| 307 | target.role = callback_data.role | ||
| 308 | await session.flush() | ||
| 309 | |||
| 310 | await clb.message.edit_text( | ||
| 311 | await get_item_text(bot, target), | ||
| 312 | reply_markup=build_item_markup(target.id, callback_data.page), | ||
| 313 | ) | ||
| 314 | await clb.answer(f"Роль: {ROLE_NAME[target.role]}.") | ||
| 315 | |||
| 316 | |||
| 317 | @router.callback_query(UserDeleteClb.filter()) | ||
| 318 | async def delete_menu( | ||
| 319 | clb: CallbackQuery, | ||
| 320 | bot: Bot, | ||
| 321 | callback_data: UserDeleteClb, | ||
| 322 | session: AsyncSession, | ||
| 323 | user: User, | ||
| 324 | ) -> None: | ||
| 325 | assert isinstance(clb.message, Message) | ||
| 326 | if user.id == callback_data.user_id: | ||
| 327 | await clb.answer("Нельзя удалить себя.", show_alert=True) | ||
| 328 | return | ||
| 329 | |||
| 330 | target = await session.get(User, callback_data.user_id) | ||
| 331 | if target is None: | ||
| 332 | await clb.answer("Пользователь не найден.", show_alert=True) | ||
| 333 | return | ||
| 334 | |||
| 335 | user_cache = await load_user_cache(bot, target.id) | ||
| 336 | await clb.message.edit_text( | ||
| 337 | f"Удалить пользователя {user_cache.mention}?", | ||
| 338 | reply_markup=build_delete_markup(target.id, callback_data.page), | ||
| 339 | ) | ||
| 340 | await clb.answer() | ||
| 341 | |||
| 342 | |||
| 343 | @router.callback_query(UserDeleteConfirmClb.filter()) | ||
| 344 | async def delete_confirm( | ||
| 345 | clb: CallbackQuery, | ||
| 346 | bot: Bot, | ||
| 347 | callback_data: UserDeleteConfirmClb, | ||
| 348 | session: AsyncSession, | ||
| 349 | user: User, | ||
| 350 | ) -> None: | ||
| 351 | assert isinstance(clb.message, Message) | ||
| 352 | if user.id == callback_data.user_id: | ||
| 353 | await clb.answer("Нельзя удалить себя.", show_alert=True) | ||
| 354 | return | ||
| 355 | |||
| 356 | target = await session.get(User, callback_data.user_id) | ||
| 357 | if target is None: | ||
| 358 | await clb.answer("Пользователь не найден.", show_alert=True) | ||
| 359 | return | ||
| 360 | |||
| 361 | await session.execute(delete(Payment).where(Payment.user_id == target.id)) | ||
| 362 | await session.delete(target) | ||
| 363 | await session.flush() | ||
| 364 | |||
| 365 | await clb.message.edit_text( | ||
| 366 | LIST_TEXT, | ||
| 367 | reply_markup=await get_list_markup(callback_data.page, bot, session), | ||
| 368 | ) | ||
| 369 | await clb.answer("Пользователь удалён.") | ||
| 370 | |||
| 371 | |||
| 372 | @router.callback_query(UserVpnLinkClb.filter()) | ||
| 373 | async def vpn_link_button( | ||
| 374 | clb: CallbackQuery, | ||
| 375 | bot: Bot, | ||
| 376 | state: FSMContext, | ||
| 377 | callback_data: UserVpnLinkClb, | ||
| 378 | session: AsyncSession, | ||
| 379 | ) -> None: | ||
| 380 | assert isinstance(clb.message, Message) | ||
| 381 | target = await session.get(User, callback_data.user_id) | ||
| 382 | if target is None: | ||
| 383 | await clb.answer("Пользователь не найден.", show_alert=True) | ||
| 384 | return | ||
| 385 | |||
| 386 | await clb.message.delete() | ||
| 387 | await bot.send_message( | ||
| 388 | clb.from_user.id, | ||
| 389 | "Укажите новую ссылку для доступа к VPN.", | ||
| 390 | reply_markup=ReplyKeyboardMarkup( | ||
| 391 | keyboard=[ | ||
| 392 | [KeyboardButton(text=EDIT_CANCEL_BUTTON, style=ButtonStyle.DANGER)] | ||
| 393 | ], | ||
| 394 | resize_keyboard=True, | ||
| 395 | ), | ||
| 396 | ) | ||
| 397 | await set_data( | ||
| 398 | state, | ||
| 399 | EditVpnLinkData(page=callback_data.page, user_id=callback_data.user_id), | ||
| 400 | ) | ||
| 401 | await state.set_state(EditVpnLinkStates.vpn_link) | ||
| 402 | await clb.answer() | ||
| 403 | |||
| 404 | |||
| 405 | async def send_item(bot: Bot, chat_id: int, target: User, page: int) -> None: | ||
| 406 | await bot.send_message( | ||
| 407 | chat_id, | ||
| 408 | await get_item_text(bot, target), | ||
| 409 | reply_markup=build_item_markup(target.id, page), | ||
| 410 | ) | ||
| 411 | |||
| 412 | |||
| 413 | @router.message(EditVpnLinkStates.vpn_link, F.text == EDIT_CANCEL_BUTTON) | ||
| 414 | async def vpn_link_cancel( | ||
| 415 | msg: Message, | ||
| 416 | bot: Bot, | ||
| 417 | state: FSMContext, | ||
| 418 | session: AsyncSession, | ||
| 419 | ) -> None: | ||
| 420 | data = await get_data(state, EditVpnLinkData) | ||
| 421 | await state.clear() | ||
| 422 | |||
| 423 | await msg.answer( | ||
| 424 | "Изменение VPN ссылки отменено.", | ||
| 425 | reply_markup=ReplyKeyboardRemove(), | ||
| 426 | ) | ||
| 427 | |||
| 428 | target = await session.get(User, data.user_id) | ||
| 429 | if target is None: | ||
| 430 | return | ||
| 431 | await send_item(bot, msg.chat.id, target, data.page) | ||
| 432 | |||
| 433 | |||
| 434 | @router.message(EditVpnLinkStates.vpn_link) | ||
| 435 | async def vpn_link_set( | ||
| 436 | msg: Message, | ||
| 437 | bot: Bot, | ||
| 438 | state: FSMContext, | ||
| 439 | session: AsyncSession, | ||
| 440 | ) -> None: | ||
| 441 | if msg.text is None: | ||
| 442 | await msg.answer("Вы должны указать ссылку отправив текстовое сообщение.") | ||
| 443 | return | ||
| 444 | |||
| 445 | data = await get_data(state, EditVpnLinkData) | ||
| 446 | target = await session.get(User, data.user_id) | ||
| 447 | if target is None: | ||
| 448 | await msg.answer( | ||
| 449 | "Пользователь не найден.", | ||
| 450 | reply_markup=ReplyKeyboardRemove(), | ||
| 451 | ) | ||
| 452 | await state.clear() | ||
| 453 | return | ||
| 454 | |||
| 455 | target.vpn_link = msg.text | ||
| 456 | await session.flush() | ||
| 457 | await state.clear() | ||
| 458 | |||
| 459 | await msg.answer("VPN ссылка обновлена.", reply_markup=ReplyKeyboardRemove()) | ||
| 460 | await send_item(bot, msg.chat.id, target, data.page) | ||
| 461 | |||
| 462 | |||
| 463 | @router.callback_query(UserAddClb.filter()) | ||
| 464 | async def add_button( | ||
| 465 | clb: CallbackQuery, | ||
| 466 | bot: Bot, | ||
| 467 | state: FSMContext, | ||
| 468 | ) -> None: | ||
| 469 | assert isinstance(clb.message, Message) | ||
| 470 | await clb.message.delete() | ||
| 471 | await bot.send_message( | ||
| 472 | clb.from_user.id, | ||
| 473 | "Выберете пользователя которого хотите добавить.", | ||
| 474 | reply_markup=ReplyKeyboardMarkup( | ||
| 475 | keyboard=[ | ||
| 476 | [ | ||
| 477 | KeyboardButton( | ||
| 478 | text="Выбрать пользователя", | ||
| 479 | style=ButtonStyle.PRIMARY, | ||
| 480 | request_users=KeyboardButtonRequestUsers(request_id=0), | ||
| 481 | ), | ||
| 482 | ], | ||
| 483 | [KeyboardButton(text=ADD_CANCEL_BUTTON, style=ButtonStyle.DANGER)], | ||
| 484 | ], | ||
| 485 | resize_keyboard=True, | ||
| 486 | ), | ||
| 487 | ) | ||
| 488 | await set_data(state, AddUserData(user_id=None)) | ||
| 489 | await state.set_state(AddUserStates.user_id) | ||
| 490 | await clb.answer() | ||
| 491 | |||
| 492 | |||
| 493 | @router.message(AddUserStates(), F.text == ADD_CANCEL_BUTTON) | ||
| 494 | async def add_cancel( | ||
| 495 | msg: Message, | ||
| 496 | bot: Bot, | ||
| 497 | state: FSMContext, | ||
| 498 | session: AsyncSession, | ||
| 499 | ) -> None: | ||
| 500 | await msg.answer( | ||
| 501 | "Добавление пользователя отменено.", | ||
| 502 | reply_markup=ReplyKeyboardRemove(), | ||
| 503 | ) | ||
| 504 | |||
| 505 | await msg.answer( | ||
| 506 | LIST_TEXT, | ||
| 507 | reply_markup=await get_list_markup(0, bot, session), | ||
| 508 | ) | ||
| 509 | await state.clear() | ||
| 510 | |||
| 511 | |||
| 512 | @router.message(AddUserStates.user_id) | ||
| 513 | async def add_set_user_id(msg: Message, state: FSMContext) -> None: | ||
| 514 | if msg.users_shared is None: | ||
| 515 | await msg.answer("Вы должны воспользоваться кнопкой ниже.") | ||
| 516 | return | ||
| 517 | |||
| 518 | async with edit_data(state, AddUserData) as data: | ||
| 519 | data.user_id = msg.users_shared.users[0].user_id | ||
| 520 | |||
| 521 | await msg.answer( | ||
| 522 | "Укажите ссылку для доступа к VPN.", | ||
| 523 | reply_markup=ReplyKeyboardMarkup( | ||
| 524 | keyboard=[ | ||
| 525 | [KeyboardButton(text=ADD_CANCEL_BUTTON, style=ButtonStyle.DANGER)] | ||
| 526 | ], | ||
| 527 | resize_keyboard=True, | ||
| 528 | ), | ||
| 529 | ) | ||
| 530 | await state.set_state(AddUserStates.vpn_link) | ||
| 531 | |||
| 532 | |||
| 533 | @router.message(AddUserStates.vpn_link) | ||
| 534 | async def add_set_vpn_link( | ||
| 535 | msg: Message, | ||
| 536 | bot: Bot, | ||
| 537 | state: FSMContext, | ||
| 538 | session: AsyncSession, | ||
| 539 | ) -> None: | ||
| 540 | if msg.text is None: | ||
| 541 | await msg.answer("Вы должны указать ссылку отправив текстовое сообщение.") | ||
| 542 | return | ||
| 543 | |||
| 544 | data = await get_data(state, AddUserData) | ||
| 545 | assert data.user_id is not None | ||
| 546 | |||
| 547 | session.add(User(id=data.user_id, vpn_link=msg.text, datetime=datetime.now(UTC))) | ||
| 548 | await session.flush() | ||
| 549 | |||
| 550 | await msg.answer("Пользователь добавлен.", reply_markup=ReplyKeyboardRemove()) | ||
| 551 | |||
| 552 | target = await session.get(User, data.user_id) | ||
| 553 | if target is None: | ||
| 554 | return | ||
| 555 | |||
| 556 | await send_item(bot, msg.chat.id, target, 0) | ||
| 557 | 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 | |||
| 5 | from sqlalchemy.ext.asyncio import AsyncSession | 5 | from sqlalchemy.ext.asyncio import AsyncSession |
| 6 | 6 | ||
| 7 | from database import sessions | 7 | from database import sessions |
| 8 | from libs.user import set_user_cache | ||
| 8 | from models import User | 9 | from models import User |
| 10 | from models.user import UserCache | ||
| 9 | 11 | ||
| 10 | 12 | ||
| 11 | class InjectSessionMiddleware(BaseMiddleware): | 13 | class InjectSessionMiddleware(BaseMiddleware): |
| @@ -54,3 +56,25 @@ class UserAccessMiddleware(BaseMiddleware): | |||
| 54 | data["user"] = user | 56 | data["user"] = user |
| 55 | 57 | ||
| 56 | return await handler(event, data) | 58 | return await handler(event, data) |
| 59 | |||
| 60 | |||
| 61 | class UserCacheMiddleware(BaseMiddleware): | ||
| 62 | async def __call__( | ||
| 63 | self, | ||
| 64 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], | ||
| 65 | event: TelegramObject, | ||
| 66 | data: dict[str, Any], | ||
| 67 | ) -> Any: | ||
| 68 | if not isinstance(event, (Message, CallbackQuery)): | ||
| 69 | raise TypeError( | ||
| 70 | f"UserAccessMiddleware doesn't support event with type: {type(event).__name__}", | ||
| 71 | event, | ||
| 72 | ) | ||
| 73 | |||
| 74 | if isinstance(event, Message): | ||
| 75 | user_cache = UserCache.from_chat(event.chat) | ||
| 76 | else: | ||
| 77 | user_cache = UserCache.from_user(event.from_user) | ||
| 78 | |||
| 79 | await set_user_cache(user_cache) | ||
| 80 | 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 + [ | |||
| 47 | description="Создать новый счёт на оплату", | 47 | description="Создать новый счёт на оплату", |
| 48 | ), | 48 | ), |
| 49 | BotCommand( | 49 | BotCommand( |
| 50 | command="add_user", | 50 | command="users", |
| 51 | description="дть пользователя", | 51 | description="ра пользователями", |
| 52 | ), | 52 | ), |
| 53 | BotCommand( | 53 | BotCommand( |
| 54 | command="suggest_list", | 54 | 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 ( | |||
| 18 | get_payment_status, | 18 | get_payment_status, |
| 19 | ) | 19 | ) |
| 20 | from libs.msg import eclipse_text | 20 | from libs.msg import eclipse_text |
| 21 | from libs.user import mention | 21 | from libs.user import load_user_cache |
| 22 | from models import Invoice, PaymentStatus, User | 22 | from models import Invoice, PaymentStatus, User |
| 23 | from models.callback_data import InvoiceItemClb, InvoicePageClb, PayInvoiceClb | 23 | from models.callback_data import InvoiceItemClb, InvoicePageClb, PayInvoiceClb |
| 24 | 24 | ||
| @@ -166,14 +166,14 @@ async def item( | |||
| 166 | ] | 166 | ] |
| 167 | ) | 167 | ) |
| 168 | 168 | ||
| 169 | await clb.message.edit_text(text_template.format("..."), reply_markup=reply_markup) | ||
| 170 | user_status = [] | 169 | user_status = [] |
| 171 | for user_id, s in invoice_payments.user_status.items(): | 170 | for user_id, s in invoice_payments.user_status.items(): |
| 172 | chat = await bot.get_chat(user_id) | 171 | user_cache = await load_user_cache(bot, user_id) |
| 173 | user_status.append(f"{PAYMENT_STATUS[s]} - {mention(chat)}") | 172 | user_status.append(f"{PAYMENT_STATUS[s]} - {user_cache.mention}") |
| 174 | await clb.message.edit_text( | 173 | |
| 175 | text_template.format("\n".join(user_status)), | 174 | await clb.message.edit_text( |
| 176 | reply_markup=reply_markup, | 175 | text_template.format("\n".join(user_status)), |
| 177 | ) | 176 | reply_markup=reply_markup, |
| 177 | ) | ||
| 178 | 178 | ||
| 179 | await clb.answer() | 179 | 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 | |||
| 19 | from sqlalchemy.ext.asyncio import AsyncSession | 19 | from sqlalchemy.ext.asyncio import AsyncSession |
| 20 | 20 | ||
| 21 | from libs.fsm import get_data, set_data | 21 | from libs.fsm import get_data, set_data |
| 22 | from libs.user import mention | ||
| 23 | from models import ( | 22 | from models import ( |
| 24 | Invoice, | 23 | Invoice, |
| 25 | Payment, | 24 | Payment, |
| @@ -27,6 +26,7 @@ from models import ( | |||
| 27 | ReceiptFile, | 26 | ReceiptFile, |
| 28 | ReceiptFileType, | 27 | ReceiptFileType, |
| 29 | User, | 28 | User, |
| 29 | UserCache, | ||
| 30 | UserRole, | 30 | UserRole, |
| 31 | ) | 31 | ) |
| 32 | from models.callback_data import PayInvoiceClb, PaymentStatusClb | 32 | from models.callback_data import PayInvoiceClb, PaymentStatusClb |
| @@ -112,6 +112,7 @@ async def receipt( | |||
| 112 | bot: Bot, | 112 | bot: Bot, |
| 113 | state: FSMContext, | 113 | state: FSMContext, |
| 114 | session: AsyncSession, | 114 | session: AsyncSession, |
| 115 | user_cache: UserCache, | ||
| 115 | ) -> None: | 116 | ) -> None: |
| 116 | if msg.document is not None: | 117 | if msg.document is not None: |
| 117 | receipt_file = ReceiptFile( | 118 | receipt_file = ReceiptFile( |
| @@ -173,7 +174,7 @@ async def receipt( | |||
| 173 | try: | 174 | try: |
| 174 | await bot.send_message( | 175 | await bot.send_message( |
| 175 | admin_id, | 176 | admin_id, |
| 176 | f"Новое подтверждение оплаты:\nПользователь: {mention(msg.chat)}", | 177 | f"Новое подтверждение оплаты:\nПользователь: {user_cache.mention}", |
| 177 | ) | 178 | ) |
| 178 | await receipt_file.send(bot, admin_id, reply_markup=reply_markup) | 179 | await receipt_file.send(bot, admin_id, reply_markup=reply_markup) |
| 179 | except TelegramAPIError as e: | 180 | 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 | |||
| 15 | 15 | ||
| 16 | from libs.invoice import get_payment_status | 16 | from libs.invoice import get_payment_status |
| 17 | from libs.msg import eclipse_text | 17 | from libs.msg import eclipse_text |
| 18 | from libs.user import mention | 18 | from libs.user import load_user_cache |
| 19 | from models import Invoice, Payment, PaymentStatus, User | 19 | from models import Invoice, Payment, PaymentStatus, User |
| 20 | from models.callback_data import ( | 20 | from models.callback_data import ( |
| 21 | PayInvoiceClb, | ||
| 21 | PaymentItemClb, | 22 | PaymentItemClb, |
| 22 | PaymentPageClb, | 23 | PaymentPageClb, |
| 23 | PaymentStatusClb, | 24 | PaymentStatusClb, |
| 24 | PayInvoiceClb, | ||
| 25 | ) | 25 | ) |
| 26 | 26 | ||
| 27 | router = Router(name="payments") | 27 | router = Router(name="payments") |
| @@ -213,7 +213,7 @@ async def item( | |||
| 213 | 213 | ||
| 214 | invoice = await session.get(Invoice, payment.invoice_id) | 214 | invoice = await session.get(Invoice, payment.invoice_id) |
| 215 | assert invoice is not None | 215 | assert invoice is not None |
| 216 | chat = await bot.get_chat(payment.user_id) | 216 | user_cache = await load_user_cache(bot, payment.user_id) |
| 217 | 217 | ||
| 218 | status_buttons = [] | 218 | status_buttons = [] |
| 219 | if payment.status != PaymentStatus.ACCEPTED: | 219 | if payment.status != PaymentStatus.ACCEPTED: |
| @@ -242,12 +242,10 @@ async def item( | |||
| 242 | text="Назад к выбору", | 242 | text="Назад к выбору", |
| 243 | callback_data=PaymentPageClb(page=callback_data.page).pack(), | 243 | callback_data=PaymentPageClb(page=callback_data.page).pack(), |
| 244 | ) | 244 | ) |
| 245 | reply_markup = InlineKeyboardMarkup( | 245 | reply_markup = InlineKeyboardMarkup(inline_keyboard=[status_buttons, [back_button]]) |
| 246 | inline_keyboard=[status_buttons, [back_button]] | ||
| 247 | ) | ||
| 248 | 246 | ||
| 249 | caption = ( | 247 | caption = ( |
| 250 | f"Платёж от {mention(chat)}\n" | 248 | f"Платёж от {user_cache.mention}\n" |
| 251 | f"Счёт: {eclipse_text(invoice.message.text, 30)}\n" | 249 | f"Счёт: {eclipse_text(invoice.message.text, 30)}\n" |
| 252 | f"Дата: {payment.datetime.strftime('%d %b %y г.')}\n" | 250 | f"Дата: {payment.datetime.strftime('%d %b %y г.')}\n" |
| 253 | f"Статус: {PAYMENT_STATUS[payment.status]}" | 251 | 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 @@ | |||
| 1 | from . import fsm, invoice, msg, storage, user | 1 | from . import fsm, invoice, msg, user |
| 2 | 2 | ||
| 3 | __all__ = [ | 3 | __all__ = [ |
| 4 | "storage", | ||
| 5 | "fsm", | 4 | "fsm", |
| 6 | "msg", | 5 | "msg", |
| 7 | "user", | 6 | "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 @@ | |||
| 1 | from pathlib import Path | ||
| 2 | from typing import Any, Mapping | ||
| 3 | |||
| 4 | from aiofiles import open as open | ||
| 5 | from aiogram.fsm.state import State | ||
| 6 | from aiogram.fsm.storage.base import ( | ||
| 7 | BaseStorage, | ||
| 8 | DefaultKeyBuilder, | ||
| 9 | KeyBuilder, | ||
| 10 | StateType, | ||
| 11 | StorageKey, | ||
| 12 | ) | ||
| 13 | from pydantic import TypeAdapter | ||
| 14 | from pydantic.main import BaseModel | ||
| 15 | |||
| 16 | |||
| 17 | class Record(BaseModel): | ||
| 18 | data: dict[str, Any] = {} | ||
| 19 | state: str | None = None | ||
| 20 | |||
| 21 | |||
| 22 | class JsonStorage(BaseStorage): | ||
| 23 | file_path: Path | ||
| 24 | records: dict[str, Record] | ||
| 25 | records_adapter: TypeAdapter | ||
| 26 | key_builder: KeyBuilder | ||
| 27 | |||
| 28 | def __init__(self, file_path: Path, key_builder: KeyBuilder | None = None) -> None: | ||
| 29 | self.file_path = file_path | ||
| 30 | self.records = {} | ||
| 31 | self.records_adapter = TypeAdapter(dict[str, Record]) | ||
| 32 | self.key_builder = DefaultKeyBuilder() if key_builder is None else key_builder | ||
| 33 | |||
| 34 | async def read(self) -> None: | ||
| 35 | async with open(self.file_path, "rb") as file: | ||
| 36 | json = await file.read() | ||
| 37 | self.records = self.records_adapter.validate_json(json) | ||
| 38 | |||
| 39 | async def flush(self) -> None: | ||
| 40 | async with open(self.file_path, "wb") as file: | ||
| 41 | json = self.records_adapter.dump_json(self.records) | ||
| 42 | await file.write(json) | ||
| 43 | |||
| 44 | async def get_record(self, key: StorageKey) -> Record: | ||
| 45 | await self.read() | ||
| 46 | record_key = self.key_builder.build(key) | ||
| 47 | if record_key not in self.records: | ||
| 48 | self.records[record_key] = Record() | ||
| 49 | return self.records[record_key] | ||
| 50 | |||
| 51 | async def set_state(self, key: StorageKey, state: StateType = None) -> None: | ||
| 52 | record = await self.get_record(key) | ||
| 53 | record.state = state.state if isinstance(state, State) else state | ||
| 54 | await self.flush() | ||
| 55 | |||
| 56 | async def get_state(self, key: StorageKey) -> str | None: | ||
| 57 | record = await self.get_record(key) | ||
| 58 | return record.state | ||
| 59 | |||
| 60 | async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: | ||
| 61 | if not isinstance(data, dict): | ||
| 62 | raise TypeError( | ||
| 63 | f"Data must be a dict or dict-like object, got {type(data).__name__}", | ||
| 64 | data, | ||
| 65 | ) | ||
| 66 | record = await self.get_record(key) | ||
| 67 | record.data = data.copy() | ||
| 68 | await self.flush() | ||
| 69 | |||
| 70 | async def get_data(self, key: StorageKey) -> dict[str, Any]: | ||
| 71 | record = await self.get_record(key) | ||
| 72 | return record.data | ||
| 73 | |||
| 74 | async def close(self) -> None: | ||
| 75 | 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 @@ | |||
| 1 | from aiogram.types import Chat, User | 1 | from aiogram import Bot |
| 2 | 2 | ||
| 3 | from models import UserCache | ||
| 4 | from shared import redis_users | ||
| 3 | 5 | ||
| 4 | def mention(user: User | Chat) -> str: | 6 | |
| 5 | return f'<a href="tg://user?id={user.id}">{user.full_name}</a>' | 7 | async def set_user_cache(user_cache: UserCache) -> None: |
| 8 | await redis_users.set( | ||
| 9 | str(user_cache.id), | ||
| 10 | user_cache.model_dump_json( | ||
| 11 | exclude_defaults=True, | ||
| 12 | exclude_none=True, | ||
| 13 | ), | ||
| 14 | ) | ||
| 15 | |||
| 16 | |||
| 17 | async def get_user_cache(user_id: int) -> UserCache | None: | ||
| 18 | user_cache = await redis_users.get(str(user_id)) | ||
| 19 | if user_cache is None: | ||
| 20 | return None | ||
| 21 | return UserCache.model_validate_json(user_cache) | ||
| 22 | |||
| 23 | |||
| 24 | async def load_user_cache(bot: Bot, user_id: int) -> UserCache: | ||
| 25 | user_cache = await get_user_cache(user_id) | ||
| 26 | if user_cache is None: | ||
| 27 | user_cache = UserCache.from_chat(await bot.get_chat(user_id)) | ||
| 28 | await set_user_cache(user_cache) | ||
| 29 | 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 @@ | |||
| 1 | # isort: off | 1 | # isort: off |
| 2 | from .base import BaseTable | 2 | from .base import BaseTable |
| 3 | from .rich_text import RichText | 3 | from .rich_text import RichText |
| 4 | from .user import User, UserRole | 4 | from .user import UserCache, User, UserRole |
| 5 | from .invoce import Invoice | 5 | from .invoce import Invoice |
| 6 | from .payment import Payment, PaymentStatus, ReceiptFile, ReceiptFileType | 6 | from .payment import Payment, PaymentStatus, ReceiptFile, ReceiptFileType |
| 7 | from .announcement import Announcement | 7 | from .announcement import Announcement |
| @@ -10,6 +10,7 @@ from . import callback_data | |||
| 10 | 10 | ||
| 11 | __all__ = [ | 11 | __all__ = [ |
| 12 | "BaseTable", | 12 | "BaseTable", |
| 13 | "UserCache", | ||
| 13 | "User", | 14 | "User", |
| 14 | "UserRole", | 15 | "UserRole", |
| 15 | "Invoice", | 16 | "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 @@ | |||
| 1 | from aiogram.filters.callback_data import CallbackData | 1 | from aiogram.filters.callback_data import CallbackData |
| 2 | 2 | ||
| 3 | from models import PaymentStatus | 3 | from models import PaymentStatus, UserRole |
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | class PayInvoiceClb(CallbackData, prefix="pay_invoice"): | 6 | class PayInvoiceClb(CallbackData, prefix="pay_invoice"): |
| @@ -37,3 +37,42 @@ class PaymentPageClb(CallbackData, prefix="payment.p"): | |||
| 37 | class PaymentItemClb(CallbackData, prefix="payment.i"): | 37 | class PaymentItemClb(CallbackData, prefix="payment.i"): |
| 38 | page: int | 38 | page: int |
| 39 | payment_id: int | 39 | payment_id: int |
| 40 | |||
| 41 | |||
| 42 | class UserPageClb(CallbackData, prefix="user.p"): | ||
| 43 | page: int | ||
| 44 | |||
| 45 | |||
| 46 | class UserItemClb(CallbackData, prefix="user.i"): | ||
| 47 | page: int | ||
| 48 | user_id: int | ||
| 49 | |||
| 50 | |||
| 51 | class UserAddClb(CallbackData, prefix="user.a"): | ||
| 52 | pass | ||
| 53 | |||
| 54 | |||
| 55 | class UserRoleClb(CallbackData, prefix="user.e.r"): | ||
| 56 | page: int | ||
| 57 | user_id: int | ||
| 58 | |||
| 59 | |||
| 60 | class UserRoleSetClb(CallbackData, prefix="user.e.r.s"): | ||
| 61 | page: int | ||
| 62 | user_id: int | ||
| 63 | role: UserRole | ||
| 64 | |||
| 65 | |||
| 66 | class UserDeleteClb(CallbackData, prefix="user.e.d"): | ||
| 67 | page: int | ||
| 68 | user_id: int | ||
| 69 | |||
| 70 | |||
| 71 | class UserDeleteConfirmClb(CallbackData, prefix="user.e.d.c"): | ||
| 72 | page: int | ||
| 73 | user_id: int | ||
| 74 | |||
| 75 | |||
| 76 | class UserVpnLinkClb(CallbackData, prefix="user.e.v"): | ||
| 77 | page: int | ||
| 78 | 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 @@ | |||
| 1 | from datetime import datetime | 1 | from datetime import datetime |
| 2 | from enum import IntEnum | 2 | from enum import IntEnum |
| 3 | from typing import Self | ||
| 3 | 4 | ||
| 5 | from aiogram.types import Chat | ||
| 6 | from aiogram.types import User as TgUser | ||
| 7 | from pydantic import BaseModel | ||
| 4 | from sqlalchemy.orm import Mapped, mapped_column | 8 | from sqlalchemy.orm import Mapped, mapped_column |
| 5 | 9 | ||
| 6 | from models import BaseTable | 10 | from models import BaseTable |
| 7 | 11 | ||
| 8 | 12 | ||
| 13 | class UserCache(BaseModel): | ||
| 14 | id: int | ||
| 15 | username: str | None = None | ||
| 16 | full_name: str | ||
| 17 | |||
| 18 | @classmethod | ||
| 19 | def from_chat(cls, chat: Chat) -> Self: | ||
| 20 | return cls( | ||
| 21 | id=chat.id, | ||
| 22 | username=chat.username, | ||
| 23 | full_name=chat.full_name, | ||
| 24 | ) | ||
| 25 | |||
| 26 | @classmethod | ||
| 27 | def from_user(cls, user: TgUser) -> Self: | ||
| 28 | return cls( | ||
| 29 | id=user.id, | ||
| 30 | username=user.username, | ||
| 31 | full_name=user.full_name, | ||
| 32 | ) | ||
| 33 | |||
| 34 | @property | ||
| 35 | def mention(self) -> str: | ||
| 36 | if self.username is not None: | ||
| 37 | return f'<a href="tg://{self.username}">{self.full_name}</a>' | ||
| 38 | else: | ||
| 39 | return f'<a href="tg://user?id={self.id}">{self.full_name}</a>' | ||
| 40 | |||
| 41 | |||
| 9 | class UserRole(IntEnum): | 42 | class UserRole(IntEnum): |
| 10 | REGULAR = 0 | 43 | REGULAR = 0 |
| 11 | ADMIN = 1 | 44 | 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 = [ | |||
| 12 | "magic-filter>=1.0.12", | 12 | "magic-filter>=1.0.12", |
| 13 | "pydantic>=2.12.5", | 13 | "pydantic>=2.12.5", |
| 14 | "pydantic-settings>=2.13.1", | 14 | "pydantic-settings>=2.13.1", |
| 15 | "redis[hiredis]>=7.4.0", | ||
| 15 | "sqlalchemy[asyncio]>=2.0.48", | 16 | "sqlalchemy[asyncio]>=2.0.48", |
| 16 | ] | 17 | ] |
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) | |||
| 14 | database_path = storage_path / "database.db" | 14 | database_path = storage_path / "database.db" |
| 15 | database_url = f"sqlite+aiosqlite:///{database_path}" | 15 | database_url = f"sqlite+aiosqlite:///{database_path}" |
| 16 | 16 | ||
| 17 | json_storage_path = storage_path / "storage.json" | ||
| 18 | |||
| 19 | __all__ = [ | 17 | __all__ = [ |
| 20 | "database_path", | 18 | "database_path", |
| 21 | "database_url", | 19 | "database_url", |
| 22 | "json_storage_path", | ||
| 23 | "Env", | 20 | "Env", |
| 24 | ] | 21 | ] |
| @@ -2,14 +2,25 @@ from aiogram import Bot, Dispatcher | |||
| 2 | from aiogram.client.default import DefaultBotProperties | 2 | from aiogram.client.default import DefaultBotProperties |
| 3 | from aiogram.client.session.aiohttp import AiohttpSession | 3 | from aiogram.client.session.aiohttp import AiohttpSession |
| 4 | from aiogram.enums import ParseMode | 4 | from aiogram.enums import ParseMode |
| 5 | from aiogram.fsm.storage.base import DefaultKeyBuilder | ||
| 6 | from aiogram.fsm.storage.redis import RedisStorage | ||
| 7 | from redis.asyncio.client import Redis | ||
| 5 | 8 | ||
| 6 | from libs.storage import JsonStorage | 9 | from settings import Env |
| 7 | from settings import Env, json_storage_path | ||
| 8 | 10 | ||
| 9 | env = Env() # ty:ignore[missing-argument] # pyright: ignore[reportCallIssue] | 11 | env = Env() # ty:ignore[missing-argument] # pyright: ignore[reportCallIssue] |
| 12 | |||
| 13 | redis_users = Redis(host="redis", db=0) | ||
| 14 | |||
| 10 | bot = Bot( | 15 | bot = Bot( |
| 11 | token=env.token, | 16 | token=env.token, |
| 12 | session=AiohttpSession(proxy=env.proxy), | 17 | session=AiohttpSession(proxy=env.proxy), |
| 13 | default=DefaultBotProperties(parse_mode=ParseMode.HTML), | 18 | default=DefaultBotProperties(parse_mode=ParseMode.HTML), |
| 14 | ) | 19 | ) |
| 15 | dp = Dispatcher(storage=JsonStorage(json_storage_path)) | 20 | |
| 21 | dp = Dispatcher( | ||
| 22 | storage=RedisStorage( | ||
| 23 | Redis(host="redis", db=1), | ||
| 24 | DefaultKeyBuilder(), | ||
| 25 | ) | ||
| 26 | ) | ||
| @@ -232,6 +232,40 @@ wheels = [ | |||
| 232 | ] | 232 | ] |
| 233 | 233 | ||
| 234 | [[package]] | 234 | [[package]] |
| 235 | name = "hiredis" | ||
| 236 | version = "3.3.1" | ||
| 237 | source = { registry = "https://pypi.org/simple" } | ||
| 238 | 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" } | ||
| 239 | wheels = [ | ||
| 240 | { 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" }, | ||
| 241 | { 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" }, | ||
| 242 | { 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" }, | ||
| 243 | { 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" }, | ||
| 244 | { 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" }, | ||
| 245 | { 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" }, | ||
| 246 | { 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" }, | ||
| 247 | { 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" }, | ||
| 248 | { 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" }, | ||
| 249 | { 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" }, | ||
| 250 | { 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" }, | ||
| 251 | { 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" }, | ||
| 252 | { 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" }, | ||
| 253 | { 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" }, | ||
| 254 | { 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" }, | ||
| 255 | { 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" }, | ||
| 256 | { 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" }, | ||
| 257 | { 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" }, | ||
| 258 | { 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" }, | ||
| 259 | { 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" }, | ||
| 260 | { 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" }, | ||
| 261 | { 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" }, | ||
| 262 | { 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" }, | ||
| 263 | { 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" }, | ||
| 264 | { 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" }, | ||
| 265 | { 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" }, | ||
| 266 | ] | ||
| 267 | |||
| 268 | [[package]] | ||
| 235 | name = "idna" | 269 | name = "idna" |
| 236 | version = "3.11" | 270 | version = "3.11" |
| 237 | source = { registry = "https://pypi.org/simple" } | 271 | source = { registry = "https://pypi.org/simple" } |
| @@ -462,6 +496,20 @@ wheels = [ | |||
| 462 | ] | 496 | ] |
| 463 | 497 | ||
| 464 | [[package]] | 498 | [[package]] |
| 499 | name = "redis" | ||
| 500 | version = "7.4.0" | ||
| 501 | source = { registry = "https://pypi.org/simple" } | ||
| 502 | 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" } | ||
| 503 | wheels = [ | ||
| 504 | { 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" }, | ||
| 505 | ] | ||
| 506 | |||
| 507 | [package.optional-dependencies] | ||
| 508 | hiredis = [ | ||
| 509 | { name = "hiredis" }, | ||
| 510 | ] | ||
| 511 | |||
| 512 | [[package]] | ||
| 465 | name = "sqlalchemy" | 513 | name = "sqlalchemy" |
| 466 | version = "2.0.49" | 514 | version = "2.0.49" |
| 467 | source = { registry = "https://pypi.org/simple" } | 515 | source = { registry = "https://pypi.org/simple" } |
| @@ -526,6 +574,7 @@ dependencies = [ | |||
| 526 | { name = "magic-filter" }, | 574 | { name = "magic-filter" }, |
| 527 | { name = "pydantic" }, | 575 | { name = "pydantic" }, |
| 528 | { name = "pydantic-settings" }, | 576 | { name = "pydantic-settings" }, |
| 577 | { name = "redis", extra = ["hiredis"] }, | ||
| 529 | { name = "sqlalchemy", extra = ["asyncio"] }, | 578 | { name = "sqlalchemy", extra = ["asyncio"] }, |
| 530 | ] | 579 | ] |
| 531 | 580 | ||
| @@ -539,6 +588,7 @@ requires-dist = [ | |||
| 539 | { name = "magic-filter", specifier = ">=1.0.12" }, | 588 | { name = "magic-filter", specifier = ">=1.0.12" }, |
| 540 | { name = "pydantic", specifier = ">=2.12.5" }, | 589 | { name = "pydantic", specifier = ">=2.12.5" }, |
| 541 | { name = "pydantic-settings", specifier = ">=2.13.1" }, | 590 | { name = "pydantic-settings", specifier = ">=2.13.1" }, |
| 591 | { name = "redis", extras = ["hiredis"], specifier = ">=7.4.0" }, | ||
| 542 | { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.48" }, | 592 | { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.48" }, |
| 543 | ] | 593 | ] |
| 544 | 594 | ||
