aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTolmachev Igor <me@igorek.dev>2026-04-20 20:56:35 +0300
committerTolmachev Igor <me@igorek.dev>2026-04-20 20:56:35 +0300
commit75e99ca0712a2c09230e5c6f8d093dc526cc717d (patch)
treef3f00494364a82b866f093651cb9a08030135c4e
parentf186fca0f1aa9bbe5eab7613f229df527b2ab774 (diff)
downloadvpn_manager_bot-75e99ca0712a2c09230e5c6f8d093dc526cc717d.tar.gz
vpn_manager_bot-75e99ca0712a2c09230e5c6f8d093dc526cc717d.zip
Add users command
-rw-r--r--compose.yaml13
-rw-r--r--handlers/admin/__init__.py4
-rw-r--r--handlers/admin/add_user.py99
-rw-r--r--handlers/admin/users.py557
-rw-r--r--handlers/middleware.py24
-rw-r--r--handlers/user/info.py4
-rw-r--r--handlers/user/invoices.py16
-rw-r--r--handlers/user/pay_invoice.py5
-rw-r--r--handlers/user/payments.py12
-rw-r--r--libs/__init__.py3
-rw-r--r--libs/storage.py75
-rw-r--r--libs/user.py30
-rw-r--r--models/__init__.py3
-rw-r--r--models/callback_data.py41
-rw-r--r--models/user.py33
-rw-r--r--pyproject.toml1
-rw-r--r--settings.py3
-rw-r--r--shared.py17
-rw-r--r--uv.lock50
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
9volumes: 21volumes:
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
5from . import new_announcement 5from . import new_announcement
6from . import new_invoice 6from . import new_invoice
7from . import add_user 7from . import users
8from . import payment_status 8from . import payment_status
9# isort: on 9# isort: on
10 10
@@ -15,6 +15,6 @@ router.callback_query.filter(MagicData(F.user.is_admin()))
15router.include_routers( 15router.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 @@
1from datetime import UTC, datetime
2
3from aiogram import F, Router
4from aiogram.enums.button_style import ButtonStyle
5from aiogram.filters import Command
6from aiogram.fsm.context import FSMContext
7from aiogram.fsm.state import State, StatesGroup
8from aiogram.types import (
9 KeyboardButton,
10 KeyboardButtonRequestUsers,
11 Message,
12 ReplyKeyboardMarkup,
13 ReplyKeyboardRemove,
14)
15from pydantic import BaseModel
16from sqlalchemy.ext.asyncio import AsyncSession
17
18from libs.fsm import edit_data, get_data, set_data
19from models import User
20
21router = Router(name="add_user")
22
23
24class AddUserStates(StatesGroup):
25 user_id = State()
26 vpn_link = State()
27
28
29class AddUserData(BaseModel):
30 user_id: int | None = None
31
32
33CANCEL_BUTTON = "Отменить добавление"
34
35
36@router.message(Command("add_user"))
37async 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)
61async 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)
70async 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)
83async 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 @@
1from datetime import UTC, datetime
2from math import ceil
3
4from aiogram import Bot, F, Router
5from aiogram.enums import ButtonStyle
6from aiogram.filters import Command
7from aiogram.fsm.context import FSMContext
8from aiogram.fsm.state import State, StatesGroup
9from aiogram.types import (
10 CallbackQuery,
11 InlineKeyboardButton,
12 InlineKeyboardMarkup,
13 KeyboardButton,
14 KeyboardButtonRequestUsers,
15 Message,
16 ReplyKeyboardMarkup,
17 ReplyKeyboardRemove,
18)
19from pydantic import BaseModel
20from sqlalchemy import delete, select
21from sqlalchemy.ext.asyncio import AsyncSession
22from sqlalchemy.sql.functions import count
23
24from libs.fsm import edit_data, get_data, set_data
25from libs.user import load_user_cache
26from models import Payment, User, UserRole
27from models.callback_data import (
28 UserAddClb,
29 UserDeleteClb,
30 UserDeleteConfirmClb,
31 UserItemClb,
32 UserPageClb,
33 UserRoleClb,
34 UserRoleSetClb,
35 UserVpnLinkClb,
36)
37
38router = Router(name="users")
39PAGE_SIZE = 5
40ROLE_ICON = {
41 UserRole.ADMIN: "👑",
42 UserRole.REGULAR: "👤",
43}
44ROLE_NAME = {
45 UserRole.ADMIN: "Администратор",
46 UserRole.REGULAR: "Пользователь",
47}
48
49LIST_TEXT = "Список пользователей:"
50ADD_CANCEL_BUTTON = "Отменить добавление"
51EDIT_CANCEL_BUTTON = "Отменить изменение"
52
53
54class AddUserStates(StatesGroup):
55 user_id = State()
56 vpn_link = State()
57
58
59class AddUserData(BaseModel):
60 user_id: int | None = None
61
62
63class EditVpnLinkStates(StatesGroup):
64 vpn_link = State()
65
66
67class EditVpnLinkData(BaseModel):
68 page: int
69 user_id: int
70
71
72async 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
124def 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
156def 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
189def 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
212async 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"))
222async 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())
230async 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())
245async 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())
265async 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())
289async 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())
318async 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())
344async 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())
373async 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
405async 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)
414async 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)
435async 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())
464async 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)
494async 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)
513async 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)
534async 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
5from sqlalchemy.ext.asyncio import AsyncSession 5from sqlalchemy.ext.asyncio import AsyncSession
6 6
7from database import sessions 7from database import sessions
8from libs.user import set_user_cache
8from models import User 9from models import User
10from models.user import UserCache
9 11
10 12
11class InjectSessionMiddleware(BaseMiddleware): 13class 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
61class 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)
20from libs.msg import eclipse_text 20from libs.msg import eclipse_text
21from libs.user import mention 21from libs.user import load_user_cache
22from models import Invoice, PaymentStatus, User 22from models import Invoice, PaymentStatus, User
23from models.callback_data import InvoiceItemClb, InvoicePageClb, PayInvoiceClb 23from 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
19from sqlalchemy.ext.asyncio import AsyncSession 19from sqlalchemy.ext.asyncio import AsyncSession
20 20
21from libs.fsm import get_data, set_data 21from libs.fsm import get_data, set_data
22from libs.user import mention
23from models import ( 22from 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)
32from models.callback_data import PayInvoiceClb, PaymentStatusClb 32from 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
16from libs.invoice import get_payment_status 16from libs.invoice import get_payment_status
17from libs.msg import eclipse_text 17from libs.msg import eclipse_text
18from libs.user import mention 18from libs.user import load_user_cache
19from models import Invoice, Payment, PaymentStatus, User 19from models import Invoice, Payment, PaymentStatus, User
20from models.callback_data import ( 20from models.callback_data import (
21 PayInvoiceClb,
21 PaymentItemClb, 22 PaymentItemClb,
22 PaymentPageClb, 23 PaymentPageClb,
23 PaymentStatusClb, 24 PaymentStatusClb,
24 PayInvoiceClb,
25) 25)
26 26
27router = Router(name="payments") 27router = 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 @@
1from . import fsm, invoice, msg, storage, user 1from . 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 @@
1from pathlib import Path
2from typing import Any, Mapping
3
4from aiofiles import open as open
5from aiogram.fsm.state import State
6from aiogram.fsm.storage.base import (
7 BaseStorage,
8 DefaultKeyBuilder,
9 KeyBuilder,
10 StateType,
11 StorageKey,
12)
13from pydantic import TypeAdapter
14from pydantic.main import BaseModel
15
16
17class Record(BaseModel):
18 data: dict[str, Any] = {}
19 state: str | None = None
20
21
22class 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 @@
1from aiogram.types import Chat, User 1from aiogram import Bot
2 2
3from models import UserCache
4from shared import redis_users
3 5
4def mention(user: User | Chat) -> str: 6
5 return f'<a href="tg://user?id={user.id}">{user.full_name}</a>' 7async 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
17async 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
24async 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
2from .base import BaseTable 2from .base import BaseTable
3from .rich_text import RichText 3from .rich_text import RichText
4from .user import User, UserRole 4from .user import UserCache, User, UserRole
5from .invoce import Invoice 5from .invoce import Invoice
6from .payment import Payment, PaymentStatus, ReceiptFile, ReceiptFileType 6from .payment import Payment, PaymentStatus, ReceiptFile, ReceiptFileType
7from .announcement import Announcement 7from .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 @@
1from aiogram.filters.callback_data import CallbackData 1from aiogram.filters.callback_data import CallbackData
2 2
3from models import PaymentStatus 3from models import PaymentStatus, UserRole
4 4
5 5
6class PayInvoiceClb(CallbackData, prefix="pay_invoice"): 6class PayInvoiceClb(CallbackData, prefix="pay_invoice"):
@@ -37,3 +37,42 @@ class PaymentPageClb(CallbackData, prefix="payment.p"):
37class PaymentItemClb(CallbackData, prefix="payment.i"): 37class PaymentItemClb(CallbackData, prefix="payment.i"):
38 page: int 38 page: int
39 payment_id: int 39 payment_id: int
40
41
42class UserPageClb(CallbackData, prefix="user.p"):
43 page: int
44
45
46class UserItemClb(CallbackData, prefix="user.i"):
47 page: int
48 user_id: int
49
50
51class UserAddClb(CallbackData, prefix="user.a"):
52 pass
53
54
55class UserRoleClb(CallbackData, prefix="user.e.r"):
56 page: int
57 user_id: int
58
59
60class UserRoleSetClb(CallbackData, prefix="user.e.r.s"):
61 page: int
62 user_id: int
63 role: UserRole
64
65
66class UserDeleteClb(CallbackData, prefix="user.e.d"):
67 page: int
68 user_id: int
69
70
71class UserDeleteConfirmClb(CallbackData, prefix="user.e.d.c"):
72 page: int
73 user_id: int
74
75
76class 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 @@
1from datetime import datetime 1from datetime import datetime
2from enum import IntEnum 2from enum import IntEnum
3from typing import Self
3 4
5from aiogram.types import Chat
6from aiogram.types import User as TgUser
7from pydantic import BaseModel
4from sqlalchemy.orm import Mapped, mapped_column 8from sqlalchemy.orm import Mapped, mapped_column
5 9
6from models import BaseTable 10from models import BaseTable
7 11
8 12
13class 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
9class UserRole(IntEnum): 42class 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)
14database_path = storage_path / "database.db" 14database_path = storage_path / "database.db"
15database_url = f"sqlite+aiosqlite:///{database_path}" 15database_url = f"sqlite+aiosqlite:///{database_path}"
16 16
17json_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]
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
2from aiogram.client.default import DefaultBotProperties 2from aiogram.client.default import DefaultBotProperties
3from aiogram.client.session.aiohttp import AiohttpSession 3from aiogram.client.session.aiohttp import AiohttpSession
4from aiogram.enums import ParseMode 4from aiogram.enums import ParseMode
5from aiogram.fsm.storage.base import DefaultKeyBuilder
6from aiogram.fsm.storage.redis import RedisStorage
7from redis.asyncio.client import Redis
5 8
6from libs.storage import JsonStorage 9from settings import Env
7from settings import Env, json_storage_path
8 10
9env = Env() # ty:ignore[missing-argument] # pyright: ignore[reportCallIssue] 11env = Env() # ty:ignore[missing-argument] # pyright: ignore[reportCallIssue]
12
13redis_users = Redis(host="redis", db=0)
14
10bot = Bot( 15bot = 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)
15dp = Dispatcher(storage=JsonStorage(json_storage_path)) 20
21dp = Dispatcher(
22 storage=RedisStorage(
23 Redis(host="redis", db=1),
24 DefaultKeyBuilder(),
25 )
26)
diff --git a/uv.lock b/uv.lock
index 090a19e..084c552 100644
--- a/uv.lock
+++ b/uv.lock
@@ -232,6 +232,40 @@ wheels = [
232] 232]
233 233
234[[package]] 234[[package]]
235name = "hiredis"
236version = "3.3.1"
237source = { registry = "https://pypi.org/simple" }
238sdist = { 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" }
239wheels = [
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]]
235name = "idna" 269name = "idna"
236version = "3.11" 270version = "3.11"
237source = { registry = "https://pypi.org/simple" } 271source = { registry = "https://pypi.org/simple" }
@@ -462,6 +496,20 @@ wheels = [
462] 496]
463 497
464[[package]] 498[[package]]
499name = "redis"
500version = "7.4.0"
501source = { registry = "https://pypi.org/simple" }
502sdist = { 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" }
503wheels = [
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]
508hiredis = [
509 { name = "hiredis" },
510]
511
512[[package]]
465name = "sqlalchemy" 513name = "sqlalchemy"
466version = "2.0.49" 514version = "2.0.49"
467source = { registry = "https://pypi.org/simple" } 515source = { 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