diff --git a/database/referrals.py b/database/referrals.py
index 2a41e51e..3867e15c 100644
--- a/database/referrals.py
+++ b/database/referrals.py
@@ -171,7 +171,8 @@ async def get_total_referral_bonus(
result = await session.execute(
text(bonus_query), {"tg_id": referrer_tg_id, "max_levels": max_levels}
)
- total_bonus = result.scalar() or 0.0
+ total_bonus_raw = result.scalar()
+ total_bonus = round(float(total_bonus_raw or 0), 2)
logger.debug(f"Получена общая сумма бонусов от рефералов: {total_bonus}")
return total_bonus
diff --git a/database/statistics.py b/database/statistics.py
index 0514b768..d383ff2c 100644
--- a/database/statistics.py
+++ b/database/statistics.py
@@ -55,11 +55,26 @@ async def count_trial_keys(session: AsyncSession) -> int:
)
-async def get_tariff_distribution(session: AsyncSession) -> list[tuple[int, int]]:
+async def get_tariff_distribution(
+ session: AsyncSession, include_unbound: bool = False
+) -> tuple[list[tuple[int, int]], list[dict]]:
result = await session.execute(
- select(Key.tariff_id, func.count(Key.client_id)).group_by(Key.tariff_id)
+ select(Key.tariff_id, func.count(Key.client_id))
+ .where(Key.tariff_id.isnot(None))
+ .group_by(Key.tariff_id)
)
- return result.all()
+ tariff_counts = result.all()
+
+ if not include_unbound:
+ return tariff_counts
+
+ result = await session.execute(
+ select(Key.expiry_time)
+ .where(Key.tariff_id.is_(None))
+ )
+ no_tariff_keys = [{"expiry_time": row[0]} for row in result.all()]
+
+ return tariff_counts, no_tariff_keys
async def get_tariff_names(
@@ -79,20 +94,23 @@ async def count_total_referrals(session: AsyncSession) -> int:
async def sum_payments_since(session: AsyncSession, since: date) -> float:
- return await session.scalar(
- select(func.coalesce(func.sum(Payment.amount), 0)).where(
- Payment.created_at >= since
- )
+ result = await session.scalar(
+ select(func.coalesce(func.sum(Payment.amount), 0))
+ .where(Payment.created_at >= since)
)
+ return round(float(result), 2)
async def sum_payments_between(session: AsyncSession, start: date, end: date) -> float:
- return await session.scalar(
- select(func.coalesce(func.sum(Payment.amount), 0)).where(
- Payment.created_at >= start, Payment.created_at < end
- )
+ result = await session.scalar(
+ select(func.coalesce(func.sum(Payment.amount), 0))
+ .where(Payment.created_at >= start, Payment.created_at < end)
)
+ return round(float(result), 2)
async def sum_total_payments(session: AsyncSession) -> float:
- return await session.scalar(select(func.coalesce(func.sum(Payment.amount), 0)))
+ result = await session.scalar(
+ select(func.coalesce(func.sum(Payment.amount), 0))
+ )
+ return round(float(result), 2)
diff --git a/handlers/admin/stats/stats_handler.py b/handlers/admin/stats/stats_handler.py
index 4fb5d358..f8e0c46c 100644
--- a/handlers/admin/stats/stats_handler.py
+++ b/handlers/admin/stats/stats_handler.py
@@ -5,6 +5,8 @@ from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
+from collections import Counter
+
from bot import bot
from config import ADMIN_ID
@@ -67,17 +69,36 @@ async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
expired_keys = total_keys - active_keys
trial_keys_count = await count_trial_keys(session)
- tariff_counts = await get_tariff_distribution(session)
- tariff_names = await get_tariff_names(
- session, [tid for tid, _ in tariff_counts]
- )
+ tariff_counts, no_tariff_keys = await get_tariff_distribution(session, include_unbound=True)
+ tariff_names = await get_tariff_names(session, [tid for tid, _ in tariff_counts])
tariff_stats_text = ""
for tid, count in tariff_counts:
name = tariff_names.get(tid, f"ID {tid}")
tariff_stats_text += f"├ {name}: {count}\n"
+
+ duration_buckets = Counter()
+ now_ts = int(datetime.utcnow().timestamp() * 1000)
+
+ for key in no_tariff_keys:
+ duration_days = round((key["expiry_time"] - now_ts) / (1000 * 60 * 60 * 24))
+ if 25 <= duration_days <= 35:
+ bucket = "Без тарифа: 1 мес"
+ elif 80 <= duration_days <= 100:
+ bucket = "Без тарифа: 3 мес"
+ elif 170 <= duration_days <= 200:
+ bucket = "Без тарифа: 6 мес"
+ elif 350 <= duration_days <= 380:
+ bucket = "Без тарифа: 12 мес"
+ else:
+ bucket = "Без тарифа: прочее"
+ duration_buckets[bucket] += 1
+
+ for name, count in duration_buckets.items():
+ tariff_stats_text += f"├ {name}: {count}\n"
+
tariff_stats_text = (
- "└ По тарифам:\n" + tariff_stats_text
+ "└ По тарифам и срокам:\n" + tariff_stats_text
if tariff_stats_text
else "└ Нет данных по тарифам\n"
)
diff --git a/handlers/admin/users/users_handler.py b/handlers/admin/users/users_handler.py
index 0eb6c42f..2b6cb007 100644
--- a/handlers/admin/users/users_handler.py
+++ b/handlers/admin/users/users_handler.py
@@ -82,6 +82,11 @@ class UserEditorState(StatesGroup):
selecting_country = State()
+class RenewTariffState(StatesGroup):
+ selecting_group = State()
+ selecting_tariff = State()
+
+
class BanUserStates(StatesGroup):
waiting_for_reason = State()
waiting_for_ban_duration = State()
@@ -554,14 +559,20 @@ async def handle_key_edit(
)
-@router.callback_query(
- AdminUserEditorCallback.filter(F.action == "users_back"), IsAdminFilter()
-)
-async def handle_users_back_to_key_edit(
+@router.callback_query(F.data == "back:renew", IsAdminFilter())
+async def handle_back_to_key_menu(
callback_query: CallbackQuery,
- callback_data: AdminUserEditorCallback,
session: AsyncSession,
+ state: FSMContext,
):
+ data = await state.get_data()
+ email = data["email"]
+ tg_id = data["tg_id"]
+ await state.clear()
+
+ callback_data = AdminUserEditorCallback(
+ action="users_key_edit", data=email, tg_id=tg_id
+ )
await handle_key_edit(
callback_query=callback_query,
callback_data=callback_data,
@@ -577,27 +588,21 @@ async def handle_user_choose_tariff_group(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
session: AsyncSession,
+ state: FSMContext,
):
email = callback_data.data
tg_id = callback_data.tg_id
+ await state.set_state(RenewTariffState.selecting_group)
+ await state.update_data(email=email, tg_id=tg_id)
+
result = await session.execute(select(Tariff.group_code).distinct())
groups = [row[0] for row in result.fetchall()]
builder = InlineKeyboardBuilder()
for group_code in groups:
- builder.button(
- text=group_code,
- callback_data=AdminUserEditorCallback(
- action="users_renew_group", data=f"{email}|{group_code}", tg_id=tg_id
- ).pack(),
- )
- builder.button(
- text="🔙 Назад",
- callback_data=AdminUserEditorCallback(
- action="users_back", data=email, tg_id=tg_id
- ).pack(),
- )
+ builder.button(text=group_code, callback_data=f"group:{group_code}")
+ builder.button(text="🔙 Назад", callback_data="back:renew")
builder.adjust(1)
await callback_query.message.edit_text(
@@ -606,16 +611,15 @@ async def handle_user_choose_tariff_group(
)
-@router.callback_query(
- AdminUserEditorCallback.filter(F.action == "users_renew_group"), IsAdminFilter()
-)
+@router.callback_query(F.data.startswith("group:"), IsAdminFilter())
async def handle_user_choose_tariff(
callback_query: CallbackQuery,
- callback_data: AdminUserEditorCallback,
session: AsyncSession,
+ state: FSMContext,
):
- email, group_code = callback_data.data.split("|")
- tg_id = callback_data.tg_id
+ group_code = callback_query.data.split(":", 1)[1]
+ await state.update_data(group_code=group_code)
+ await state.set_state(RenewTariffState.selecting_tariff)
result = await session.execute(
select(Tariff)
@@ -631,19 +635,10 @@ async def handle_user_choose_tariff(
builder = InlineKeyboardBuilder()
for tariff in tariffs:
builder.button(
- text=f"{tariff.duration_days}д / {int(tariff.price_rub)}₽",
- callback_data=AdminUserEditorCallback(
- action="users_renew_confirm",
- data=f"{email}|{tariff.id}",
- tg_id=tg_id,
- ).pack(),
+ text=f"{tariff.name} – {int(tariff.price_rub)}₽",
+ callback_data=f"confirm:{tariff.id}"
)
- builder.button(
- text="🔙 Назад",
- callback_data=AdminUserEditorCallback(
- action="users_renew", data=email, tg_id=tg_id
- ).pack(),
- )
+ builder.button(text="🔙 Назад", callback_data="back:group")
builder.adjust(1)
await callback_query.message.edit_text(
@@ -652,17 +647,16 @@ async def handle_user_choose_tariff(
)
-@router.callback_query(
- AdminUserEditorCallback.filter(F.action == "users_renew_confirm"), IsAdminFilter()
-)
+@router.callback_query(F.data.startswith("confirm:"), IsAdminFilter())
async def handle_user_renew_confirm(
callback_query: CallbackQuery,
- callback_data: AdminUserEditorCallback,
session: AsyncSession,
+ state: FSMContext,
):
- email, tariff_id = callback_data.data.split("|")
- tg_id = callback_data.tg_id
- tariff_id = int(tariff_id)
+ tariff_id = int(callback_query.data.split(":")[1])
+ data = await state.get_data()
+ email = data["email"]
+ tg_id = data["tg_id"]
stmt = (
update(Key)
@@ -671,11 +665,42 @@ async def handle_user_renew_confirm(
)
await session.execute(stmt)
await session.commit()
+ await state.clear()
+
+ callback_data = AdminUserEditorCallback(
+ action="users_key_edit", data=email, tg_id=tg_id
+ )
+
+ await handle_key_edit(
+ callback_query=callback_query,
+ callback_data=callback_data,
+ session=session,
+ update=False,
+ )
+
+
+@router.callback_query(F.data == "back:group", IsAdminFilter())
+async def handle_back_to_group(
+ callback_query: CallbackQuery,
+ state: FSMContext,
+ session: AsyncSession,
+):
+ data = await state.get_data()
+
+ result = await session.execute(select(Tariff.group_code).distinct())
+ groups = [row[0] for row in result.fetchall()]
+
+ builder = InlineKeyboardBuilder()
+ for group_code in groups:
+ builder.button(text=group_code, callback_data=f"group:{group_code}")
+ builder.button(text="🔙 Назад", callback_data="back:renew")
+ builder.adjust(1)
await callback_query.message.edit_text(
- text="✅ Тариф успешно обновлён.",
- reply_markup=build_key_edit_kb({"tg_id": tg_id}, email),
+ text="📁 Выберите тарифную группу:",
+ reply_markup=builder.as_markup(),
)
+ await state.set_state(RenewTariffState.selecting_group)
@router.callback_query(