Merge pull request #1422 from Fr1ngg/dev4

Dev4
This commit is contained in:
Egor
2025-10-21 09:45:23 +03:00
committed by GitHub
5 changed files with 448 additions and 126 deletions

View File

@@ -231,6 +231,16 @@ async def handle_subscription_config_back(
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.confirming_purchase.state:
data = await state.get_data()
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
else:
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
@@ -80,14 +81,64 @@ from app.utils.promo_offer import (
)
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices
from .autopay import handle_autopay_menu, handle_subscription_cancel, handle_subscription_config_back, set_autopay_days, show_autopay_days, toggle_autopay
from .countries import _get_available_countries, _should_show_countries_management, apply_countries_changes, countries_continue, handle_add_countries, handle_manage_country, select_country
from .devices import confirm_add_devices, confirm_change_devices, confirm_reset_devices, execute_change_devices, get_current_devices_count, get_servers_display_names, handle_all_devices_reset_from_management, handle_app_selection, handle_change_devices, handle_device_guide, handle_device_management, handle_devices_page, handle_reset_devices, handle_single_device_reset, handle_specific_app_guide, show_device_connection_help
from .happ import handle_happ_download_back, handle_happ_download_close, handle_happ_download_platform_choice, handle_happ_download_request
from .autopay import (
handle_autopay_menu,
handle_subscription_cancel,
handle_subscription_config_back,
set_autopay_days,
show_autopay_days,
toggle_autopay,
)
from .countries import (
_get_available_countries,
_should_show_countries_management,
apply_countries_changes,
countries_continue,
handle_add_countries,
handle_manage_country,
select_country,
)
from .devices import (
confirm_add_devices,
confirm_change_devices,
confirm_reset_devices,
execute_change_devices,
get_current_devices_count,
get_servers_display_names,
handle_all_devices_reset_from_management,
handle_app_selection,
handle_change_devices,
handle_device_guide,
handle_device_management,
handle_devices_page,
handle_reset_devices,
handle_single_device_reset,
handle_specific_app_guide,
show_device_connection_help,
)
from .happ import (
handle_happ_download_back,
handle_happ_download_close,
handle_happ_download_platform_choice,
handle_happ_download_request,
)
from .links import handle_connect_subscription, handle_open_subscription_link
from .pricing import _build_subscription_period_prompt, _prepare_subscription_summary
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint, claim_discount_offer, handle_promo_offer_close
from .traffic import confirm_reset_traffic, confirm_switch_traffic, execute_switch_traffic, handle_no_traffic_packages, handle_reset_traffic, handle_switch_traffic, select_traffic
from .promo import (
_build_promo_group_discount_text,
_get_promo_offer_hint,
claim_discount_offer,
handle_promo_offer_close,
)
from .traffic import (
confirm_reset_traffic,
confirm_switch_traffic,
execute_switch_traffic,
handle_no_traffic_packages,
handle_reset_traffic,
handle_switch_traffic,
select_traffic,
)
async def show_subscription_info(
callback: types.CallbackQuery,
@@ -528,11 +579,12 @@ async def start_subscription_purchase(
texts = get_texts(db_user.language)
keyboard = get_subscription_period_keyboard(db_user.language)
prompt_text = await _build_subscription_period_prompt(db_user, texts, db)
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=keyboard,
parse_mode="HTML",
await _edit_message_text_or_caption(
callback.message,
prompt_text,
keyboard,
)
subscription = getattr(db_user, 'subscription', None)
@@ -557,6 +609,46 @@ async def start_subscription_purchase(
await state.set_state(SubscriptionStates.selecting_period)
await callback.answer()
async def _edit_message_text_or_caption(
message: types.Message,
text: str,
reply_markup: InlineKeyboardMarkup,
parse_mode: Optional[str] = "HTML",
) -> None:
"""Edits message text when possible, falls back to caption or re-sends message."""
try:
await message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
except TelegramBadRequest as error:
error_message = str(error).lower()
if "message is not modified" in error_message:
return
if "there is no text in the message to edit" in error_message:
if message.caption is not None:
await message.edit_caption(
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
return
await message.delete()
await message.answer(
text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
return
raise
async def save_cart_and_redirect_to_topup(
callback: types.CallbackQuery,
state: FSMContext,
@@ -2310,6 +2402,11 @@ async def handle_simple_subscription_purchase(
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
traffic_text = (
"Безлимит"
if subscription_params["traffic_limit_gb"] == 0
else f"{subscription_params['traffic_limit_gb']} ГБ"
)
if user_balance_kopeks >= price_kopeks:
# Если баланс достаточный, предлагаем оплатить с баланса
@@ -2317,7 +2414,7 @@ async def handle_simple_subscription_purchase(
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"📊 Трафик: {traffic_text}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
@@ -2335,7 +2432,7 @@ async def handle_simple_subscription_purchase(
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"📊 Трафик: {traffic_text}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"

View File

@@ -1370,7 +1370,7 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
for days in [1, 3, 7, 14]:
keyboard.append([
InlineKeyboardButton(
text=f"{days} дн{_get_days_suffix(days)}",
text=f"{days} {_get_days_word(days)}",
callback_data=f"autopay_days_{days}"
)
])
@@ -1382,13 +1382,12 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def _get_days_suffix(days: int) -> str:
if days == 1:
return "ь"
elif 2 <= days <= 4:
return "я"
else:
return "ей"
def _get_days_word(days: int) -> str:
if days % 10 == 1 and days % 100 != 11:
return "день"
if 2 <= days % 10 <= 4 and not (12 <= days % 100 <= 14):
return "дня"
return "дней"

View File

@@ -3,13 +3,16 @@ import logging
from dataclasses import dataclass
from datetime import date, datetime, time as datetime_time, timedelta, timezone
from enum import Enum
from typing import Optional, Tuple
from html import escape
from typing import Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from sqlalchemy import func, select
from sqlalchemy import cast, func, not_, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import false, true
from app.config import settings
from app.database.crud.subscription import get_subscriptions_statistics
@@ -17,10 +20,12 @@ from app.database.database import AsyncSessionLocal
from app.database.models import (
Subscription,
SubscriptionConversion,
SubscriptionStatus,
Ticket,
TicketStatus,
Transaction,
TransactionType,
User,
)
@@ -45,7 +50,7 @@ class ReportPeriodRange:
class ReportingService:
"""Generates admin summary reports and can schedule daily delivery."""
"""Generates admin summary reports (text only, no charts)."""
def __init__(self) -> None:
self.bot: Optional[Bot] = None
@@ -170,11 +175,64 @@ class ReportingService:
chat_id=chat_id,
text=report_text,
message_thread_id=topic_id,
parse_mode="HTML",
)
except (TelegramBadRequest, TelegramForbiddenError) as exc:
logger.error("Не удалось отправить отчет: %s", exc)
raise ReportingServiceError("Не удалось отправить отчет в чат") from exc
# ---------- referral helpers ----------
def _referral_markers(self) -> List:
"""
Набор условий, по которым операция помечается как реферальная (если вдруг записана типом DEPOSIT).
"""
clauses = []
# Явные флаги
if hasattr(Transaction, "is_referral_bonus"):
clauses.append(Transaction.is_referral_bonus == true())
if hasattr(Transaction, "is_bonus"):
clauses.append(Transaction.is_bonus == true())
# Источник/причина
if hasattr(Transaction, "source"):
clauses.append(Transaction.source == "referral")
clauses.append(Transaction.source == "referral_bonus")
if hasattr(Transaction, "reason"):
clauses.append(Transaction.reason == "referral")
clauses.append(Transaction.reason == "referral_bonus")
clauses.append(Transaction.reason == "referral_reward")
# Текстовые поля
like_patterns = ["%реферал%", "%реферальн%", "%referral%"]
if hasattr(Transaction, "description"):
for pattern in like_patterns:
try:
clauses.append(Transaction.description.ilike(pattern))
except Exception: # noqa: BLE001 - best effort
pass
if hasattr(Transaction, "comment"):
for pattern in like_patterns:
try:
clauses.append(Transaction.comment.ilike(pattern))
except Exception: # noqa: BLE001 - best effort
pass
return [clause for clause in clauses if clause is not None]
def _exclude_referral_deposits_condition(self):
"""
Условие «это НЕ реферальный бонус».
Если нет ни одного маркера — ничего не исключаем.
"""
markers = self._referral_markers()
if not markers:
return true()
return not_(or_(*markers))
# --------------------------------------
async def _build_report(
self,
period: ReportPeriod,
@@ -186,38 +244,91 @@ class ReportingService:
async with AsyncSessionLocal() as session:
totals = await self._collect_current_totals(session)
period_stats = await self._collect_period_stats(session, start_utc, end_utc)
stats = await self._collect_period_stats(session, start_utc, end_utc)
top_referrers = await self._get_top_referrers(session, start_utc, end_utc, limit=5)
usage = await self._get_user_usage_stats(session)
conversion_rate = (
(stats["trial_to_paid_conversions"] / stats["new_trials"] * 100)
if stats["new_trials"] > 0
else 0.0
)
lines: List[str] = []
header = (
f"📊 <b>Отчет за {period_range.label}</b>"
if period == ReportPeriod.DAILY
else f"📊 <b>Отчет за период {period_range.label}</b>"
)
lines += [header, ""]
lines = [
header,
# TL;DR
lines += [
"🧭 <b>Итог по периоду</b>",
f"• Новых пользователей: <b>{stats['new_users']}</b>",
f"• Новых триалов: <b>{stats['new_trials']}</b>",
(
f"• Конверсий триал → платная: <b>{stats['trial_to_paid_conversions']}</b> "
f"(<i>{conversion_rate:.1f}%</i>)"
),
f"• Новых платных (всего): <b>{stats['new_paid_subscriptions']}</b>",
f"• Поступления всего (только пополнения): <b>{self._format_amount(stats['deposits_amount'])}</b>",
"",
"🎯 <b>Триалы</b>",
f"• Активных сейчас: {totals['active_trials']}",
f"• Новых за период: {period_stats['new_trials']}",
"",
"💎 <b>Платные подписки</b>",
f"• Активных сейчас: {totals['active_paid']}",
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
"",
"🎟️ <b>Тикеты поддержки</b>",
f"• Активных сейчас: {totals['open_tickets']}",
f"• Новых за период: {period_stats['new_tickets']}",
"",
"💰 <b>Платежи</b>",
f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
f"{self._format_amount(period_stats['subscription_payments_amount'])}",
f"• Пополнений: {period_stats['deposits_count']} на сумму "
f"{self._format_amount(period_stats['deposits_amount'])}",
f"Всего поступлений: {period_stats['total_payments_count']} на сумму "
f"{self._format_amount(period_stats['total_payments_amount'])}",
]
# Подписки
lines += [
"💎 <b>Подписки</b>",
f"• Активные триалы сейчас: {totals['active_trials']}",
f"• Активные платные сейчас: {totals['active_paid']}",
"",
]
# Финансы
lines += [
"💰 <b>Финансы</b>",
(
"• Оплаты подписок: "
f"{stats['subscription_payments_count']} на сумму {self._format_amount(stats['subscription_payments_amount'])}"
),
(
"• Пополнения: "
f"{stats['deposits_count']} на сумму {self._format_amount(stats['deposits_amount'])}"
),
(
"<i>Примечание: «Поступления всего» учитывают только пополнения; покупки подписок и реферальные бонусы "
"исключены.</i>"
),
"",
]
# Поддержка
lines += [
"🎟️ <b>Поддержка</b>",
f"• Новых тикетов: {stats['new_tickets']}",
f"• Активных тикетов сейчас: {totals['open_tickets']}",
"",
]
# Активность пользователей
lines += [
"👤 <b>Активность пользователей</b>",
f"• Пользователей с активной платной подпиской: {usage['active_paid_users']}",
f"• Пользователей, ни разу не подключившихся: {usage['never_connected_users']}",
"",
]
# Топ по рефералам
lines += ["🤝 <b>Топ по рефералам (за период)</b>"]
if top_referrers:
for index, row in enumerate(top_referrers, 1):
referrer_label = escape(row["referrer_label"], quote=False)
lines.append(
f"{index}. {referrer_label}: {row['count']} приглашений"
)
else:
lines.append("— данных нет")
return "\n".join(lines)
def _get_period_range(
@@ -273,87 +384,197 @@ class ReportingService:
start_utc: datetime,
end_utc: datetime,
) -> dict:
new_trials_result = await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == True, # noqa: E712
)
)
new_trials = int(new_trials_result.scalar() or 0)
direct_paid_result = await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == False, # noqa: E712
)
)
direct_paid = int(direct_paid_result.scalar() or 0)
conversions_result = await session.execute(
select(func.count(SubscriptionConversion.id)).where(
SubscriptionConversion.converted_at >= start_utc,
SubscriptionConversion.converted_at < end_utc,
)
)
conversions_count = int(conversions_result.scalar() or 0)
subscription_payments_row = (
await session.execute(
select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
).where(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed == True, # noqa: E712
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
new_users = int(
(
await session.execute(
select(func.count(User.id)).where(
User.created_at >= start_utc,
User.created_at < end_utc,
)
)
)
).one()
deposits_row = (
await session.execute(
select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
).where(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True, # noqa: E712
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
)
)
).one()
subscription_payments_count = int(subscription_payments_row[0] or 0)
subscription_payments_amount = int(subscription_payments_row[1] or 0)
deposits_count = int(deposits_row[0] or 0)
deposits_amount = int(deposits_row[1] or 0)
total_payments_count = subscription_payments_count + deposits_count
total_payments_amount = subscription_payments_amount + deposits_amount
new_tickets_result = await session.execute(
select(func.count(Ticket.id)).where(
Ticket.created_at >= start_utc,
Ticket.created_at < end_utc,
)
).scalar()
or 0
)
new_trials = int(
(
await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == true(),
)
)
).scalar()
or 0
)
direct_paid = int(
(
await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == false(),
)
)
).scalar()
or 0
)
trial_to_paid_conversions = int(
(
await session.execute(
select(func.count(SubscriptionConversion.id)).where(
SubscriptionConversion.converted_at >= start_utc,
SubscriptionConversion.converted_at < end_utc,
)
)
).scalar()
or 0
)
subscription_payments_count, subscription_payments_amount = (
(
await session.execute(
self._txn_query_base(
TransactionType.SUBSCRIPTION_PAYMENT.value,
start_utc,
end_utc,
)
)
).one()
)
deposits_count, deposits_amount = (
(
await session.execute(
self._deposit_query_excluding_referrals(start_utc, end_utc)
)
).one()
)
new_tickets = int(
(
await session.execute(
select(func.count(Ticket.id)).where(
Ticket.created_at >= start_utc,
Ticket.created_at < end_utc,
)
)
).scalar()
or 0
)
new_tickets = int(new_tickets_result.scalar() or 0)
return {
"new_users": new_users,
"new_trials": new_trials,
"new_paid_subscriptions": direct_paid + conversions_count,
"subscription_payments_count": subscription_payments_count,
"subscription_payments_amount": subscription_payments_amount,
"deposits_count": deposits_count,
"deposits_amount": deposits_amount,
"total_payments_count": total_payments_count,
"total_payments_amount": total_payments_amount,
"new_paid_subscriptions": direct_paid + trial_to_paid_conversions,
"trial_to_paid_conversions": trial_to_paid_conversions,
"subscription_payments_count": int(subscription_payments_count or 0),
"subscription_payments_amount": int(subscription_payments_amount or 0),
"deposits_count": int(deposits_count or 0),
"deposits_amount": int(deposits_amount or 0),
"new_tickets": new_tickets,
}
def _txn_query_base(self, txn_type: str, start_utc: datetime, end_utc: datetime):
return select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
).where(
Transaction.type == txn_type,
Transaction.is_completed == true(),
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
)
def _deposit_query_excluding_referrals(self, start_utc: datetime, end_utc: datetime):
return select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
).where(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == true(),
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
self._exclude_referral_deposits_condition(),
)
async def _get_top_referrers(
self,
session,
start_utc: datetime,
end_utc: datetime,
limit: int = 5,
) -> List[Dict]:
rows = await session.execute(
select(
User.referred_by_id,
func.count(User.id).label("cnt"),
)
.where(
User.created_at >= start_utc,
User.created_at < end_utc,
User.referred_by_id.isnot(None),
)
.group_by(User.referred_by_id)
.order_by(func.count(User.id).desc())
.limit(limit)
)
rows = rows.all()
if not rows:
return []
ref_ids = [row[0] for row in rows if row[0] is not None]
users_map: Dict[int, str] = {}
if ref_ids:
urows = await session.execute(select(User).where(User.id.in_(ref_ids)))
for user in urows.scalars().all():
users_map[user.id] = self._user_label(user)
return [
{"referrer_label": users_map.get(ref_id, f"User #{ref_id}"), "count": int(count or 0)}
for ref_id, count in rows
]
async def _get_user_usage_stats(self, session) -> Dict[str, int]:
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
active_paid_q = await session.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.is_trial == false(),
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now_utc,
)
)
active_paid_users = int(active_paid_q.scalar() or 0)
never_connected_q = await session.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
or_(
Subscription.connected_squads.is_(None),
func.jsonb_array_length(cast(Subscription.connected_squads, JSONB)) == 0,
)
)
)
never_connected_users = int(never_connected_q.scalar() or 0)
return {
"active_paid_users": active_paid_users,
"never_connected_users": never_connected_users,
}
def _user_label(self, user: User) -> str:
if getattr(user, "username", None):
return f"@{user.username}"
parts = []
if getattr(user, "first_name", None):
parts.append(user.first_name)
if getattr(user, "last_name", None):
parts.append(user.last_name)
if parts:
return " ".join(parts)
return f"User #{getattr(user, 'id', '?')}"
def _format_period_label(self, start: datetime, end: datetime) -> str:
start_date = start.astimezone(self._moscow_tz).date()
end_boundary = (end - timedelta(seconds=1)).astimezone(self._moscow_tz)
@@ -362,15 +583,10 @@ class ReportingService:
if start_date == end_date:
return start_date.strftime("%d.%m.%Y")
return (
f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
)
return f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
def _format_amount(self, amount_kopeks: int) -> str:
if not amount_kopeks:
return "0 ₽"
rubles = amount_kopeks / 100
rubles = (amount_kopeks or 0) / 100
return f"{rubles:,.2f}".replace(",", " ")

View File

@@ -323,7 +323,7 @@
Функции: `_t` — Helper for localized button labels with fallbacks., `get_admin_main_keyboard`, `get_admin_users_submenu_keyboard`, `get_admin_promo_submenu_keyboard`, `get_admin_communications_submenu_keyboard`, `get_admin_support_submenu_keyboard`, `get_admin_settings_submenu_keyboard`, `get_admin_system_submenu_keyboard`, `get_admin_reports_keyboard`, `get_admin_report_result_keyboard`, `get_admin_users_keyboard`, `get_admin_users_filters_keyboard`, `get_admin_subscriptions_keyboard`, `get_admin_promocodes_keyboard`, `get_admin_campaigns_keyboard`, `get_campaign_management_keyboard`, `get_campaign_edit_keyboard`, `get_campaign_bonus_type_keyboard`, `get_promocode_management_keyboard`, `get_admin_messages_keyboard`, `get_admin_monitoring_keyboard`, `get_admin_remnawave_keyboard`, `get_admin_statistics_keyboard`, `get_user_management_keyboard`, `get_user_promo_group_keyboard`, `get_confirmation_keyboard`, `get_promocode_type_keyboard`, `get_promocode_list_keyboard`, `get_broadcast_target_keyboard`, `get_custom_criteria_keyboard`, `get_broadcast_history_keyboard`, `get_sync_options_keyboard`, `get_sync_confirmation_keyboard`, `get_sync_result_keyboard`, `get_period_selection_keyboard`, `get_node_management_keyboard`, `get_squad_management_keyboard`, `get_squad_edit_keyboard`, `get_monitoring_keyboard`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_navigation_keyboard`, `get_log_detail_keyboard`, `get_monitoring_clear_confirm_keyboard`, `get_monitoring_status_keyboard`, `get_monitoring_settings_keyboard`, `get_log_type_filter_keyboard`, `get_admin_servers_keyboard`, `get_server_edit_keyboard`, `get_admin_pagination_keyboard`, `get_maintenance_keyboard`, `get_sync_simplified_keyboard`, `get_welcome_text_keyboard`, `get_broadcast_button_config`, `get_broadcast_button_labels`, `get_message_buttons_selector_keyboard`, `get_broadcast_media_keyboard`, `get_media_confirm_keyboard`, `get_updated_message_buttons_selector_keyboard_with_media`
- `app/keyboards/inline.py` — Python-модуль
Классы: нет
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_suffix`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
- `app/keyboards/reply.py` — Python-модуль
Классы: нет
Функции: `get_main_reply_keyboard`, `get_admin_reply_keyboard`, `get_cancel_keyboard`, `get_confirmation_reply_keyboard`, `get_skip_keyboard`, `remove_keyboard`, `get_contact_keyboard`, `get_location_keyboard`