Merge pull request #2177 from BEDOLAGA-DEV/dev5
Remnawave 2.4.0+ Api Update, Menu layout Api, Partners Stats Api, Yookassa 22%
This commit is contained in:
+1
-1
@@ -226,7 +226,7 @@ YOOKASSA_VAT_CODE=1
|
||||
# 1 - НДС не облагается
|
||||
# 2 - НДС 0%
|
||||
# 3 - НДС 10%
|
||||
# 4 - НДС 20%
|
||||
# 4 - НДС 20%/22%
|
||||
# 5 - НДС 10/110
|
||||
# 6 - НДС 20/120
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.middlewares.throttling import ThrottlingMiddleware
|
||||
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
@@ -127,6 +128,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.pre_checkout_query.middleware(display_name_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
# Middleware для автоматического логирования кликов по кнопкам
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
button_stats_middleware = ButtonStatsMiddleware()
|
||||
dp.callback_query.middleware(button_stats_middleware)
|
||||
logger.info("📊 ButtonStatsMiddleware активирован")
|
||||
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
@@ -175,6 +175,9 @@ class Settings(BaseSettings):
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
|
||||
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
|
||||
|
||||
# Настройки конструктора меню (API)
|
||||
MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API
|
||||
|
||||
# Настройки мониторинга трафика
|
||||
TRAFFIC_MONITORING_ENABLED: bool = False
|
||||
TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки
|
||||
@@ -183,6 +186,8 @@ class Settings(BaseSettings):
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
ENABLE_AUTOPAY: bool = False
|
||||
|
||||
DEFAULT_AUTOPAY_ENABLED: bool = False
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
|
||||
|
||||
@@ -4,12 +4,14 @@ from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import selectinload, aliased
|
||||
|
||||
from app.database.models import (
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
User,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -177,6 +179,10 @@ async def get_contest_leaderboard(
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
) -> Sequence[Tuple[User, int, int]]:
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
return []
|
||||
|
||||
query = (
|
||||
select(
|
||||
User,
|
||||
@@ -191,7 +197,9 @@ async def get_contest_leaderboard(
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.all()
|
||||
leaderboard = result.all()
|
||||
|
||||
return leaderboard
|
||||
|
||||
|
||||
async def get_contest_participants(
|
||||
@@ -248,6 +256,16 @@ async def get_contest_events_count(
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_contest_events(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> List[ReferralContestEvent]:
|
||||
result = await db.execute(
|
||||
select(ReferralContestEvent).where(ReferralContestEvent.contest_id == contest_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def mark_daily_summary_sent(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
|
||||
@@ -1782,3 +1782,48 @@ class MainMenuButton(Base):
|
||||
f"<MainMenuButton id={self.id} text='{self.text}' "
|
||||
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
|
||||
)
|
||||
|
||||
|
||||
class MenuLayoutHistory(Base):
|
||||
"""История изменений конфигурации меню."""
|
||||
__tablename__ = "menu_layout_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
config_json = Column(Text, nullable=False) # Полная конфигурация в JSON
|
||||
action = Column(String(50), nullable=False) # update, reset, import
|
||||
changes_summary = Column(Text, nullable=True) # Краткое описание изменений
|
||||
user_info = Column(String(255), nullable=True) # Информация о пользователе/токене
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_menu_layout_history_created", "created_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MenuLayoutHistory id={self.id} action='{self.action}' created_at={self.created_at}>"
|
||||
|
||||
|
||||
class ButtonClickLog(Base):
|
||||
"""Логи кликов по кнопкам меню."""
|
||||
__tablename__ = "button_click_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
button_id = Column(String(100), nullable=False, index=True) # ID кнопки
|
||||
user_id = Column(BigInteger, ForeignKey("users.telegram_id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
callback_data = Column(String(255), nullable=True) # callback_data кнопки
|
||||
clicked_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# Дополнительная информация
|
||||
button_type = Column(String(20), nullable=True) # builtin, callback, url, mini_app
|
||||
button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_button_click_logs_button_date", "button_id", "clicked_at"),
|
||||
Index("ix_button_click_logs_user_date", "user_id", "clicked_at"),
|
||||
)
|
||||
|
||||
# Связи
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ButtonClickLog id={self.id} button='{self.button_id}' user={self.user_id} at={self.clicked_at}>"
|
||||
|
||||
@@ -3715,6 +3715,133 @@ async def create_system_settings_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_menu_layout_history_table() -> bool:
|
||||
"""Создаёт таблицу для хранения истории изменений конфигурации меню."""
|
||||
table_exists = await check_table_exists("menu_layout_history")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица menu_layout_history уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_table_sql = """
|
||||
CREATE TABLE menu_layout_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
config_json TEXT NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
changes_summary TEXT NULL,
|
||||
user_info VARCHAR(255) NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_table_sql = """
|
||||
CREATE TABLE menu_layout_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
config_json TEXT NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
changes_summary TEXT NULL,
|
||||
user_info VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
else:
|
||||
create_table_sql = """
|
||||
CREATE TABLE menu_layout_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
config_json TEXT NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
changes_summary TEXT NULL,
|
||||
user_info VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_table_sql))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX ix_menu_layout_history_created ON menu_layout_history(created_at)"
|
||||
))
|
||||
logger.info("✅ Таблица menu_layout_history создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы menu_layout_history: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_button_click_logs_table() -> bool:
|
||||
"""Создаёт таблицу для логирования кликов по кнопкам меню."""
|
||||
table_exists = await check_table_exists("button_click_logs")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица button_click_logs уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_table_sql = """
|
||||
CREATE TABLE button_click_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
button_id VARCHAR(100) NOT NULL,
|
||||
user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL,
|
||||
callback_data VARCHAR(255) NULL,
|
||||
clicked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
button_type VARCHAR(20) NULL,
|
||||
button_text VARCHAR(255) NULL
|
||||
)
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_table_sql = """
|
||||
CREATE TABLE button_click_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
button_id VARCHAR(100) NOT NULL,
|
||||
user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL,
|
||||
callback_data VARCHAR(255) NULL,
|
||||
clicked_at TIMESTAMP DEFAULT NOW(),
|
||||
button_type VARCHAR(20) NULL,
|
||||
button_text VARCHAR(255) NULL
|
||||
)
|
||||
"""
|
||||
else:
|
||||
create_table_sql = """
|
||||
CREATE TABLE button_click_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
button_id VARCHAR(100) NOT NULL,
|
||||
user_id BIGINT NULL,
|
||||
callback_data VARCHAR(255) NULL,
|
||||
clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
button_type VARCHAR(20) NULL,
|
||||
button_text VARCHAR(255) NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(telegram_id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_table_sql))
|
||||
|
||||
# Создаём индексы отдельными запросами
|
||||
index_statements = [
|
||||
"CREATE INDEX ix_button_click_logs_button_id ON button_click_logs(button_id)",
|
||||
"CREATE INDEX ix_button_click_logs_user_id ON button_click_logs(user_id)",
|
||||
"CREATE INDEX ix_button_click_logs_clicked_at ON button_click_logs(clicked_at)",
|
||||
"CREATE INDEX ix_button_click_logs_button_date ON button_click_logs(button_id, clicked_at)",
|
||||
"CREATE INDEX ix_button_click_logs_user_date ON button_click_logs(user_id, clicked_at)",
|
||||
]
|
||||
for stmt in index_statements:
|
||||
await conn.execute(text(stmt))
|
||||
|
||||
logger.info("✅ Таблица button_click_logs создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы button_click_logs: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_web_api_tokens_table() -> bool:
|
||||
table_exists = await check_table_exists("web_api_tokens")
|
||||
if table_exists:
|
||||
@@ -4316,6 +4443,20 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MENU_LAYOUT_HISTORY ===")
|
||||
menu_layout_history_ready = await create_menu_layout_history_table()
|
||||
if menu_layout_history_ready:
|
||||
logger.info("✅ Таблица menu_layout_history готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей menu_layout_history")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ BUTTON_CLICK_LOGS ===")
|
||||
button_click_logs_ready = await create_button_click_logs_table()
|
||||
if button_click_logs_ready:
|
||||
logger.info("✅ Таблица button_click_logs готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей button_click_logs")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ ДЛЯ ТРИАЛЬНЫХ СКВАДОВ ===")
|
||||
trial_column_ready = await add_server_trial_flag_column()
|
||||
if trial_column_ready:
|
||||
|
||||
Vendored
+254
-31
@@ -189,6 +189,33 @@ class SubscriptionInfo:
|
||||
happ_crypto_link: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionPageConfig:
|
||||
"""Конфигурация страницы подписки"""
|
||||
uuid: str
|
||||
name: str
|
||||
view_position: int
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveExternalSquad:
|
||||
"""Структура External Squad"""
|
||||
uuid: str
|
||||
name: str
|
||||
view_position: int
|
||||
members_count: int
|
||||
templates: List[Dict[str, str]]
|
||||
subscription_settings: Optional[Dict[str, Any]] = None
|
||||
host_overrides: Optional[Dict[str, Any]] = None
|
||||
response_headers: Optional[Dict[str, str]] = None
|
||||
hwid_settings: Optional[Dict[str, Any]] = None
|
||||
custom_remarks: Optional[Dict[str, Any]] = None
|
||||
subpage_config_uuid: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
|
||||
self.message = message
|
||||
@@ -497,15 +524,6 @@ class RemnaWaveAPI:
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает список всех пользователей.
|
||||
|
||||
Args:
|
||||
start: Смещение для пагинации
|
||||
size: Размер страницы
|
||||
enrich_happ_links: Если True, для каждого пользователя будет запрошена
|
||||
зашифрованная happ ссылка (медленно для больших списков)
|
||||
"""
|
||||
params = {'start': start, 'size': size}
|
||||
response = await self._make_request('GET', '/api/users', params=params)
|
||||
|
||||
@@ -590,6 +608,100 @@ class RemnaWaveAPI:
|
||||
response = await self._make_request('POST', '/api/internal-squads/actions/reorder', data)
|
||||
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
|
||||
|
||||
# ============== External Squads API ==============
|
||||
|
||||
async def get_external_squads(self) -> List[RemnaWaveExternalSquad]:
|
||||
"""Получает список всех External Squads"""
|
||||
response = await self._make_request('GET', '/api/external-squads')
|
||||
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
|
||||
|
||||
async def get_external_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveExternalSquad]:
|
||||
"""Получает External Squad по UUID"""
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/external-squads/{uuid}')
|
||||
return self._parse_external_squad(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def create_external_squad(self, name: str) -> RemnaWaveExternalSquad:
|
||||
data = {'name': name}
|
||||
response = await self._make_request('POST', '/api/external-squads', data)
|
||||
return self._parse_external_squad(response['response'])
|
||||
|
||||
async def update_external_squad(
|
||||
self,
|
||||
uuid: str,
|
||||
name: Optional[str] = None,
|
||||
templates: Optional[List[Dict[str, str]]] = None,
|
||||
subscription_settings: Optional[Dict[str, Any]] = None,
|
||||
host_overrides: Optional[Dict[str, Any]] = None,
|
||||
response_headers: Optional[Dict[str, str]] = None,
|
||||
hwid_settings: Optional[Dict[str, Any]] = None,
|
||||
custom_remarks: Optional[Dict[str, Any]] = None,
|
||||
subpage_config_uuid: Optional[str] = None
|
||||
) -> RemnaWaveExternalSquad:
|
||||
data = {'uuid': uuid}
|
||||
if name is not None:
|
||||
data['name'] = name
|
||||
if templates is not None:
|
||||
data['templates'] = templates
|
||||
if subscription_settings is not None:
|
||||
data['subscriptionSettings'] = subscription_settings
|
||||
if host_overrides is not None:
|
||||
data['hostOverrides'] = host_overrides
|
||||
if response_headers is not None:
|
||||
data['responseHeaders'] = response_headers
|
||||
if hwid_settings is not None:
|
||||
data['hwidSettings'] = hwid_settings
|
||||
if custom_remarks is not None:
|
||||
data['customRemarks'] = custom_remarks
|
||||
if subpage_config_uuid is not None:
|
||||
data['subpageConfigUuid'] = subpage_config_uuid
|
||||
|
||||
response = await self._make_request('PATCH', '/api/external-squads', data)
|
||||
return self._parse_external_squad(response['response'])
|
||||
|
||||
async def delete_external_squad(self, uuid: str) -> bool:
|
||||
"""Удаляет External Squad"""
|
||||
response = await self._make_request('DELETE', f'/api/external-squads/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
async def add_users_to_external_squad(self, uuid: str) -> bool:
|
||||
"""Добавляет всех пользователей в External Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/add-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def remove_users_from_external_squad(self, uuid: str) -> bool:
|
||||
"""Удаляет всех пользователей из External Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/remove-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def reorder_external_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveExternalSquad]:
|
||||
data = {'items': items}
|
||||
response = await self._make_request('POST', '/api/external-squads/actions/reorder', data)
|
||||
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
|
||||
|
||||
def _parse_external_squad(self, squad_data: Dict) -> RemnaWaveExternalSquad:
|
||||
"""Парсит данные External Squad"""
|
||||
info = squad_data.get('info', {})
|
||||
return RemnaWaveExternalSquad(
|
||||
uuid=squad_data['uuid'],
|
||||
name=squad_data['name'],
|
||||
view_position=squad_data.get('viewPosition', 0),
|
||||
members_count=info.get('membersCount', 0),
|
||||
templates=squad_data.get('templates', []),
|
||||
subscription_settings=squad_data.get('subscriptionSettings'),
|
||||
host_overrides=squad_data.get('hostOverrides'),
|
||||
response_headers=squad_data.get('responseHeaders'),
|
||||
hwid_settings=squad_data.get('hwidSettings'),
|
||||
custom_remarks=squad_data.get('customRemarks'),
|
||||
subpage_config_uuid=squad_data.get('subpageConfigUuid'),
|
||||
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
|
||||
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
|
||||
)
|
||||
|
||||
|
||||
async def get_all_nodes(self) -> List[RemnaWaveNode]:
|
||||
response = await self._make_request('GET', '/api/nodes')
|
||||
@@ -683,29 +795,148 @@ class RemnaWaveAPI:
|
||||
return response['response']
|
||||
|
||||
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
|
||||
response = await self._make_request('GET', '/api/nodes/usage/realtime')
|
||||
return response['response']
|
||||
return await self.get_bandwidth_stats_nodes_realtime()
|
||||
|
||||
async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает статистику использования трафика пользователем за указанный период
|
||||
return await self.get_bandwidth_stats_user_legacy(user_uuid, start_date, end_date)
|
||||
|
||||
Args:
|
||||
user_uuid: UUID пользователя
|
||||
start_date: Начальная дата в формате ISO (например, "2025-12-09T00:00:00.000Z")
|
||||
end_date: Конечная дата в формате ISO (например, "2025-12-09T23:59:59.999Z")
|
||||
# ============== Bandwidth Stats API ==============
|
||||
|
||||
Returns:
|
||||
Словарь с информацией о трафике пользователя за указанный период
|
||||
"""
|
||||
async def get_bandwidth_stats_nodes(self, start_date: str, end_date: str) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/users/stats/usage/{user_uuid}/range', params=params)
|
||||
response = await self._make_request('GET', '/api/bandwidth-stats/nodes', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_nodes_realtime(self) -> List[Dict[str, Any]]:
|
||||
response = await self._make_request('GET', '/api/bandwidth-stats/nodes/realtime')
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_node_users(
|
||||
self,
|
||||
node_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
top_users_limit: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date,
|
||||
'topUsersLimit': top_users_limit
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_node_users_legacy(
|
||||
self,
|
||||
node_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_user(
|
||||
self,
|
||||
user_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_user_legacy(
|
||||
self,
|
||||
user_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}/legacy', params=params)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
# ============== Subscription Page Configs API ==============
|
||||
|
||||
async def get_subscription_page_configs(self) -> List[SubscriptionPageConfig]:
|
||||
response = await self._make_request('GET', '/api/subscription-page-configs')
|
||||
configs_data = response['response'].get('configs', [])
|
||||
return [self._parse_subscription_page_config(c) for c in configs_data]
|
||||
|
||||
async def get_subscription_page_config(self, uuid: str) -> Optional[SubscriptionPageConfig]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/subscription-page-configs/{uuid}')
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def create_subscription_page_config(self, name: str) -> SubscriptionPageConfig:
|
||||
data = {'name': name}
|
||||
response = await self._make_request('POST', '/api/subscription-page-configs', data)
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
|
||||
async def update_subscription_page_config(
|
||||
self,
|
||||
uuid: str,
|
||||
name: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
) -> SubscriptionPageConfig:
|
||||
data = {'uuid': uuid}
|
||||
if name is not None:
|
||||
data['name'] = name
|
||||
if config is not None:
|
||||
data['config'] = config
|
||||
response = await self._make_request('PATCH', '/api/subscription-page-configs', data)
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
|
||||
async def delete_subscription_page_config(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/subscription-page-configs/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
async def reorder_subscription_page_configs(self, items: List[Dict[str, Any]]) -> List[SubscriptionPageConfig]:
|
||||
data = {'items': items}
|
||||
response = await self._make_request('POST', '/api/subscription-page-configs/actions/reorder', data)
|
||||
configs_data = response['response'].get('configs', [])
|
||||
return [self._parse_subscription_page_config(c) for c in configs_data]
|
||||
|
||||
async def clone_subscription_page_config(self, clone_from_uuid: str) -> SubscriptionPageConfig:
|
||||
data = {'cloneFromUuid': clone_from_uuid}
|
||||
response = await self._make_request('POST', '/api/subscription-page-configs/actions/clone', data)
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
|
||||
async def get_subpage_config_by_short_uuid(self, short_uuid: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/subscriptions/subpage-config/{short_uuid}')
|
||||
return response.get('response')
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def _parse_subscription_page_config(self, data: Dict) -> SubscriptionPageConfig:
|
||||
"""Парсит данные конфигурации страницы подписки"""
|
||||
return SubscriptionPageConfig(
|
||||
uuid=data['uuid'],
|
||||
name=data['name'],
|
||||
view_position=data['viewPosition'],
|
||||
config=data.get('config')
|
||||
)
|
||||
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
|
||||
@@ -756,10 +987,6 @@ class RemnaWaveAPI:
|
||||
return False
|
||||
|
||||
async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> Optional[str]:
|
||||
"""
|
||||
Шифрует ссылку подписки через API Remnawave.
|
||||
Возвращает зашифрованную happ:// ссылку или None при ошибке.
|
||||
"""
|
||||
try:
|
||||
data = {"linkToEncrypt": link_to_encrypt}
|
||||
response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data)
|
||||
@@ -772,10 +999,6 @@ class RemnaWaveAPI:
|
||||
return None
|
||||
|
||||
async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser:
|
||||
"""
|
||||
Обогащает объект пользователя зашифрованной happ ссылкой,
|
||||
если она отсутствует но есть subscription_url.
|
||||
"""
|
||||
if not user.happ_crypto_link and user.subscription_url:
|
||||
encrypted = await self.encrypt_happ_crypto_link(user.subscription_url)
|
||||
if encrypted:
|
||||
|
||||
@@ -436,7 +436,7 @@ async def show_leaderboard(
|
||||
texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"),
|
||||
]
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
lines.append(f"{idx}. {user.full_name} — {score}")
|
||||
lines.append(f"{idx}. {user.full_name} ({user.telegram_id}) — {score}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
@@ -659,6 +659,107 @@ async def finalize_contest_creation(message: types.Message, state: FSMContext, d
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_detailed_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
if not contest:
|
||||
await callback.answer("Конкурс не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
|
||||
# Общее сообщение с основной статистикой
|
||||
general_lines = [
|
||||
"📈 <b>Статистика конкурса</b>",
|
||||
f"🏆 {contest.title}",
|
||||
"",
|
||||
f"👥 Участников: <b>{stats['total_participants']}</b>",
|
||||
f"📨 Приглашено рефералов: <b>{stats['total_invited']}</b>",
|
||||
f"💰 Оплатили подписок: <b>{stats['total_paid_amount'] // 100} руб.</b>",
|
||||
f"❌ Не оплатили: <b>{stats['total_unpaid']}</b>",
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(general_lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest_id, is_active=contest.is_active, language=db_user.language
|
||||
),
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_detailed_stats_page(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
contest_id: int = None,
|
||||
page: int = 1,
|
||||
stats: dict = None,
|
||||
):
|
||||
if contest_id is None or stats is None:
|
||||
# Парсим из callback.data: admin_contest_detailed_stats_page_{contest_id}_page_{page}
|
||||
parts = callback.data.split("_")
|
||||
contest_id = int(parts[5]) # contest_id после page
|
||||
page = int(parts[7]) # page после второго page
|
||||
|
||||
# Получаем stats если не переданы
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
|
||||
participants = stats['participants']
|
||||
total_participants = len(participants)
|
||||
PAGE_SIZE = 10
|
||||
total_pages = math.ceil(total_participants / PAGE_SIZE)
|
||||
|
||||
page = max(1, min(page, total_pages))
|
||||
offset = (page - 1) * PAGE_SIZE
|
||||
page_participants = participants[offset:offset + PAGE_SIZE]
|
||||
|
||||
lines = [f"📊 По участникам (страница {page}/{total_pages}):"]
|
||||
for p in page_participants:
|
||||
lines.extend([
|
||||
f"• <b>{p['full_name']}</b>",
|
||||
f" 📨 Приглашено: {p['total_referrals']}",
|
||||
f" 💰 Оплатили: {p['paid_referrals']}",
|
||||
f" ❌ Не оплатили: {p['unpaid_referrals']}",
|
||||
f" 💵 Сумма: {p['total_paid_amount'] // 100} руб.",
|
||||
"" # Пустая строка для разделения
|
||||
])
|
||||
|
||||
pagination = get_admin_pagination_keyboard(
|
||||
page,
|
||||
total_pages,
|
||||
f"admin_contest_detailed_stats_page_{contest_id}",
|
||||
back_callback=f"admin_contest_view_{contest_id}",
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=pagination,
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_contests_menu, F.data == "admin_contests")
|
||||
dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral")
|
||||
@@ -669,6 +770,8 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(prompt_edit_summary_times, F.data.startswith("admin_contest_edit_times_"))
|
||||
dp.callback_query.register(delete_contest, F.data.startswith("admin_contest_delete_"))
|
||||
dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_"))
|
||||
dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_"))
|
||||
dp.callback_query.register(show_detailed_stats_page, F.data.startswith("admin_contest_detailed_stats_page_"))
|
||||
dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create")
|
||||
dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"]))
|
||||
|
||||
|
||||
@@ -1047,6 +1047,106 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
if target == "expiring_subscribers":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 7)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
|
||||
if target == "expired_subscribers":
|
||||
now = datetime.utcnow()
|
||||
expired_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
}
|
||||
expired_users = []
|
||||
for user in users:
|
||||
subscription = user.subscription
|
||||
if subscription:
|
||||
if subscription.status in expired_statuses:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
if subscription.end_date <= now and not subscription.is_active:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
elif user.has_had_paid_subscription:
|
||||
expired_users.append(user)
|
||||
return expired_users
|
||||
|
||||
if target == "canceled_subscribers":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.status == SubscriptionStatus.DISABLED.value
|
||||
]
|
||||
|
||||
if target == "trial_ending":
|
||||
now = datetime.utcnow()
|
||||
in_3_days = now + timedelta(days=3)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and user.subscription.end_date <= in_3_days
|
||||
]
|
||||
|
||||
if target == "trial_expired":
|
||||
now = datetime.utcnow()
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.end_date <= now
|
||||
]
|
||||
|
||||
if target == "autopay_failed":
|
||||
from app.database.models import SubscriptionEvent
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
stmt = select(SubscriptionEvent.user_id).where(
|
||||
and_(
|
||||
SubscriptionEvent.event_type == "autopay_failed",
|
||||
SubscriptionEvent.occurred_at >= week_ago,
|
||||
)
|
||||
).distinct()
|
||||
result = await db.execute(stmt)
|
||||
failed_user_ids = set(result.scalars().all())
|
||||
return [user for user in users if user.id in failed_user_ids]
|
||||
|
||||
if target == "low_balance":
|
||||
threshold_kopeks = 10000 # 100 рублей
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if (user.balance_kopeks or 0) < threshold_kopeks
|
||||
and (user.balance_kopeks or 0) > 0
|
||||
]
|
||||
|
||||
if target == "inactive_30d":
|
||||
threshold = datetime.utcnow() - timedelta(days=30)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.last_activity and user.last_activity < threshold
|
||||
]
|
||||
|
||||
if target == "inactive_60d":
|
||||
threshold = datetime.utcnow() - timedelta(days=60)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.last_activity and user.last_activity < threshold
|
||||
]
|
||||
|
||||
if target == "inactive_90d":
|
||||
threshold = datetime.utcnow() - timedelta(days=90)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.last_activity and user.last_activity < threshold
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
|
||||
+26
-42
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -11,8 +10,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
get_active_rounds,
|
||||
get_template_by_slug,
|
||||
get_active_round_by_template,
|
||||
get_attempt,
|
||||
create_attempt,
|
||||
increment_winner_count,
|
||||
@@ -47,10 +44,6 @@ def _user_allowed(subscription) -> bool:
|
||||
}
|
||||
|
||||
|
||||
async def _with_session() -> AsyncSession:
|
||||
return AsyncSessionLocal()
|
||||
|
||||
|
||||
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
user = await get_user_by_id(db, user_id)
|
||||
@@ -64,12 +57,6 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language
|
||||
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
|
||||
|
||||
|
||||
async def _ensure_round_for_template(template: ContestTemplate) -> Optional[ContestRound]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
round_obj = await get_active_round_by_template(db, template.id)
|
||||
return round_obj
|
||||
|
||||
|
||||
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
|
||||
texts = get_texts(language)
|
||||
await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True)
|
||||
@@ -181,7 +168,6 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest
|
||||
texts = get_texts(db_user.language)
|
||||
rows = round_obj.payload.get("rows", 3)
|
||||
cols = round_obj.payload.get("cols", 3)
|
||||
secret = random.randint(0, rows * cols - 1)
|
||||
keyboard = []
|
||||
for r in range(rows):
|
||||
row_buttons = []
|
||||
@@ -190,7 +176,7 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="🎛",
|
||||
callback_data=f"contest_pick_{round_obj.id}_{idx}_{secret}"
|
||||
callback_data=f"contest_pick_{round_obj.id}_quest_{idx}"
|
||||
)
|
||||
)
|
||||
keyboard.append(row_buttons)
|
||||
@@ -205,11 +191,10 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest
|
||||
async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
total = round_obj.payload.get("total", 20)
|
||||
secret = random.randint(0, total - 1)
|
||||
keyboard = []
|
||||
row = []
|
||||
for i in range(total):
|
||||
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_{i}_{secret}"))
|
||||
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}"))
|
||||
if len(row) == 5:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
@@ -337,19 +322,31 @@ async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
|
||||
is_winner = False
|
||||
if tpl.slug == GAME_SERVER:
|
||||
is_winner = pick == correct_flag
|
||||
elif tpl.slug in {GAME_QUEST, GAME_LOCKS}:
|
||||
elif tpl.slug == GAME_QUEST:
|
||||
# Format: quest_{idx}
|
||||
try:
|
||||
idx_str, secret_str = pick.split("_", 1)
|
||||
idx = int(idx_str)
|
||||
secret = int(secret_str)
|
||||
is_winner = idx == secret
|
||||
except ValueError:
|
||||
if pick.startswith("quest_"):
|
||||
idx = int(pick.split("_")[1])
|
||||
is_winner = secret_idx is not None and idx == secret_idx
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
elif tpl.slug == GAME_LOCKS:
|
||||
# Format: locks_{idx}
|
||||
try:
|
||||
if pick.startswith("locks_"):
|
||||
idx = int(pick.split("_")[1])
|
||||
is_winner = secret_idx is not None and idx == secret_idx
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
is_winner = pick == "blitz"
|
||||
else:
|
||||
is_winner = False
|
||||
|
||||
# Check if max winners already reached
|
||||
if is_winner and round_obj.winners_count >= round_obj.max_winners:
|
||||
is_winner = False # Too late, max winners already reached
|
||||
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
@@ -393,6 +390,11 @@ async def handle_text_answer(message: types.Message, state: FSMContext, db_user,
|
||||
correct = (round_obj.payload.get("answer") or "").upper()
|
||||
|
||||
is_winner = correct and answer == correct
|
||||
|
||||
# Check if max winners already reached
|
||||
if is_winner and round_obj.winners_count >= round_obj.max_winners:
|
||||
is_winner = False # Too late, max winners already reached
|
||||
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
@@ -404,24 +406,6 @@ async def handle_text_answer(message: types.Message, state: FSMContext, db_user,
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
return "ошибка: подписка не найдена"
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
subscription.end_date = subscription.end_date + timedelta(days=prize_days)
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
logger.info(f"🎁 Продлена подписка пользователя {user_id} на {prize_days} дней за конкурс")
|
||||
return f"подписка продлена на {prize_days} дней"
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_contests_menu, F.data == "contests_menu")
|
||||
dp.callback_query.register(play_contest, F.data.startswith("contest_play_"))
|
||||
|
||||
+38
-29
@@ -17,6 +17,7 @@ from app.database.crud.promo_group import (
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.keyboards.inline import (
|
||||
get_main_menu_keyboard,
|
||||
get_main_menu_keyboard_async,
|
||||
get_language_selection_keyboard,
|
||||
get_info_menu_keyboard,
|
||||
)
|
||||
@@ -198,22 +199,26 @@ async def show_main_menu(
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=db_user,
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
keyboard=keyboard,
|
||||
parse_mode="HTML",
|
||||
force_text=settings.is_text_main_menu_mode(),
|
||||
)
|
||||
@@ -1061,22 +1066,26 @@ async def handle_back_to_menu(
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=db_user,
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
keyboard=keyboard,
|
||||
parse_mode="HTML",
|
||||
force_text=settings.is_text_main_menu_mode(),
|
||||
)
|
||||
@@ -1216,10 +1225,10 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
return _insert_random_message(base_text, random_message, action_prompt)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
|
||||
return base_text
|
||||
|
||||
|
||||
@@ -1303,7 +1312,7 @@ async def handle_activate_button(
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_back_to_menu,
|
||||
F.data == "back_to_menu"
|
||||
|
||||
+185
-162
@@ -2,6 +2,7 @@ import logging
|
||||
from datetime import datetime
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,6 +23,7 @@ from app.keyboards.inline import (
|
||||
get_rules_keyboard,
|
||||
get_privacy_policy_keyboard,
|
||||
get_main_menu_keyboard,
|
||||
get_main_menu_keyboard_async,
|
||||
get_post_registration_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
)
|
||||
@@ -264,10 +266,14 @@ async def _continue_registration_after_language(
|
||||
return
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await target_message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
try:
|
||||
await target_message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"⚠️ Пользователь {callback.from_user.id if callback else message.from_user.id} заблокировал бота, пропускаем отправку правил")
|
||||
return
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 LANGUAGE: Правила отправлены после выбора языка")
|
||||
|
||||
@@ -319,7 +325,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
if referral_code:
|
||||
await state.update_data(referral_code=referral_code)
|
||||
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if campaign and not campaign_notification_sent:
|
||||
@@ -337,32 +343,32 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
campaign.id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
|
||||
|
||||
|
||||
profile_updated = False
|
||||
|
||||
|
||||
if user.username != message.from_user.username:
|
||||
old_username = user.username
|
||||
user.username = message.from_user.username
|
||||
logger.info(f"📝 Username обновлен: '{old_username}' → '{user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
|
||||
if user.first_name != message.from_user.first_name:
|
||||
old_first_name = user.first_name
|
||||
user.first_name = message.from_user.first_name
|
||||
logger.info(f"📝 Имя обновлено: '{old_first_name}' → '{user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
|
||||
if user.last_name != message.from_user.last_name:
|
||||
old_last_name = user.last_name
|
||||
user.last_name = message.from_user.last_name
|
||||
logger.info(f"📝 Фамилия обновлена: '{old_last_name}' → '{user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
|
||||
user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
if profile_updated:
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -370,7 +376,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f"💾 Профиль пользователя {user.telegram_id} обновлен")
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
if referral_code and not user.referred_by_id:
|
||||
@@ -393,11 +399,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления о рекламной кампании: {e}"
|
||||
)
|
||||
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
@@ -414,35 +420,38 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=user,
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
|
||||
if user and user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию")
|
||||
|
||||
|
||||
try:
|
||||
from app.services.user_service import UserService
|
||||
from app.database.models import (
|
||||
Subscription, Transaction, PromoCodeUse,
|
||||
Subscription, Transaction, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
await db.execute(
|
||||
@@ -451,51 +460,51 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
)
|
||||
)
|
||||
logger.info(f"🗑️ Удалены записи SubscriptionServer")
|
||||
|
||||
|
||||
if user.subscription:
|
||||
await db.delete(user.subscription)
|
||||
logger.info(f"🗑️ Удалена подписка пользователя")
|
||||
|
||||
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
|
||||
)
|
||||
|
||||
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
|
||||
)
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
|
||||
)
|
||||
|
||||
|
||||
await db.execute(
|
||||
delete(Transaction).where(Transaction.user_id == user.id)
|
||||
)
|
||||
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
user.balance_kopeks = 0
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.referred_by_id = None
|
||||
|
||||
|
||||
user.username = message.from_user.username
|
||||
user.first_name = message.from_user.first_name
|
||||
user.last_name = message.from_user.last_name
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
user.referral_code = await generate_unique_referral_code(db, user.telegram_id)
|
||||
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} подготовлен к восстановлению")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка подготовки к восстановлению: {e}")
|
||||
await db.rollback()
|
||||
else:
|
||||
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
if not data.get('language'):
|
||||
if settings.is_language_selection_enabled():
|
||||
@@ -626,11 +635,11 @@ async def _show_privacy_policy_after_rules(
|
||||
Возвращает True, если политика была показана, False если её нет или произошла ошибка.
|
||||
"""
|
||||
policy = await PrivacyPolicyService.get_policy(db, language, fallback=True)
|
||||
|
||||
|
||||
if not policy or not policy.is_enabled:
|
||||
logger.info("⚠️ Политика конфиденциальности не включена, пропускаем её показ")
|
||||
return False
|
||||
|
||||
|
||||
if not policy.content or not policy.content.strip():
|
||||
privacy_policy_text = get_privacy_policy(language)
|
||||
if not privacy_policy_text or not privacy_policy_text.strip():
|
||||
@@ -640,7 +649,7 @@ async def _show_privacy_policy_after_rules(
|
||||
else:
|
||||
privacy_policy_text = policy.content
|
||||
logger.info(f"🔒 Используется политика конфиденциальности из БД для языка {language}")
|
||||
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
privacy_policy_text,
|
||||
@@ -675,7 +684,7 @@ async def _continue_registration_after_rules(
|
||||
"""
|
||||
data = await state.get_data() or {}
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
@@ -717,10 +726,10 @@ async def process_rules_accept(
|
||||
logger.info(f"📋 RULES: Начало обработки правил")
|
||||
logger.info(f"📊 Callback data: {callback.data}")
|
||||
logger.info(f"👤 User: {callback.from_user.id}")
|
||||
|
||||
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Текущее состояние: {current_state}")
|
||||
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -730,24 +739,24 @@ async def process_rules_accept(
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', language)
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
if callback.data == 'rules_accept':
|
||||
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
|
||||
|
||||
|
||||
# Пытаемся показать политику конфиденциальности
|
||||
policy_shown = await _show_privacy_policy_after_rules(
|
||||
callback, state, db, language
|
||||
)
|
||||
|
||||
|
||||
# Если политика не была показана, продолжаем регистрацию
|
||||
if not policy_shown:
|
||||
await _continue_registration_after_rules(
|
||||
callback, state, db, language
|
||||
)
|
||||
|
||||
|
||||
else:
|
||||
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
|
||||
|
||||
|
||||
rules_required_text = texts.t(
|
||||
"RULES_REQUIRED",
|
||||
"Для использования бота необходимо принять правила сервиса.",
|
||||
@@ -767,9 +776,9 @@ async def process_rules_accept(
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True)
|
||||
await callback.answer(
|
||||
@@ -798,14 +807,14 @@ async def process_privacy_policy_accept(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
|
||||
logger.info(f"🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности")
|
||||
logger.info(f"📊 Callback data: {callback.data}")
|
||||
logger.info(f"👤 User: {callback.from_user.id}")
|
||||
|
||||
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Текущее состояние: {current_state}")
|
||||
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -815,10 +824,10 @@ async def process_privacy_policy_accept(
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', language)
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
if callback.data == 'privacy_policy_accept':
|
||||
logger.info(f"✅ Политика конфиденциальности принята пользователем {callback.from_user.id}")
|
||||
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
logger.info(f"🗑️ Сообщение с политикой конфиденциальности удалено")
|
||||
@@ -834,7 +843,7 @@ async def process_privacy_policy_accept(
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
@@ -853,7 +862,7 @@ async def process_privacy_policy_accept(
|
||||
try:
|
||||
await state.set_data(data)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
|
||||
|
||||
await callback.bot.send_message(
|
||||
chat_id=callback.from_user.id,
|
||||
text=texts.t(
|
||||
@@ -866,10 +875,10 @@ async def process_privacy_policy_accept(
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
|
||||
else:
|
||||
logger.info(f"❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}")
|
||||
|
||||
|
||||
privacy_policy_required_text = texts.t(
|
||||
"PRIVACY_POLICY_REQUIRED",
|
||||
"Для использования бота необходимо принять политику конфиденциальности.",
|
||||
@@ -889,9 +898,9 @@ async def process_privacy_policy_accept(
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
logger.info(f"✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки политики конфиденциальности: {e}", exc_info=True)
|
||||
await callback.answer(
|
||||
@@ -994,7 +1003,7 @@ async def process_referral_code_skip(
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
|
||||
@@ -1029,11 +1038,11 @@ async def complete_registration_from_callback(
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
|
||||
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await callback.message.answer(
|
||||
@@ -1042,13 +1051,13 @@ async def complete_registration_from_callback(
|
||||
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
is_admin = settings.is_admin(existing_user.telegram_id)
|
||||
@@ -1067,19 +1076,22 @@ async def complete_registration_from_callback(
|
||||
)
|
||||
|
||||
try:
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=existing_user,
|
||||
language=existing_user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -1090,10 +1102,10 @@ async def complete_registration_from_callback(
|
||||
"Добро пожаловать, {user_name}!",
|
||||
).format(user_name=existing_user.full_name)
|
||||
)
|
||||
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
@@ -1112,10 +1124,10 @@ async def complete_registration_from_callback(
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
existing_user.username = callback.from_user.username
|
||||
existing_user.first_name = callback.from_user.first_name
|
||||
existing_user.last_name = callback.from_user.last_name
|
||||
@@ -1124,22 +1136,22 @@ async def complete_registration_from_callback(
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
|
||||
|
||||
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
|
||||
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=callback.from_user.id,
|
||||
@@ -1148,7 +1160,7 @@ async def complete_registration_from_callback(
|
||||
last_name=callback.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
referral_code=referral_code
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
else:
|
||||
@@ -1157,15 +1169,15 @@ async def complete_registration_from_callback(
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
user = existing_user
|
||||
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id, callback.bot)
|
||||
@@ -1224,11 +1236,11 @@ async def complete_registration_from_callback(
|
||||
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
@@ -1247,19 +1259,22 @@ async def complete_registration_from_callback(
|
||||
)
|
||||
|
||||
try:
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=user,
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
@@ -1303,11 +1318,11 @@ async def complete_registration(
|
||||
return
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await message.answer(
|
||||
@@ -1316,13 +1331,13 @@ async def complete_registration(
|
||||
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
is_admin = settings.is_admin(existing_user.telegram_id)
|
||||
@@ -1341,19 +1356,22 @@ async def complete_registration(
|
||||
)
|
||||
|
||||
try:
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=existing_user,
|
||||
language=existing_user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -1364,10 +1382,10 @@ async def complete_registration(
|
||||
"Добро пожаловать, {user_name}!",
|
||||
).format(user_name=existing_user.full_name)
|
||||
)
|
||||
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
@@ -1386,10 +1404,10 @@ async def complete_registration(
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Восстанавливаем удаленного пользователя {message.from_user.id}")
|
||||
|
||||
|
||||
existing_user.username = message.from_user.username
|
||||
existing_user.first_name = message.from_user.first_name
|
||||
existing_user.last_name = message.from_user.last_name
|
||||
@@ -1398,22 +1416,22 @@ async def complete_registration(
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
|
||||
|
||||
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}")
|
||||
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, message.from_user.id)
|
||||
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=message.from_user.id,
|
||||
@@ -1431,15 +1449,15 @@ async def complete_registration(
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
user = existing_user
|
||||
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id, message.bot)
|
||||
@@ -1521,11 +1539,11 @@ async def complete_registration(
|
||||
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
@@ -1544,19 +1562,22 @@ async def complete_registration(
|
||||
)
|
||||
|
||||
try:
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=user,
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
@@ -1665,7 +1686,7 @@ def _insert_random_message(base_text: str, random_message: str, action_prompt: s
|
||||
|
||||
def get_referral_code_keyboard(language: str):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
@@ -1875,7 +1896,9 @@ async def required_sub_channel_check(
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = get_main_menu_keyboard(
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=user,
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
@@ -1964,29 +1987,29 @@ async def required_sub_channel_check(
|
||||
await query.answer(f"{texts.ERROR}!", show_alert=True)
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
|
||||
logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===")
|
||||
|
||||
|
||||
dp.message.register(
|
||||
cmd_start,
|
||||
Command("start")
|
||||
)
|
||||
logger.info("✅ Зарегистрирован cmd_start")
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
process_rules_accept,
|
||||
F.data.in_(["rules_accept", "rules_decline"]),
|
||||
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_rules_accept")
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
process_privacy_policy_accept,
|
||||
F.data.in_(["privacy_policy_accept", "privacy_policy_decline"]),
|
||||
StateFilter(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_privacy_policy_accept")
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
process_language_selection,
|
||||
F.data.startswith("language_select:"),
|
||||
@@ -2000,13 +2023,13 @@ def register_handlers(dp: Dispatcher):
|
||||
StateFilter(RegistrationStates.waiting_for_referral_code)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_referral_code_skip")
|
||||
|
||||
|
||||
dp.message.register(
|
||||
process_referral_code_input,
|
||||
StateFilter(RegistrationStates.waiting_for_referral_code)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_referral_code_input")
|
||||
|
||||
|
||||
dp.message.register(
|
||||
handle_potential_referral_code,
|
||||
StateFilter(
|
||||
@@ -2021,6 +2044,6 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.in_(["sub_channel_check"])
|
||||
)
|
||||
logger.info("✅ Зарегистрирован required_sub_channel_check")
|
||||
|
||||
|
||||
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
|
||||
|
||||
|
||||
|
||||
@@ -616,6 +616,12 @@ def get_referral_contest_manage_keyboard(
|
||||
callback_data=f"admin_contest_toggle_{contest_id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📈 Детальная статистика",
|
||||
callback_data=f"admin_contest_detailed_stats_{contest_id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_EDIT_SUMMARY_TIMES", "🕒 Итоги в день"),
|
||||
|
||||
+131
-1
@@ -1,7 +1,7 @@
|
||||
from typing import List, Optional
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from app.database.models import User
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -23,6 +23,136 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_main_menu_keyboard_async(
|
||||
db: AsyncSession,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
is_admin: bool = False,
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: bool = False,
|
||||
subscription_is_active: bool = False,
|
||||
balance_kopeks: int = 0,
|
||||
subscription=None,
|
||||
show_resume_checkout: bool = False,
|
||||
has_saved_cart: bool = False,
|
||||
*,
|
||||
is_moderator: bool = False,
|
||||
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
|
||||
user=None, # Добавляем параметр пользователя для получения данных
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Асинхронная версия get_main_menu_keyboard с поддержкой конструктора меню.
|
||||
|
||||
Если MENU_LAYOUT_ENABLED=True, использует конфигурацию из БД.
|
||||
Иначе делегирует в синхронную версию.
|
||||
"""
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
from app.services.menu_layout_service import MenuLayoutService, MenuContext
|
||||
from datetime import datetime
|
||||
|
||||
# Получаем данные для плейсхолдеров
|
||||
subscription_days_left = 0
|
||||
traffic_used_gb = 0.0
|
||||
traffic_left_gb = 0.0
|
||||
referral_count = 0
|
||||
referral_earnings_kopeks = 0
|
||||
registration_days = 0
|
||||
promo_group_id = None
|
||||
has_autopay = False
|
||||
username = ""
|
||||
|
||||
# Заполняем данными из подписки
|
||||
if subscription:
|
||||
# Дни до окончания подписки
|
||||
if hasattr(subscription, 'days_left'):
|
||||
# Используем свойство из модели, которое правильно вычисляет дни в UTC
|
||||
subscription_days_left = subscription.days_left
|
||||
elif hasattr(subscription, 'end_date') and subscription.end_date:
|
||||
# Fallback: вычисляем вручную, используя UTC
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
days_left = (subscription.end_date - now_utc).days
|
||||
subscription_days_left = max(0, days_left)
|
||||
|
||||
# Трафик
|
||||
if hasattr(subscription, 'traffic_used_gb'):
|
||||
traffic_used_gb = subscription.traffic_used_gb or 0.0
|
||||
|
||||
if hasattr(subscription, 'traffic_limit_gb') and subscription.traffic_limit_gb:
|
||||
traffic_left_gb = max(0, subscription.traffic_limit_gb - (subscription.traffic_used_gb or 0))
|
||||
|
||||
# Автоплатеж
|
||||
if hasattr(subscription, 'autopay_enabled'):
|
||||
has_autopay = subscription.autopay_enabled
|
||||
|
||||
# Получаем данные пользователя
|
||||
if user:
|
||||
# Имя пользователя
|
||||
if hasattr(user, 'username') and user.username:
|
||||
username = user.username
|
||||
elif hasattr(user, 'first_name') and user.first_name:
|
||||
username = user.first_name
|
||||
|
||||
# Дни с регистрации
|
||||
if hasattr(user, 'created_at') and user.created_at:
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
registration_days = (now_utc - user.created_at).days
|
||||
|
||||
# ID промо-группы
|
||||
if hasattr(user, 'promo_group_id'):
|
||||
promo_group_id = user.promo_group_id
|
||||
|
||||
# Получаем данные о рефералах из БД (если нужно)
|
||||
try:
|
||||
from app.database.crud.referral import get_user_referral_stats
|
||||
if user and hasattr(user, 'id'):
|
||||
referral_data = await get_user_referral_stats(db, user.id)
|
||||
if referral_data:
|
||||
referral_count = referral_data.get('invited_count', 0)
|
||||
referral_earnings_kopeks = referral_data.get('total_earned_kopeks', 0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting referral data: {e}")
|
||||
|
||||
context = MenuContext(
|
||||
language=language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
balance_kopeks=balance_kopeks,
|
||||
subscription=subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
custom_buttons=custom_buttons or [],
|
||||
# Добавляем данные для плейсхолдеров
|
||||
username=username,
|
||||
subscription_days=subscription_days_left,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
traffic_left_gb=traffic_left_gb,
|
||||
referral_count=referral_count,
|
||||
referral_earnings_kopeks=referral_earnings_kopeks,
|
||||
registration_days=registration_days,
|
||||
promo_group_id=promo_group_id,
|
||||
has_autopay=has_autopay,
|
||||
)
|
||||
|
||||
return await MenuLayoutService.build_keyboard(db, context)
|
||||
|
||||
# Fallback на синхронную версию
|
||||
return get_main_menu_keyboard(
|
||||
language=language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks,
|
||||
subscription=subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
|
||||
def _get_localized_value(values, language: str, default_language: str = "en") -> str:
|
||||
if not isinstance(values, dict):
|
||||
return ""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Add traffic to your subscription</b>\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
|
||||
"BUY_TRAFFIC_BUTTON": "📈 Buy more traffic",
|
||||
"ADMIN_BACK_TO_ADMIN": "⬅️ Back to admin",
|
||||
"ADMIN_BACK_TO_LIST": "⬅️ Back to list",
|
||||
"ADMIN_BACK_TO_MAIN": "🏠 Back to main menu",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Добавить трафик к подписке</b>\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:",
|
||||
"BUY_TRAFFIC_BUTTON": "📈 Докупить трафик",
|
||||
"ADMIN_BACK_TO_ADMIN": "⬅️ Назад в админку",
|
||||
"ADMIN_BACK_TO_LIST": "⬅️ К списку",
|
||||
"ADMIN_BACK_TO_MAIN": "🏠 В главное меню",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"ACCESS_DENIED": "❌ Доступ заборонено",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостатньо коштів</b>\n\nВартість послуги: {required}\nНа балансі: {balance}\nНе вистачає: {missing}\n\nОберіть спосіб поповнення. Сума підставиться автоматично.",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Додати країни",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Додати трафік до підписки</b>\n\nПоточний ліміт: {current_traffic}\nВиберіть додатковий трафік:",
|
||||
"BUY_TRAFFIC_BUTTON": "📈 Докупити трафік",
|
||||
"ADMIN_BACK_TO_ADMIN": "⬅️ Назад до адмінки",
|
||||
"ADMIN_BACK_TO_LIST": "⬅️ До списку",
|
||||
"ADMIN_BACK_TO_MAIN": "🏠 В головне меню",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"ACCESS_DENIED":"❌拒绝访问",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE":"⚠️<b>资金不足</b>\n\n服务费用:{required}\n当前余额:{balance}\n缺少:{missing}\n\n请选择充值方式。金额将自动填入。",
|
||||
"ADD_COUNTRIES_BUTTON":"🌐添加国家",
|
||||
"BUY_TRAFFIC_BUTTON":"📈购买更多流量",
|
||||
"ADMIN_BACK_TO_ADMIN":"⬅️返回后台管理",
|
||||
"ADMIN_BACK_TO_LIST":"⬅️返回列表",
|
||||
"ADMIN_BACK_TO_MAIN":"🏠返回主菜单",
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Middleware для автоматического логирования кликов по кнопкам."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable, Set
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, TelegramObject
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Известные builtin callback_data из меню
|
||||
BUILTIN_CALLBACKS: Set[str] = {
|
||||
# Основные кнопки меню
|
||||
"subscription_connect",
|
||||
"subscription_happ_download",
|
||||
"menu_subscription",
|
||||
"buy_traffic",
|
||||
"menu_balance",
|
||||
"menu_trial",
|
||||
"menu_buy",
|
||||
"simple_subscription_purchase",
|
||||
"return_to_saved_cart",
|
||||
"menu_promocode",
|
||||
"menu_referrals",
|
||||
"contests_menu",
|
||||
"menu_support",
|
||||
"menu_info",
|
||||
"menu_language",
|
||||
"admin_panel",
|
||||
"moderator_panel",
|
||||
# Навигация
|
||||
"back_to_menu",
|
||||
"menu_faq",
|
||||
"menu_info_promo_groups",
|
||||
"menu_privacy_policy",
|
||||
"menu_public_offer",
|
||||
"menu_rules",
|
||||
"menu_server_status",
|
||||
# Баланс
|
||||
"balance_history",
|
||||
"balance_topup",
|
||||
# Подписка
|
||||
"subscription_extend",
|
||||
"subscription_autopay",
|
||||
"subscription_settings",
|
||||
"open_subscription_link",
|
||||
"subscription_add_countries",
|
||||
"subscription_reset_traffic",
|
||||
"subscription_switch_traffic",
|
||||
"subscription_change_devices",
|
||||
"subscription_manage_devices",
|
||||
"subscription_upgrade",
|
||||
# Устройства
|
||||
"device_guide_ios",
|
||||
"device_guide_android",
|
||||
"device_guide_windows",
|
||||
"device_guide_mac",
|
||||
"device_guide_tv",
|
||||
"device_guide_appletv",
|
||||
# Happ
|
||||
"happ_download_ios",
|
||||
"happ_download_android",
|
||||
"happ_download_macos",
|
||||
"happ_download_windows",
|
||||
# Рефералы
|
||||
"referral_create_invite",
|
||||
"referral_show_qr",
|
||||
"referral_list",
|
||||
"referral_analytics",
|
||||
# Поддержка
|
||||
"create_ticket",
|
||||
"my_tickets",
|
||||
# Триал
|
||||
"trial_activate",
|
||||
# Покупка
|
||||
"clear_saved_cart",
|
||||
"subscription_confirm",
|
||||
"subscription_cancel",
|
||||
}
|
||||
|
||||
|
||||
class ButtonStatsMiddleware(BaseMiddleware):
|
||||
"""Middleware для автоматического логирования статистики кликов по кнопкам."""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""Перехватывает CallbackQuery и логирует клики по кнопкам."""
|
||||
|
||||
# Обрабатываем только CallbackQuery
|
||||
if not isinstance(event, CallbackQuery):
|
||||
return await handler(event, data)
|
||||
|
||||
# Пропускаем, если статистика отключена
|
||||
if not settings.MENU_LAYOUT_ENABLED:
|
||||
return await handler(event, data)
|
||||
|
||||
# Логируем клик асинхронно, не блокируя обработку
|
||||
try:
|
||||
# Получаем callback_data
|
||||
callback_data = event.data
|
||||
if not callback_data:
|
||||
return await handler(event, data)
|
||||
|
||||
# Получаем user_id
|
||||
user_id = event.from_user.id if event.from_user else None
|
||||
|
||||
# Определяем тип кнопки по callback_data
|
||||
button_type = self._determine_button_type(callback_data)
|
||||
|
||||
# Получаем текст кнопки, если возможно
|
||||
button_text = None
|
||||
if event.message and hasattr(event.message, 'reply_markup'):
|
||||
button_text = self._extract_button_text(event.message.reply_markup, callback_data)
|
||||
|
||||
# Логируем в фоне, не блокируя обработку
|
||||
asyncio.create_task(
|
||||
self._log_button_click_async(
|
||||
button_id=callback_data,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
# Не прерываем обработку при ошибке логирования
|
||||
logger.error(f"Ошибка логирования клика по кнопке: {e}", exc_info=True)
|
||||
|
||||
# Продолжаем обработку
|
||||
return await handler(event, data)
|
||||
|
||||
def _determine_button_type(self, callback_data: str) -> str:
|
||||
"""Определяет тип кнопки по callback_data.
|
||||
|
||||
Примечание: URL и MiniApp кнопки не имеют callback_data,
|
||||
поэтому они не отслеживаются через этот middleware.
|
||||
Для их отслеживания нужен отдельный механизм на стороне клиента.
|
||||
"""
|
||||
# Проверяем по известному списку builtin кнопок
|
||||
if callback_data in BUILTIN_CALLBACKS:
|
||||
return "builtin"
|
||||
|
||||
# Дополнительная проверка по префиксам для динамических callback_data
|
||||
builtin_prefixes = (
|
||||
"menu_",
|
||||
"admin_",
|
||||
"subscription_",
|
||||
"balance_",
|
||||
"referral_",
|
||||
"device_guide_",
|
||||
"happ_download_",
|
||||
)
|
||||
if callback_data.startswith(builtin_prefixes):
|
||||
return "builtin"
|
||||
|
||||
# Всё остальное - кастомные callback кнопки
|
||||
return "callback"
|
||||
|
||||
def _extract_button_text(self, reply_markup, callback_data: str) -> str:
|
||||
"""Извлекает текст кнопки из клавиатуры."""
|
||||
try:
|
||||
if not reply_markup or not hasattr(reply_markup, 'inline_keyboard'):
|
||||
return None
|
||||
|
||||
for row in reply_markup.inline_keyboard:
|
||||
for button in row:
|
||||
if hasattr(button, 'callback_data') and button.callback_data == callback_data:
|
||||
if hasattr(button, 'text'):
|
||||
return button.text
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def _log_button_click_async(
|
||||
self,
|
||||
button_id: str,
|
||||
user_id: int = None,
|
||||
callback_data: str = None,
|
||||
button_type: str = None,
|
||||
button_text: str = None
|
||||
):
|
||||
"""Асинхронно логирует клик по кнопке."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
from app.services.menu_layout_service import MenuLayoutService
|
||||
|
||||
await MenuLayoutService.log_button_click(
|
||||
db,
|
||||
button_id=button_id,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка записи клика в БД {button_id}: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка создания сессии БД для логирования клика: {e}")
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta, time, timezone
|
||||
from typing import Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -191,10 +192,15 @@ class ContestRotationService:
|
||||
async def _tick(self) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
templates = await list_templates(db)
|
||||
now_local = datetime.now().astimezone(timezone.utc)
|
||||
# Get current time in configured timezone
|
||||
tz = self._get_timezone()
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
now_local = now_utc.astimezone(tz)
|
||||
|
||||
for tpl in templates:
|
||||
times = self._parse_times(tpl.schedule_times) or []
|
||||
for slot in times[: tpl.times_per_day]:
|
||||
# Apply schedule time to local date
|
||||
starts_at_local = now_local.replace(
|
||||
hour=slot.hour, minute=slot.minute, second=0, microsecond=0
|
||||
)
|
||||
@@ -207,18 +213,31 @@ class ContestRotationService:
|
||||
exists = await get_active_round_by_template(db, tpl.id)
|
||||
if exists:
|
||||
continue
|
||||
|
||||
# Convert to UTC for storage
|
||||
starts_at_utc = starts_at_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
ends_at_utc = ends_at_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
# Анонс перед созданием раунда
|
||||
await self._announce_round_start(tpl, starts_at_local, ends_at_local)
|
||||
payload = self._build_payload_for_template(tpl)
|
||||
round_obj = await create_round(
|
||||
db,
|
||||
template=tpl,
|
||||
starts_at=starts_at_local.replace(tzinfo=None),
|
||||
ends_at=ends_at_local.replace(tzinfo=None),
|
||||
starts_at=starts_at_utc,
|
||||
ends_at=ends_at_utc,
|
||||
payload=payload,
|
||||
)
|
||||
logger.info("Создан раунд %s для шаблона %s", round_obj.id, tpl.slug)
|
||||
|
||||
def _get_timezone(self) -> ZoneInfo:
|
||||
tz_name = settings.TIMEZONE or "UTC"
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict:
|
||||
payload = tpl.payload or {}
|
||||
if tpl.slug == GAME_QUEST:
|
||||
@@ -262,9 +281,6 @@ class ContestRotationService:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
tz = settings.TIMEZONE or "UTC"
|
||||
starts_txt = starts_at_local.strftime("%d.%m %H:%M")
|
||||
ends_txt = ends_at_local.strftime("%d.%m %H:%M")
|
||||
text = (
|
||||
f"🎲 Стартует игра: <b>{tpl.name}</b>\n"
|
||||
f"Приз: {tpl.prize_days} дн. подписки • Победителей: {tpl.max_winners}\n"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Модуль конструктора меню.
|
||||
|
||||
Структура модуля:
|
||||
- constants.py - константы и дефолтная конфигурация
|
||||
- context.py - MenuContext для построения меню
|
||||
- history_service.py - сервис истории изменений
|
||||
- stats_service.py - сервис статистики кликов
|
||||
- service.py - основной MenuLayoutService
|
||||
"""
|
||||
|
||||
from .constants import (
|
||||
MENU_LAYOUT_CONFIG_KEY,
|
||||
DEFAULT_MENU_CONFIG,
|
||||
BUILTIN_BUTTONS_INFO,
|
||||
AVAILABLE_CALLBACKS,
|
||||
DYNAMIC_PLACEHOLDERS,
|
||||
)
|
||||
from .context import MenuContext
|
||||
from .history_service import MenuLayoutHistoryService
|
||||
from .stats_service import MenuLayoutStatsService
|
||||
from .service import MenuLayoutService
|
||||
|
||||
__all__ = [
|
||||
# Константы
|
||||
"MENU_LAYOUT_CONFIG_KEY",
|
||||
"DEFAULT_MENU_CONFIG",
|
||||
"BUILTIN_BUTTONS_INFO",
|
||||
"AVAILABLE_CALLBACKS",
|
||||
"DYNAMIC_PLACEHOLDERS",
|
||||
# Классы
|
||||
"MenuContext",
|
||||
"MenuLayoutService",
|
||||
"MenuLayoutHistoryService",
|
||||
"MenuLayoutStatsService",
|
||||
]
|
||||
@@ -0,0 +1,501 @@
|
||||
"""Константы для конструктора меню."""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Ключ для хранения конфигурации в SystemSetting
|
||||
MENU_LAYOUT_CONFIG_KEY = "menu_layout_config"
|
||||
|
||||
# Дефолтная конфигурация меню
|
||||
DEFAULT_MENU_CONFIG: Dict[str, Any] = {
|
||||
"version": 1,
|
||||
"rows": [
|
||||
{
|
||||
"id": "connect_row",
|
||||
"buttons": ["connect"],
|
||||
"conditions": {"has_active_subscription": True, "subscription_is_active": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "happ_row",
|
||||
"buttons": ["happ_download"],
|
||||
"conditions": {"has_active_subscription": True, "happ_enabled": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "subscription_traffic_row",
|
||||
"buttons": ["subscription", "buy_traffic"],
|
||||
"conditions": {"has_active_subscription": True},
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "balance_row",
|
||||
"buttons": ["balance"],
|
||||
"conditions": None,
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "trial_buy_row",
|
||||
"buttons": ["trial", "buy_subscription"],
|
||||
"conditions": None,
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "simple_subscription_row",
|
||||
"buttons": ["simple_subscription"],
|
||||
"conditions": {"simple_subscription_enabled": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "resume_row",
|
||||
"buttons": ["resume_checkout"],
|
||||
"conditions": {"has_saved_cart": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "promo_referral_row",
|
||||
"buttons": ["promocode", "referrals"],
|
||||
"conditions": None,
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "contests_row",
|
||||
"buttons": ["contests"],
|
||||
"conditions": {"contests_visible": True},
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "support_info_row",
|
||||
"buttons": ["support", "info"],
|
||||
"conditions": None,
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "language_row",
|
||||
"buttons": ["language"],
|
||||
"conditions": {"language_selection_enabled": True},
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "admin_row",
|
||||
"buttons": ["admin_panel"],
|
||||
"conditions": {"is_admin": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "moderator_row",
|
||||
"buttons": ["moderator_panel"],
|
||||
"conditions": {"is_moderator": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
],
|
||||
"buttons": {
|
||||
"connect": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "connect",
|
||||
"text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"},
|
||||
"action": "subscription_connect",
|
||||
"enabled": True,
|
||||
"visibility": "subscribers",
|
||||
"conditions": {"has_active_subscription": True, "subscription_is_active": True},
|
||||
"dynamic_text": False,
|
||||
"open_mode": "callback", # "callback" или "direct"
|
||||
"webapp_url": None, # URL для Mini App при open_mode="direct"
|
||||
},
|
||||
"happ_download": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "happ_download",
|
||||
"text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"},
|
||||
"action": "subscription_happ_download",
|
||||
"enabled": True,
|
||||
"visibility": "subscribers",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"subscription": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "subscription",
|
||||
"text": {"ru": "📊 Подписка", "en": "📊 Subscription"},
|
||||
"action": "menu_subscription",
|
||||
"enabled": True,
|
||||
"visibility": "subscribers",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"buy_traffic": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "buy_traffic",
|
||||
"text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"},
|
||||
"action": "buy_traffic",
|
||||
"enabled": True,
|
||||
"visibility": "subscribers",
|
||||
"conditions": {"has_traffic_limit": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"balance": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "balance",
|
||||
"text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"},
|
||||
"action": "menu_balance",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": True,
|
||||
},
|
||||
"trial": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "trial",
|
||||
"text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"},
|
||||
"action": "menu_trial",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": {"show_trial": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"buy_subscription": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "buy_subscription",
|
||||
"text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"},
|
||||
"action": "menu_buy",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": {"show_buy": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"simple_subscription": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "simple_subscription",
|
||||
"text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"},
|
||||
"action": "simple_subscription_purchase",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"resume_checkout": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "resume_checkout",
|
||||
"text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"},
|
||||
"action": "return_to_saved_cart",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"promocode": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "promocode",
|
||||
"text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"},
|
||||
"action": "menu_promocode",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"referrals": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "referrals",
|
||||
"text": {"ru": "👥 Рефералы", "en": "👥 Referrals"},
|
||||
"action": "menu_referrals",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": {"referral_enabled": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"contests": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "contests",
|
||||
"text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"},
|
||||
"action": "contests_menu",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"support": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "support",
|
||||
"text": {"ru": "💬 Поддержка", "en": "💬 Support"},
|
||||
"action": "menu_support",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": {"support_enabled": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"info": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "info",
|
||||
"text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"},
|
||||
"action": "menu_info",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"language": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "language",
|
||||
"text": {"ru": "🌐 Язык", "en": "🌐 Language"},
|
||||
"action": "menu_language",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"admin_panel": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "admin_panel",
|
||||
"text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"},
|
||||
"action": "admin_panel",
|
||||
"enabled": True,
|
||||
"visibility": "admins",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"moderator_panel": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "moderator_panel",
|
||||
"text": {"ru": "🧑⚖️ Модерация", "en": "🧑⚖️ Moderation"},
|
||||
"action": "moderator_panel",
|
||||
"enabled": True,
|
||||
"visibility": "moderators",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Информация о встроенных кнопках для API
|
||||
BUILTIN_BUTTONS_INFO: List[Dict[str, Any]] = [
|
||||
{
|
||||
"id": "connect",
|
||||
"default_text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"},
|
||||
"callback_data": "subscription_connect",
|
||||
"default_conditions": {"has_active_subscription": True, "subscription_is_active": True},
|
||||
"supports_dynamic_text": False,
|
||||
"supports_direct_open": True,
|
||||
},
|
||||
{
|
||||
"id": "happ_download",
|
||||
"default_text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"},
|
||||
"callback_data": "subscription_happ_download",
|
||||
"default_conditions": {"happ_enabled": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "subscription",
|
||||
"default_text": {"ru": "📊 Подписка", "en": "📊 Subscription"},
|
||||
"callback_data": "menu_subscription",
|
||||
"default_conditions": {"has_active_subscription": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "buy_traffic",
|
||||
"default_text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"},
|
||||
"callback_data": "buy_traffic",
|
||||
"default_conditions": {"has_traffic_limit": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "balance",
|
||||
"default_text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"},
|
||||
"callback_data": "menu_balance",
|
||||
"default_conditions": None,
|
||||
"supports_dynamic_text": True,
|
||||
},
|
||||
{
|
||||
"id": "trial",
|
||||
"default_text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"},
|
||||
"callback_data": "menu_trial",
|
||||
"default_conditions": {"show_trial": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "buy_subscription",
|
||||
"default_text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"},
|
||||
"callback_data": "menu_buy",
|
||||
"default_conditions": {"show_buy": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "simple_subscription",
|
||||
"default_text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"},
|
||||
"callback_data": "simple_subscription_purchase",
|
||||
"default_conditions": {"simple_subscription_enabled": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "resume_checkout",
|
||||
"default_text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"},
|
||||
"callback_data": "return_to_saved_cart",
|
||||
"default_conditions": {"has_saved_cart": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "promocode",
|
||||
"default_text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"},
|
||||
"callback_data": "menu_promocode",
|
||||
"default_conditions": None,
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "referrals",
|
||||
"default_text": {"ru": "👥 Рефералы", "en": "👥 Referrals"},
|
||||
"callback_data": "menu_referrals",
|
||||
"default_conditions": {"referral_enabled": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "contests",
|
||||
"default_text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"},
|
||||
"callback_data": "contests_menu",
|
||||
"default_conditions": {"contests_visible": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "support",
|
||||
"default_text": {"ru": "💬 Поддержка", "en": "💬 Support"},
|
||||
"callback_data": "menu_support",
|
||||
"default_conditions": {"support_enabled": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "info",
|
||||
"default_text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"},
|
||||
"callback_data": "menu_info",
|
||||
"default_conditions": None,
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "language",
|
||||
"default_text": {"ru": "🌐 Язык", "en": "🌐 Language"},
|
||||
"callback_data": "menu_language",
|
||||
"default_conditions": {"language_selection_enabled": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "admin_panel",
|
||||
"default_text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"},
|
||||
"callback_data": "admin_panel",
|
||||
"default_conditions": {"is_admin": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "moderator_panel",
|
||||
"default_text": {"ru": "🧑⚖️ Модерация", "en": "🧑⚖️ Moderation"},
|
||||
"callback_data": "moderator_panel",
|
||||
"default_conditions": {"is_moderator": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Все доступные callback_data в боте (для добавления кастомных кнопок)
|
||||
AVAILABLE_CALLBACKS: List[Dict[str, Any]] = [
|
||||
# Меню
|
||||
{"callback_data": "back_to_menu", "name": "Назад в меню", "category": "menu", "icon": "⬅️",
|
||||
"text": {"ru": "⬅️ Назад", "en": "⬅️ Back"}},
|
||||
{"callback_data": "menu_faq", "name": "FAQ", "category": "menu", "icon": "❓",
|
||||
"text": {"ru": "❓ FAQ", "en": "❓ FAQ"}},
|
||||
{"callback_data": "menu_info_promo_groups", "name": "Промо-группы", "category": "menu", "icon": "👥",
|
||||
"text": {"ru": "👥 Промо-группы", "en": "👥 Promo groups"}},
|
||||
{"callback_data": "menu_privacy_policy", "name": "Политика конфиденциальности", "category": "menu", "icon": "🔒",
|
||||
"text": {"ru": "🔒 Политика конфиденциальности", "en": "🔒 Privacy Policy"}},
|
||||
{"callback_data": "menu_public_offer", "name": "Публичная оферта", "category": "menu", "icon": "📜",
|
||||
"text": {"ru": "📜 Публичная оферта", "en": "📜 Public Offer"}},
|
||||
{"callback_data": "menu_rules", "name": "Правила", "category": "menu", "icon": "📋",
|
||||
"text": {"ru": "📋 Правила", "en": "📋 Rules"}},
|
||||
{"callback_data": "menu_server_status", "name": "Статус серверов", "category": "menu", "icon": "🖥️",
|
||||
"text": {"ru": "🖥️ Статус серверов", "en": "🖥️ Server Status"}},
|
||||
|
||||
# Баланс
|
||||
{"callback_data": "balance_history", "name": "История баланса", "category": "balance", "icon": "📜",
|
||||
"text": {"ru": "📜 История", "en": "📜 History"}},
|
||||
{"callback_data": "balance_topup", "name": "Пополнить баланс", "category": "balance", "icon": "💳",
|
||||
"text": {"ru": "💳 Пополнить", "en": "💳 Top up"}},
|
||||
|
||||
# Подписка
|
||||
{"callback_data": "subscription_extend", "name": "Продлить подписку", "category": "subscription", "icon": "📅",
|
||||
"text": {"ru": "📅 Продлить", "en": "📅 Extend"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_autopay", "name": "Автоплатёж", "category": "subscription", "icon": "🔄",
|
||||
"text": {"ru": "🔄 Автоплатёж", "en": "🔄 Autopay"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_settings", "name": "Настройки подписки", "category": "subscription", "icon": "⚙️",
|
||||
"text": {"ru": "⚙️ Настройки", "en": "⚙️ Settings"}, "requires_subscription": True},
|
||||
{"callback_data": "open_subscription_link", "name": "Показать ссылку подписки", "category": "subscription", "icon": "🔗",
|
||||
"text": {"ru": "🔗 Показать ссылку", "en": "🔗 Show link"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_add_countries", "name": "Добавить страны", "category": "subscription", "icon": "🌍",
|
||||
"text": {"ru": "🌍 Добавить страны", "en": "🌍 Add countries"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_reset_traffic", "name": "Сбросить трафик", "category": "subscription", "icon": "🔄",
|
||||
"text": {"ru": "🔄 Сбросить трафик", "en": "🔄 Reset traffic"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_switch_traffic", "name": "Переключить трафик", "category": "subscription", "icon": "🔀",
|
||||
"text": {"ru": "🔀 Переключить трафик", "en": "🔀 Switch traffic"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_change_devices", "name": "Изменить устройства", "category": "subscription", "icon": "📱",
|
||||
"text": {"ru": "📱 Изменить устройства", "en": "📱 Change devices"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_manage_devices", "name": "Управление устройствами", "category": "subscription", "icon": "📲",
|
||||
"text": {"ru": "📲 Управление устройствами", "en": "📲 Manage devices"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_upgrade", "name": "Улучшить подписку", "category": "subscription", "icon": "⬆️",
|
||||
"text": {"ru": "⬆️ Улучшить", "en": "⬆️ Upgrade"}, "requires_subscription": True},
|
||||
|
||||
# Подключение устройств
|
||||
{"callback_data": "device_guide_ios", "name": "Инструкция iOS", "category": "devices", "icon": "📱",
|
||||
"text": {"ru": "📱 iOS", "en": "📱 iOS"}, "requires_subscription": True},
|
||||
{"callback_data": "device_guide_android", "name": "Инструкция Android", "category": "devices", "icon": "🤖",
|
||||
"text": {"ru": "🤖 Android", "en": "🤖 Android"}, "requires_subscription": True},
|
||||
{"callback_data": "device_guide_windows", "name": "Инструкция Windows", "category": "devices", "icon": "💻",
|
||||
"text": {"ru": "💻 Windows", "en": "💻 Windows"}, "requires_subscription": True},
|
||||
{"callback_data": "device_guide_mac", "name": "Инструкция macOS", "category": "devices", "icon": "🎯",
|
||||
"text": {"ru": "🎯 macOS", "en": "🎯 macOS"}, "requires_subscription": True},
|
||||
{"callback_data": "device_guide_tv", "name": "Инструкция Android TV", "category": "devices", "icon": "📺",
|
||||
"text": {"ru": "📺 Android TV", "en": "📺 Android TV"}, "requires_subscription": True},
|
||||
{"callback_data": "device_guide_appletv", "name": "Инструкция Apple TV", "category": "devices", "icon": "📺",
|
||||
"text": {"ru": "📺 Apple TV", "en": "📺 Apple TV"}, "requires_subscription": True},
|
||||
|
||||
# Happ
|
||||
{"callback_data": "happ_download_ios", "name": "Скачать Happ iOS", "category": "happ", "icon": "🍎",
|
||||
"text": {"ru": "🍎 iOS", "en": "🍎 iOS"}},
|
||||
{"callback_data": "happ_download_android", "name": "Скачать Happ Android", "category": "happ", "icon": "🤖",
|
||||
"text": {"ru": "🤖 Android", "en": "🤖 Android"}},
|
||||
{"callback_data": "happ_download_macos", "name": "Скачать Happ macOS", "category": "happ", "icon": "🖥️",
|
||||
"text": {"ru": "🖥️ macOS", "en": "🖥️ macOS"}},
|
||||
{"callback_data": "happ_download_windows", "name": "Скачать Happ Windows", "category": "happ", "icon": "💻",
|
||||
"text": {"ru": "💻 Windows", "en": "💻 Windows"}},
|
||||
|
||||
# Рефералы
|
||||
{"callback_data": "referral_create_invite", "name": "Создать инвайт", "category": "referral", "icon": "✉️",
|
||||
"text": {"ru": "✉️ Создать инвайт", "en": "✉️ Create invite"}},
|
||||
{"callback_data": "referral_show_qr", "name": "QR код реферала", "category": "referral", "icon": "📱",
|
||||
"text": {"ru": "📱 QR код", "en": "📱 QR code"}},
|
||||
{"callback_data": "referral_list", "name": "Список рефералов", "category": "referral", "icon": "👥",
|
||||
"text": {"ru": "👥 Мои рефералы", "en": "👥 My referrals"}},
|
||||
{"callback_data": "referral_analytics", "name": "Аналитика рефералов", "category": "referral", "icon": "📊",
|
||||
"text": {"ru": "📊 Аналитика", "en": "📊 Analytics"}},
|
||||
|
||||
# Поддержка
|
||||
{"callback_data": "create_ticket", "name": "Создать тикет", "category": "support", "icon": "✏️",
|
||||
"text": {"ru": "✏️ Создать тикет", "en": "✏️ Create ticket"}},
|
||||
{"callback_data": "my_tickets", "name": "Мои тикеты", "category": "support", "icon": "📋",
|
||||
"text": {"ru": "📋 Мои тикеты", "en": "📋 My tickets"}},
|
||||
|
||||
# Триал
|
||||
{"callback_data": "trial_activate", "name": "Активировать триал", "category": "trial", "icon": "🎁",
|
||||
"text": {"ru": "🎁 Активировать", "en": "🎁 Activate"}},
|
||||
|
||||
# Покупка
|
||||
{"callback_data": "clear_saved_cart", "name": "Очистить корзину", "category": "purchase", "icon": "🗑️",
|
||||
"text": {"ru": "🗑️ Очистить корзину", "en": "🗑️ Clear cart"}},
|
||||
{"callback_data": "subscription_confirm", "name": "Подтвердить покупку", "category": "purchase", "icon": "✅",
|
||||
"text": {"ru": "✅ Подтвердить", "en": "✅ Confirm"}},
|
||||
{"callback_data": "subscription_cancel", "name": "Отменить покупку", "category": "purchase", "icon": "❌",
|
||||
"text": {"ru": "❌ Отменить", "en": "❌ Cancel"}},
|
||||
]
|
||||
|
||||
# Динамические плейсхолдеры для текста кнопок
|
||||
DYNAMIC_PLACEHOLDERS: List[Dict[str, str]] = [
|
||||
{"placeholder": "{balance}", "description": "Баланс пользователя", "example": "1 500 ₽", "category": "user"},
|
||||
{"placeholder": "{username}", "description": "Имя пользователя", "example": "John", "category": "user"},
|
||||
{"placeholder": "{subscription_days}", "description": "Дней до окончания подписки", "example": "14", "category": "subscription"},
|
||||
{"placeholder": "{traffic_used}", "description": "Использованный трафик", "example": "5.2 GB", "category": "subscription"},
|
||||
{"placeholder": "{traffic_left}", "description": "Оставшийся трафик", "example": "94.8 GB", "category": "subscription"},
|
||||
{"placeholder": "{referral_count}", "description": "Количество рефералов", "example": "12", "category": "referral"},
|
||||
{"placeholder": "{referral_earnings}", "description": "Заработок с рефералов", "example": "500 ₽", "category": "referral"},
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Контекст меню для построения кнопок."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuContext:
|
||||
"""Контекст пользователя для построения меню."""
|
||||
|
||||
language: str = "ru"
|
||||
is_admin: bool = False
|
||||
is_moderator: bool = False
|
||||
has_active_subscription: bool = False
|
||||
subscription_is_active: bool = False
|
||||
has_had_paid_subscription: bool = False
|
||||
balance_kopeks: int = 0
|
||||
subscription: Optional[Any] = None
|
||||
show_resume_checkout: bool = False
|
||||
has_saved_cart: bool = False
|
||||
custom_buttons: List[InlineKeyboardButton] = field(default_factory=list)
|
||||
# Расширенные поля для плейсхолдеров и условий
|
||||
username: str = ""
|
||||
subscription_days: int = 0
|
||||
traffic_used_gb: float = 0.0
|
||||
traffic_left_gb: float = 0.0
|
||||
referral_count: int = 0
|
||||
referral_earnings_kopeks: int = 0
|
||||
registration_days: int = 0
|
||||
promo_group_id: Optional[str] = None
|
||||
has_autopay: bool = False
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Сервис истории изменений конфигурации меню."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import MenuLayoutHistory
|
||||
|
||||
|
||||
class MenuLayoutHistoryService:
|
||||
"""Сервис для управления историей изменений меню."""
|
||||
|
||||
@classmethod
|
||||
async def save_history(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
config: Dict[str, Any],
|
||||
action: str,
|
||||
changes_summary: Optional[str] = None,
|
||||
user_info: Optional[str] = None,
|
||||
) -> MenuLayoutHistory:
|
||||
"""Сохранить запись в историю изменений."""
|
||||
history = MenuLayoutHistory(
|
||||
config_json=json.dumps(config, ensure_ascii=False),
|
||||
action=action,
|
||||
changes_summary=changes_summary or f"Action: {action}",
|
||||
user_info=user_info,
|
||||
)
|
||||
db.add(history)
|
||||
await db.commit()
|
||||
await db.refresh(history)
|
||||
return history
|
||||
|
||||
@classmethod
|
||||
async def get_history(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить историю изменений."""
|
||||
result = await db.execute(
|
||||
select(MenuLayoutHistory)
|
||||
.order_by(desc(MenuLayoutHistory.created_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": entry.id,
|
||||
"action": entry.action,
|
||||
"changes_summary": entry.changes_summary,
|
||||
"user_info": entry.user_info,
|
||||
"created_at": entry.created_at,
|
||||
}
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_history_count(cls, db: AsyncSession) -> int:
|
||||
"""Получить общее количество записей истории."""
|
||||
result = await db.execute(
|
||||
select(func.count(MenuLayoutHistory.id))
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
@classmethod
|
||||
async def get_history_entry(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
history_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Получить конкретную запись истории с конфигурацией."""
|
||||
result = await db.execute(
|
||||
select(MenuLayoutHistory).where(MenuLayoutHistory.id == history_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": entry.id,
|
||||
"action": entry.action,
|
||||
"changes_summary": entry.changes_summary,
|
||||
"user_info": entry.user_info,
|
||||
"created_at": entry.created_at,
|
||||
"config": json.loads(entry.config_json),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def rollback_to_history(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
history_id: int,
|
||||
get_config_func,
|
||||
save_config_func,
|
||||
user_info: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Откатить конфигурацию к записи из истории.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
history_id: ID записи истории
|
||||
get_config_func: Функция для получения текущей конфигурации
|
||||
save_config_func: Функция для сохранения конфигурации
|
||||
user_info: Информация о пользователе
|
||||
"""
|
||||
entry = await cls.get_history_entry(db, history_id)
|
||||
if not entry:
|
||||
raise KeyError(f"History entry {history_id} not found")
|
||||
|
||||
config = entry["config"]
|
||||
|
||||
# Сохраняем текущую конфигурацию в историю перед откатом
|
||||
current_config = await get_config_func(db)
|
||||
await cls.save_history(
|
||||
db, current_config, "rollback_backup",
|
||||
f"Backup before rollback to history #{history_id}",
|
||||
user_info
|
||||
)
|
||||
|
||||
# Применяем конфигурацию из истории
|
||||
await save_config_func(db, config)
|
||||
|
||||
# Сохраняем запись об откате
|
||||
await cls.save_history(
|
||||
db, config, "rollback",
|
||||
f"Rollback to history #{history_id}",
|
||||
user_info
|
||||
)
|
||||
|
||||
return config
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,500 @@
|
||||
"""Сервис статистики кликов по кнопкам меню."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select, func, and_, desc, case, Integer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import ButtonClickLog
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
"""Возвращает текущее UTC время как naive datetime (без timezone).
|
||||
|
||||
Замена deprecated datetime.utcnow().
|
||||
"""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class MenuLayoutStatsService:
|
||||
"""Сервис для сбора и анализа статистики кликов по кнопкам."""
|
||||
|
||||
@classmethod
|
||||
def _is_sqlite(cls) -> bool:
|
||||
"""Проверить, используется ли SQLite."""
|
||||
return settings.is_sqlite()
|
||||
|
||||
@classmethod
|
||||
def _get_hour_expr(cls, column):
|
||||
"""Получить выражение для извлечения часа (совместимо с SQLite и PostgreSQL)."""
|
||||
if cls._is_sqlite():
|
||||
# SQLite: strftime('%H', column) возвращает строку
|
||||
return func.cast(func.strftime('%H', column), Integer)
|
||||
else:
|
||||
# PostgreSQL: EXTRACT(hour FROM column)
|
||||
return func.extract('hour', column)
|
||||
|
||||
@classmethod
|
||||
def _get_weekday_expr(cls, column):
|
||||
"""Получить выражение для дня недели (0=Пн, 6=Вс) совместимо с SQLite и PostgreSQL."""
|
||||
if cls._is_sqlite():
|
||||
# SQLite: strftime('%w', column) возвращает 0=воскресенье, 1-6=пн-сб
|
||||
# Преобразуем: 0->6, 1->0, 2->1, ..., 6->5
|
||||
dow = func.cast(func.strftime('%w', column), Integer)
|
||||
return case(
|
||||
(dow == 0, 6),
|
||||
else_=dow - 1
|
||||
)
|
||||
else:
|
||||
# PostgreSQL: EXTRACT(dow FROM column) возвращает 0=воскресенье, 1-6=пн-сб
|
||||
dow = func.extract('dow', column)
|
||||
return case(
|
||||
(dow == 0, 6),
|
||||
else_=dow - 1
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def log_button_click(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: str,
|
||||
user_id: Optional[int] = None,
|
||||
callback_data: Optional[str] = None,
|
||||
button_type: Optional[str] = None,
|
||||
button_text: Optional[str] = None,
|
||||
) -> ButtonClickLog:
|
||||
"""Записать клик по кнопке."""
|
||||
click_log = ButtonClickLog(
|
||||
button_id=button_id,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text,
|
||||
)
|
||||
db.add(click_log)
|
||||
await db.commit()
|
||||
return click_log
|
||||
|
||||
@classmethod
|
||||
async def get_button_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: str,
|
||||
days: int = 30,
|
||||
) -> Dict[str, Any]:
|
||||
"""Получить статистику кликов по конкретной кнопке."""
|
||||
now = _utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=days)
|
||||
|
||||
# Общее количество кликов
|
||||
total_result = await db.execute(
|
||||
select(func.count(ButtonClickLog.id))
|
||||
.where(ButtonClickLog.button_id == button_id)
|
||||
)
|
||||
clicks_total = total_result.scalar() or 0
|
||||
|
||||
# Клики сегодня
|
||||
today_result = await db.execute(
|
||||
select(func.count(ButtonClickLog.id))
|
||||
.where(and_(
|
||||
ButtonClickLog.button_id == button_id,
|
||||
ButtonClickLog.clicked_at >= today_start
|
||||
))
|
||||
)
|
||||
clicks_today = today_result.scalar() or 0
|
||||
|
||||
# Клики за неделю
|
||||
week_result = await db.execute(
|
||||
select(func.count(ButtonClickLog.id))
|
||||
.where(and_(
|
||||
ButtonClickLog.button_id == button_id,
|
||||
ButtonClickLog.clicked_at >= week_ago
|
||||
))
|
||||
)
|
||||
clicks_week = week_result.scalar() or 0
|
||||
|
||||
# Клики за месяц
|
||||
month_result = await db.execute(
|
||||
select(func.count(ButtonClickLog.id))
|
||||
.where(and_(
|
||||
ButtonClickLog.button_id == button_id,
|
||||
ButtonClickLog.clicked_at >= month_ago
|
||||
))
|
||||
)
|
||||
clicks_month = month_result.scalar() or 0
|
||||
|
||||
# Уникальные пользователи
|
||||
unique_result = await db.execute(
|
||||
select(func.count(func.distinct(ButtonClickLog.user_id)))
|
||||
.where(ButtonClickLog.button_id == button_id)
|
||||
)
|
||||
unique_users = unique_result.scalar() or 0
|
||||
|
||||
# Последний клик
|
||||
last_click_result = await db.execute(
|
||||
select(ButtonClickLog.clicked_at)
|
||||
.where(ButtonClickLog.button_id == button_id)
|
||||
.order_by(desc(ButtonClickLog.clicked_at))
|
||||
.limit(1)
|
||||
)
|
||||
last_click = last_click_result.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"button_id": button_id,
|
||||
"clicks_total": clicks_total,
|
||||
"clicks_today": clicks_today,
|
||||
"clicks_week": clicks_week,
|
||||
"clicks_month": clicks_month,
|
||||
"unique_users": unique_users,
|
||||
"last_click_at": last_click,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def get_button_clicks_by_day(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: str,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику кликов по дням."""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
# Группировка по дате
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.date(ButtonClickLog.clicked_at).label("date"),
|
||||
func.count(ButtonClickLog.id).label("count")
|
||||
)
|
||||
.where(and_(
|
||||
ButtonClickLog.button_id == button_id,
|
||||
ButtonClickLog.clicked_at >= start_date
|
||||
))
|
||||
.group_by(func.date(ButtonClickLog.clicked_at))
|
||||
.order_by(func.date(ButtonClickLog.clicked_at))
|
||||
)
|
||||
|
||||
return [
|
||||
{"date": str(row.date), "count": row.count}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_all_buttons_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику по всем кнопкам."""
|
||||
now = _utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=days)
|
||||
|
||||
# Для производительности используем один запрос с подзапросами через CASE
|
||||
result = await db.execute(
|
||||
select(
|
||||
ButtonClickLog.button_id,
|
||||
# Общее количество кликов (все клики без фильтра по датам)
|
||||
func.count(ButtonClickLog.id).label("clicks_total"),
|
||||
# Уникальные пользователи (все время)
|
||||
func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"),
|
||||
# Последний клик (все время)
|
||||
func.max(ButtonClickLog.clicked_at).label("last_click_at"),
|
||||
# Подсчет кликов за сегодня
|
||||
func.sum(
|
||||
case((ButtonClickLog.clicked_at >= today_start, 1), else_=0)
|
||||
).label("clicks_today"),
|
||||
# Подсчет кликов за неделю
|
||||
func.sum(
|
||||
case((ButtonClickLog.clicked_at >= week_ago, 1), else_=0)
|
||||
).label("clicks_week"),
|
||||
# Подсчет кликов за месяц
|
||||
func.sum(
|
||||
case((ButtonClickLog.clicked_at >= month_ago, 1), else_=0)
|
||||
).label("clicks_month"),
|
||||
)
|
||||
.group_by(ButtonClickLog.button_id)
|
||||
.order_by(desc(func.count(ButtonClickLog.id)))
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"button_id": row.button_id,
|
||||
"clicks_total": row.clicks_total,
|
||||
"clicks_today": row.clicks_today or 0,
|
||||
"clicks_week": row.clicks_week or 0,
|
||||
"clicks_month": row.clicks_month or 0,
|
||||
"unique_users": row.unique_users,
|
||||
"last_click_at": row.last_click_at,
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_total_clicks(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
days: int = 30,
|
||||
) -> int:
|
||||
"""Получить общее количество кликов за период."""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(ButtonClickLog.id))
|
||||
.where(ButtonClickLog.clicked_at >= start_date)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
@classmethod
|
||||
async def get_stats_by_button_type(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику кликов по типам кнопок."""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
ButtonClickLog.button_type,
|
||||
func.count(ButtonClickLog.id).label("clicks_total"),
|
||||
func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"),
|
||||
)
|
||||
.where(and_(
|
||||
ButtonClickLog.clicked_at >= start_date,
|
||||
ButtonClickLog.button_type.isnot(None)
|
||||
))
|
||||
.group_by(ButtonClickLog.button_type)
|
||||
.order_by(desc(func.count(ButtonClickLog.id)))
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"button_type": row.button_type or "unknown",
|
||||
"clicks_total": row.clicks_total,
|
||||
"unique_users": row.unique_users,
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_clicks_by_hour(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику кликов по часам дня."""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
# Используем helper-метод для совместимости с SQLite и PostgreSQL
|
||||
hour_expr = cls._get_hour_expr(ButtonClickLog.clicked_at).label("hour")
|
||||
|
||||
query = select(
|
||||
hour_expr,
|
||||
func.count(ButtonClickLog.id).label("count")
|
||||
).where(ButtonClickLog.clicked_at >= start_date)
|
||||
|
||||
if button_id:
|
||||
query = query.where(ButtonClickLog.button_id == button_id)
|
||||
|
||||
result = await db.execute(
|
||||
query
|
||||
.group_by(hour_expr)
|
||||
.order_by(hour_expr)
|
||||
)
|
||||
|
||||
# Создаем словарь для быстрого доступа по часу
|
||||
stats_dict = {
|
||||
int(row.hour): row.count
|
||||
for row in result.all()
|
||||
}
|
||||
|
||||
# Возвращаем все 24 часа, даже если count = 0
|
||||
return [
|
||||
{"hour": hour, "count": stats_dict.get(hour, 0)}
|
||||
for hour in range(24)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_clicks_by_weekday(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику кликов по дням недели.
|
||||
|
||||
Возвращает 0=понедельник, 6=воскресенье.
|
||||
Поддерживает как PostgreSQL, так и SQLite.
|
||||
"""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
# Используем helper-метод для совместимости с SQLite и PostgreSQL
|
||||
weekday_expr = cls._get_weekday_expr(ButtonClickLog.clicked_at).label("weekday")
|
||||
|
||||
query = select(
|
||||
weekday_expr,
|
||||
func.count(ButtonClickLog.id).label("count")
|
||||
).where(ButtonClickLog.clicked_at >= start_date)
|
||||
|
||||
if button_id:
|
||||
query = query.where(ButtonClickLog.button_id == button_id)
|
||||
|
||||
result = await db.execute(
|
||||
query
|
||||
.group_by(weekday_expr)
|
||||
.order_by(weekday_expr)
|
||||
)
|
||||
|
||||
weekday_names = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"]
|
||||
|
||||
# Создаем словарь для быстрого доступа по weekday
|
||||
stats_dict = {
|
||||
int(row.weekday): row.count
|
||||
for row in result.all()
|
||||
}
|
||||
|
||||
# Возвращаем все дни недели, даже если count = 0
|
||||
return [
|
||||
{
|
||||
"weekday": weekday,
|
||||
"weekday_name": weekday_names[weekday],
|
||||
"count": stats_dict.get(weekday, 0)
|
||||
}
|
||||
for weekday in range(7)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_top_users(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить топ пользователей по количеству кликов."""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
query = select(
|
||||
ButtonClickLog.user_id,
|
||||
func.count(ButtonClickLog.id).label("clicks_count"),
|
||||
func.max(ButtonClickLog.clicked_at).label("last_click_at")
|
||||
).where(and_(
|
||||
ButtonClickLog.clicked_at >= start_date,
|
||||
ButtonClickLog.user_id.isnot(None)
|
||||
))
|
||||
|
||||
if button_id:
|
||||
query = query.where(ButtonClickLog.button_id == button_id)
|
||||
|
||||
result = await db.execute(
|
||||
query
|
||||
.group_by(ButtonClickLog.user_id)
|
||||
.order_by(desc(func.count(ButtonClickLog.id)))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"user_id": row.user_id,
|
||||
"clicks_count": row.clicks_count,
|
||||
"last_click_at": row.last_click_at,
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_period_comparison(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: Optional[str] = None,
|
||||
current_days: int = 7,
|
||||
previous_days: int = 7,
|
||||
) -> Dict[str, Any]:
|
||||
"""Сравнить статистику текущего и предыдущего периода."""
|
||||
now = _utcnow()
|
||||
current_start = now - timedelta(days=current_days)
|
||||
previous_start = current_start - timedelta(days=previous_days)
|
||||
previous_end = current_start
|
||||
|
||||
query_current = select(func.count(ButtonClickLog.id))
|
||||
query_previous = select(func.count(ButtonClickLog.id))
|
||||
|
||||
if button_id:
|
||||
query_current = query_current.where(ButtonClickLog.button_id == button_id)
|
||||
query_previous = query_previous.where(ButtonClickLog.button_id == button_id)
|
||||
|
||||
query_current = query_current.where(
|
||||
ButtonClickLog.clicked_at >= current_start
|
||||
)
|
||||
query_previous = query_previous.where(
|
||||
and_(
|
||||
ButtonClickLog.clicked_at >= previous_start,
|
||||
ButtonClickLog.clicked_at < previous_end
|
||||
)
|
||||
)
|
||||
|
||||
current_result = await db.execute(query_current)
|
||||
previous_result = await db.execute(query_previous)
|
||||
|
||||
current_count = current_result.scalar() or 0
|
||||
previous_count = previous_result.scalar() or 0
|
||||
|
||||
change_percent = 0
|
||||
if previous_count > 0:
|
||||
change_percent = ((current_count - previous_count) / previous_count) * 100
|
||||
|
||||
return {
|
||||
"current_period": {
|
||||
"clicks": current_count,
|
||||
"days": current_days,
|
||||
"start": current_start,
|
||||
"end": now,
|
||||
},
|
||||
"previous_period": {
|
||||
"clicks": previous_count,
|
||||
"days": previous_days,
|
||||
"start": previous_start,
|
||||
"end": previous_end,
|
||||
},
|
||||
"change": {
|
||||
"absolute": current_count - previous_count,
|
||||
"percent": round(change_percent, 2),
|
||||
"trend": "up" if change_percent > 0 else "down" if change_percent < 0 else "stable",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def get_click_sequences(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить последовательности кликов пользователя."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
ButtonClickLog.button_id,
|
||||
ButtonClickLog.button_text,
|
||||
ButtonClickLog.clicked_at,
|
||||
)
|
||||
.where(ButtonClickLog.user_id == user_id)
|
||||
.order_by(desc(ButtonClickLog.clicked_at))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"button_id": row.button_id,
|
||||
"button_text": row.button_text,
|
||||
"clicked_at": row.clicked_at,
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Сервис конструктора меню - управление конфигурацией через API.
|
||||
|
||||
ВНИМАНИЕ: Этот файл оставлен для обратной совместимости.
|
||||
Фактическая реализация находится в app/services/menu_layout/
|
||||
|
||||
Структура модуля:
|
||||
- app/services/menu_layout/constants.py - константы
|
||||
- app/services/menu_layout/context.py - MenuContext
|
||||
- app/services/menu_layout/history_service.py - история изменений
|
||||
- app/services/menu_layout/stats_service.py - статистика кликов
|
||||
- app/services/menu_layout/service.py - основной сервис
|
||||
"""
|
||||
|
||||
# Реэкспорт для обратной совместимости
|
||||
from app.services.menu_layout import (
|
||||
# Константы
|
||||
MENU_LAYOUT_CONFIG_KEY,
|
||||
DEFAULT_MENU_CONFIG,
|
||||
BUILTIN_BUTTONS_INFO,
|
||||
AVAILABLE_CALLBACKS,
|
||||
DYNAMIC_PLACEHOLDERS,
|
||||
# Классы
|
||||
MenuContext,
|
||||
MenuLayoutService,
|
||||
MenuLayoutHistoryService,
|
||||
MenuLayoutStatsService,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MENU_LAYOUT_CONFIG_KEY",
|
||||
"DEFAULT_MENU_CONFIG",
|
||||
"BUILTIN_BUTTONS_INFO",
|
||||
"AVAILABLE_CALLBACKS",
|
||||
"DYNAMIC_PLACEHOLDERS",
|
||||
"MenuContext",
|
||||
"MenuLayoutService",
|
||||
"MenuLayoutHistoryService",
|
||||
"MenuLayoutStatsService",
|
||||
]
|
||||
@@ -214,7 +214,8 @@ class MonitoringService:
|
||||
await self._check_trial_inactivity_notifications(db)
|
||||
await self._check_trial_channel_subscriptions(db)
|
||||
await self._check_expired_subscription_followups(db)
|
||||
await self._process_autopayments(db)
|
||||
if settings.ENABLE_AUTOPAY:
|
||||
await self._process_autopayments(db)
|
||||
await self._cleanup_inactive_users(db)
|
||||
await self._sync_with_remnawave(db)
|
||||
|
||||
@@ -937,7 +938,7 @@ class MonitoringService:
|
||||
autopay_subscriptions = []
|
||||
for sub in all_autopay_subscriptions:
|
||||
days_before_expiry = (sub.end_date - current_time).days
|
||||
if days_before_expiry <= sub.autopay_days_before:
|
||||
if days_before_expiry <= min(sub.autopay_days_before, 3):
|
||||
autopay_subscriptions.append(sub)
|
||||
|
||||
processed_count = 0
|
||||
@@ -1065,12 +1066,16 @@ class MonitoringService:
|
||||
texts = get_texts(user.language)
|
||||
days_text = format_days_declension(days, user.language)
|
||||
|
||||
if subscription.autopay_enabled:
|
||||
autopay_status = "✅ Включен - подписка продлится автоматически"
|
||||
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
|
||||
if settings.ENABLE_AUTOPAY:
|
||||
if subscription.autopay_enabled:
|
||||
autopay_status = "✅ Включен - подписка продлится автоматически"
|
||||
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
|
||||
else:
|
||||
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
|
||||
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
|
||||
else:
|
||||
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
|
||||
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
|
||||
action_text = "💡 Продлите подписку вручную"
|
||||
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
"""Сервис расширенной статистики партнёров (рефереров)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, case, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PartnerStatsService:
|
||||
"""Сервис для детальной статистики партнёров."""
|
||||
|
||||
@classmethod
|
||||
async def get_referrer_detailed_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Получить детальную статистику реферера."""
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
year_ago = now - timedelta(days=365)
|
||||
|
||||
# Базовые данные о рефералах
|
||||
referrals_query = select(User).where(User.referred_by_id == user_id)
|
||||
referrals_result = await db.execute(referrals_query)
|
||||
referrals = referrals_result.scalars().all()
|
||||
referral_ids = [r.id for r in referrals]
|
||||
|
||||
total_referrals = len(referrals)
|
||||
|
||||
# Сколько сделали первое пополнение (has_made_first_topup)
|
||||
paid_referrals = sum(1 for r in referrals if r.has_made_first_topup)
|
||||
|
||||
# Активные рефералы (с активной подпиской)
|
||||
if referral_ids:
|
||||
active_result = await db.execute(
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.id.in_(referral_ids),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
)
|
||||
active_referrals = active_result.scalar() or 0
|
||||
else:
|
||||
active_referrals = 0
|
||||
|
||||
# Заработки по периодам - один запрос с CASE WHEN
|
||||
earnings_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("today"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("week"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("month"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("year"),
|
||||
).where(ReferralEarning.user_id == user_id)
|
||||
)
|
||||
earnings_row = earnings_result.one()
|
||||
earnings_all_time = int(earnings_row.all_time)
|
||||
earnings_today = int(earnings_row.today)
|
||||
earnings_week = int(earnings_row.week)
|
||||
earnings_month = int(earnings_row.month)
|
||||
earnings_year = int(earnings_row.year)
|
||||
|
||||
# Рефералы по периодам
|
||||
referrals_today = sum(1 for r in referrals if r.created_at >= today_start)
|
||||
referrals_week = sum(1 for r in referrals if r.created_at >= week_ago)
|
||||
referrals_month = sum(1 for r in referrals if r.created_at >= month_ago)
|
||||
referrals_year = sum(1 for r in referrals if r.created_at >= year_ago)
|
||||
|
||||
# Конверсии
|
||||
conversion_to_paid = round((paid_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0
|
||||
conversion_to_active = round((active_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0
|
||||
|
||||
# Средний доход с реферала
|
||||
avg_earnings_per_referral = round(earnings_all_time / paid_referrals, 2) if paid_referrals > 0 else 0
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"summary": {
|
||||
"total_referrals": total_referrals,
|
||||
"paid_referrals": paid_referrals,
|
||||
"active_referrals": active_referrals,
|
||||
"conversion_to_paid_percent": conversion_to_paid,
|
||||
"conversion_to_active_percent": conversion_to_active,
|
||||
"avg_earnings_per_referral_kopeks": avg_earnings_per_referral,
|
||||
},
|
||||
"earnings": {
|
||||
"all_time_kopeks": earnings_all_time,
|
||||
"year_kopeks": earnings_year,
|
||||
"month_kopeks": earnings_month,
|
||||
"week_kopeks": earnings_week,
|
||||
"today_kopeks": earnings_today,
|
||||
},
|
||||
"referrals_count": {
|
||||
"all_time": total_referrals,
|
||||
"year": referrals_year,
|
||||
"month": referrals_month,
|
||||
"week": referrals_week,
|
||||
"today": referrals_today,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def get_referrer_daily_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику реферера по дням."""
|
||||
now = datetime.utcnow()
|
||||
start_date = now - timedelta(days=days)
|
||||
|
||||
# Рефералы по дням
|
||||
referrals_by_day = await db.execute(
|
||||
select(
|
||||
func.date(User.created_at).label("date"),
|
||||
func.count(User.id).label("referrals_count"),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == user_id,
|
||||
User.created_at >= start_date,
|
||||
)
|
||||
)
|
||||
.group_by(func.date(User.created_at))
|
||||
.order_by(func.date(User.created_at))
|
||||
)
|
||||
referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()}
|
||||
|
||||
# Заработки по дням (из ReferralEarning)
|
||||
earnings_by_day = await db.execute(
|
||||
select(
|
||||
func.date(ReferralEarning.created_at).label("date"),
|
||||
func.sum(ReferralEarning.amount_kopeks).label("earnings"),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
ReferralEarning.user_id == user_id,
|
||||
ReferralEarning.created_at >= start_date,
|
||||
)
|
||||
)
|
||||
.group_by(func.date(ReferralEarning.created_at))
|
||||
)
|
||||
earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()}
|
||||
|
||||
# Формируем массив за все дни
|
||||
result = []
|
||||
for i in range(days):
|
||||
date = (start_date + timedelta(days=i)).date()
|
||||
date_str = str(date)
|
||||
result.append({
|
||||
"date": date_str,
|
||||
"referrals_count": referrals_dict.get(date_str, 0),
|
||||
"earnings_kopeks": earnings_dict.get(date_str, 0),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def get_referrer_top_referrals(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить топ рефералов по доходу для реферера."""
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Получаем рефералов с их доходами
|
||||
result = await db.execute(
|
||||
select(
|
||||
User.id,
|
||||
User.telegram_id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
User.last_name,
|
||||
User.created_at,
|
||||
User.has_made_first_topup,
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("total_earnings"),
|
||||
)
|
||||
.outerjoin(ReferralEarning, ReferralEarning.referral_id == User.id)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.group_by(User.id)
|
||||
.order_by(desc(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# Собираем user_id для проверки активных подписок одним запросом
|
||||
user_ids = [row.id for row in rows]
|
||||
|
||||
# Получаем все активные подписки для этих пользователей одним запросом
|
||||
active_subs_result = await db.execute(
|
||||
select(Subscription.user_id)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.user_id.in_(user_ids),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
)
|
||||
active_user_ids = {row.user_id for row in active_subs_result.all()}
|
||||
|
||||
referrals = []
|
||||
for row in rows:
|
||||
referrals.append({
|
||||
"id": row.id,
|
||||
"telegram_id": row.telegram_id,
|
||||
"username": row.username,
|
||||
"first_name": row.first_name,
|
||||
"last_name": row.last_name,
|
||||
"full_name": f"{row.first_name or ''} {row.last_name or ''}".strip() or f"User {row.telegram_id}",
|
||||
"created_at": row.created_at,
|
||||
"has_made_first_topup": row.has_made_first_topup,
|
||||
"is_active": row.id in active_user_ids,
|
||||
"total_earnings_kopeks": int(row.total_earnings),
|
||||
})
|
||||
|
||||
return referrals
|
||||
|
||||
@classmethod
|
||||
async def get_referrer_period_comparison(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
current_days: int = 7,
|
||||
previous_days: int = 7,
|
||||
) -> Dict[str, Any]:
|
||||
"""Сравнить текущий и предыдущий период."""
|
||||
now = datetime.utcnow()
|
||||
current_start = now - timedelta(days=current_days)
|
||||
previous_start = current_start - timedelta(days=previous_days)
|
||||
previous_end = current_start
|
||||
|
||||
# Рефералы за текущий период
|
||||
current_referrals = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == user_id,
|
||||
User.created_at >= current_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
current_referrals_count = current_referrals.scalar() or 0
|
||||
|
||||
# Рефералы за предыдущий период
|
||||
previous_referrals = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == user_id,
|
||||
User.created_at >= previous_start,
|
||||
User.created_at < previous_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
previous_referrals_count = previous_referrals.scalar() or 0
|
||||
|
||||
# Заработки за текущий период
|
||||
current_earnings = await cls._get_earnings_for_period(db, user_id, current_start)
|
||||
|
||||
# Заработки за предыдущий период
|
||||
previous_earnings = await cls._get_earnings_for_period(
|
||||
db, user_id, previous_start, previous_end
|
||||
)
|
||||
|
||||
# Расчёт изменений
|
||||
referrals_change = current_referrals_count - previous_referrals_count
|
||||
referrals_change_percent = (
|
||||
round((referrals_change / previous_referrals_count * 100), 2)
|
||||
if previous_referrals_count > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
earnings_change = current_earnings - previous_earnings
|
||||
earnings_change_percent = (
|
||||
round((earnings_change / previous_earnings * 100), 2)
|
||||
if previous_earnings > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"current_period": {
|
||||
"days": current_days,
|
||||
"start": current_start.isoformat(),
|
||||
"end": now.isoformat(),
|
||||
"referrals_count": current_referrals_count,
|
||||
"earnings_kopeks": current_earnings,
|
||||
},
|
||||
"previous_period": {
|
||||
"days": previous_days,
|
||||
"start": previous_start.isoformat(),
|
||||
"end": previous_end.isoformat(),
|
||||
"referrals_count": previous_referrals_count,
|
||||
"earnings_kopeks": previous_earnings,
|
||||
},
|
||||
"change": {
|
||||
"referrals": {
|
||||
"absolute": referrals_change,
|
||||
"percent": referrals_change_percent,
|
||||
"trend": "up" if referrals_change > 0 else "down" if referrals_change < 0 else "stable",
|
||||
},
|
||||
"earnings": {
|
||||
"absolute": earnings_change,
|
||||
"percent": earnings_change_percent,
|
||||
"trend": "up" if earnings_change > 0 else "down" if earnings_change < 0 else "stable",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def get_global_partner_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
days: int = 30,
|
||||
) -> Dict[str, Any]:
|
||||
"""Глобальная статистика партнёрской программы."""
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
year_ago = now - timedelta(days=365)
|
||||
start_date = now - timedelta(days=days)
|
||||
|
||||
# Всего рефереров (у кого есть рефералы)
|
||||
total_referrers = await db.execute(
|
||||
select(func.count(func.distinct(User.referred_by_id)))
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
)
|
||||
total_referrers_count = total_referrers.scalar() or 0
|
||||
|
||||
# Всего рефералов
|
||||
total_referrals = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
)
|
||||
total_referrals_count = total_referrals.scalar() or 0
|
||||
|
||||
# Рефералы которые заплатили
|
||||
paid_referrals = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id.isnot(None),
|
||||
User.has_made_first_topup.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
paid_referrals_count = paid_referrals.scalar() or 0
|
||||
|
||||
# Всего выплачено - один запрос с CASE WHEN
|
||||
payouts_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("today"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("week"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("month"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("year"),
|
||||
)
|
||||
)
|
||||
payouts_row = payouts_result.one()
|
||||
total_paid = int(payouts_row.all_time)
|
||||
today_paid = int(payouts_row.today)
|
||||
week_paid = int(payouts_row.week)
|
||||
month_paid = int(payouts_row.month)
|
||||
year_paid = int(payouts_row.year)
|
||||
|
||||
# Новые рефералы по периодам - один запрос с CASE WHEN
|
||||
new_referrals_result = await db.execute(
|
||||
select(
|
||||
func.sum(case((User.created_at >= today_start, 1), else_=0)).label("today"),
|
||||
func.sum(case((User.created_at >= week_ago, 1), else_=0)).label("week"),
|
||||
func.sum(case((User.created_at >= month_ago, 1), else_=0)).label("month"),
|
||||
).where(User.referred_by_id.isnot(None))
|
||||
)
|
||||
new_referrals_row = new_referrals_result.one()
|
||||
new_referrals_today_count = int(new_referrals_row.today or 0)
|
||||
new_referrals_week_count = int(new_referrals_row.week or 0)
|
||||
new_referrals_month_count = int(new_referrals_row.month or 0)
|
||||
|
||||
# Конверсия
|
||||
conversion_rate = (
|
||||
round((paid_referrals_count / total_referrals_count * 100), 2)
|
||||
if total_referrals_count > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
# Средний доход с реферала
|
||||
avg_per_referral = (
|
||||
round(total_paid / paid_referrals_count, 2)
|
||||
if paid_referrals_count > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"total_referrers": total_referrers_count,
|
||||
"total_referrals": total_referrals_count,
|
||||
"paid_referrals": paid_referrals_count,
|
||||
"conversion_rate_percent": conversion_rate,
|
||||
"avg_earnings_per_referral_kopeks": avg_per_referral,
|
||||
},
|
||||
"payouts": {
|
||||
"all_time_kopeks": total_paid,
|
||||
"year_kopeks": year_paid,
|
||||
"month_kopeks": month_paid,
|
||||
"week_kopeks": week_paid,
|
||||
"today_kopeks": today_paid,
|
||||
},
|
||||
"new_referrals": {
|
||||
"today": new_referrals_today_count,
|
||||
"week": new_referrals_week_count,
|
||||
"month": new_referrals_month_count,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def get_global_daily_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Глобальная статистика по дням."""
|
||||
now = datetime.utcnow()
|
||||
start_date = now - timedelta(days=days)
|
||||
|
||||
# Рефералы по дням
|
||||
referrals_by_day = await db.execute(
|
||||
select(
|
||||
func.date(User.created_at).label("date"),
|
||||
func.count(User.id).label("referrals_count"),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id.isnot(None),
|
||||
User.created_at >= start_date,
|
||||
)
|
||||
)
|
||||
.group_by(func.date(User.created_at))
|
||||
)
|
||||
referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()}
|
||||
|
||||
# Выплаты по дням
|
||||
earnings_by_day = await db.execute(
|
||||
select(
|
||||
func.date(ReferralEarning.created_at).label("date"),
|
||||
func.sum(ReferralEarning.amount_kopeks).label("earnings"),
|
||||
)
|
||||
.where(ReferralEarning.created_at >= start_date)
|
||||
.group_by(func.date(ReferralEarning.created_at))
|
||||
)
|
||||
earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()}
|
||||
|
||||
result = []
|
||||
for i in range(days):
|
||||
date = (start_date + timedelta(days=i)).date()
|
||||
date_str = str(date)
|
||||
result.append({
|
||||
"date": date_str,
|
||||
"referrals_count": referrals_dict.get(date_str, 0),
|
||||
"earnings_kopeks": earnings_dict.get(date_str, 0),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def get_top_referrers(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
limit: int = 10,
|
||||
days: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить топ рефереров."""
|
||||
now = datetime.utcnow()
|
||||
start_date = now - timedelta(days=days) if days else None
|
||||
|
||||
# Подсчёт рефералов и заработков
|
||||
earnings_query = (
|
||||
select(
|
||||
ReferralEarning.user_id,
|
||||
func.sum(ReferralEarning.amount_kopeks).label("total_earnings"),
|
||||
)
|
||||
.group_by(ReferralEarning.user_id)
|
||||
)
|
||||
if start_date:
|
||||
earnings_query = earnings_query.where(ReferralEarning.created_at >= start_date)
|
||||
|
||||
earnings_result = await db.execute(earnings_query)
|
||||
earnings_dict = {row.user_id: int(row.total_earnings or 0) for row in earnings_result.all()}
|
||||
|
||||
# Подсчёт рефералов
|
||||
referrals_query = (
|
||||
select(
|
||||
User.referred_by_id,
|
||||
func.count(User.id).label("referrals_count"),
|
||||
)
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
.group_by(User.referred_by_id)
|
||||
)
|
||||
if start_date:
|
||||
referrals_query = referrals_query.where(User.created_at >= start_date)
|
||||
|
||||
referrals_result = await db.execute(referrals_query)
|
||||
referrals_dict = {row.referred_by_id: row.referrals_count for row in referrals_result.all()}
|
||||
|
||||
# Объединяем данные
|
||||
all_referrer_ids = set(earnings_dict.keys()) | set(referrals_dict.keys())
|
||||
referrers_data = []
|
||||
|
||||
for referrer_id in all_referrer_ids:
|
||||
referrers_data.append({
|
||||
"user_id": referrer_id,
|
||||
"referrals_count": referrals_dict.get(referrer_id, 0),
|
||||
"total_earnings": earnings_dict.get(referrer_id, 0),
|
||||
})
|
||||
|
||||
# Сортируем по заработку
|
||||
referrers_data.sort(key=lambda x: x["total_earnings"], reverse=True)
|
||||
top_referrers = referrers_data[:limit]
|
||||
|
||||
if not top_referrers:
|
||||
return []
|
||||
|
||||
# Получаем данные всех пользователей одним запросом
|
||||
top_user_ids = [data["user_id"] for data in top_referrers]
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.id.in_(top_user_ids))
|
||||
)
|
||||
users_dict = {user.id: user for user in users_result.scalars().all()}
|
||||
|
||||
# Формируем результат с сохранением порядка сортировки
|
||||
result = []
|
||||
for data in top_referrers:
|
||||
user = users_dict.get(data["user_id"])
|
||||
if user:
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"telegram_id": user.telegram_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"full_name": f"{user.first_name or ''} {user.last_name or ''}".strip() or f"User {user.telegram_id}",
|
||||
"referral_code": user.referral_code,
|
||||
"referrals_count": data["referrals_count"],
|
||||
"total_earnings_kopeks": data["total_earnings"],
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def _get_earnings_for_period(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
start_date: Optional[datetime],
|
||||
end_date: Optional[datetime] = None,
|
||||
) -> int:
|
||||
"""Получить заработки за период."""
|
||||
query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == user_id
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.where(ReferralEarning.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ReferralEarning.created_at < end_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@classmethod
|
||||
async def _get_total_earnings(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
start_date: Optional[datetime],
|
||||
) -> int:
|
||||
"""Получить общие выплаты за период."""
|
||||
query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
|
||||
if start_date:
|
||||
query = query.where(ReferralEarning.created_at >= start_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
@@ -277,7 +277,7 @@ class ReferralContestService:
|
||||
f"Название: <b>{contest.title}</b>",
|
||||
f"Статус: {'финал' if is_final else 'дневная сводка'}",
|
||||
f"Временная зона: <code>{tz.key}</code>",
|
||||
f"Всего участников: <b>{len(leaderboard)}</b>",
|
||||
f"Всего рефералов: <b>{total_events}</b>",
|
||||
"",
|
||||
"Топ участников:",
|
||||
]
|
||||
@@ -285,7 +285,7 @@ class ReferralContestService:
|
||||
if leaderboard:
|
||||
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
|
||||
name = user.full_name
|
||||
lines.append(f"{idx}. {name} — {score}")
|
||||
lines.append(f"{idx}. {name} ({user.telegram_id}) — {score}")
|
||||
else:
|
||||
lines.append("Пока нет участников.")
|
||||
|
||||
@@ -390,6 +390,55 @@ class ReferralContestService:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def get_detailed_contest_stats(self, db: AsyncSession, contest_id: int) -> dict:
|
||||
from app.database.crud.referral_contest import get_contest_leaderboard, get_referral_contest
|
||||
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
return {
|
||||
'total_participants': 0,
|
||||
'total_invited': 0,
|
||||
'total_paid_amount': 0,
|
||||
'total_unpaid': 0,
|
||||
'participants': [],
|
||||
}
|
||||
|
||||
# Get leaderboard - already includes User objects
|
||||
leaderboard = await get_contest_leaderboard(db, contest_id)
|
||||
if not leaderboard:
|
||||
return {
|
||||
'total_participants': 0,
|
||||
'total_invited': 0,
|
||||
'total_paid_amount': 0,
|
||||
'total_unpaid': 0,
|
||||
'participants': [],
|
||||
}
|
||||
|
||||
total_participants = len(leaderboard)
|
||||
total_invited = sum(score for _, score, _ in leaderboard)
|
||||
total_paid_amount = sum(amount for _, _, amount in leaderboard)
|
||||
total_unpaid = 0
|
||||
|
||||
# Build participants stats directly from leaderboard (already has User objects)
|
||||
participants_stats = []
|
||||
for user, score, amount in leaderboard:
|
||||
participants_stats.append({
|
||||
'referrer_id': user.id,
|
||||
'full_name': user.full_name,
|
||||
'total_referrals': score,
|
||||
'paid_referrals': score,
|
||||
'unpaid_referrals': 0,
|
||||
'total_paid_amount': amount,
|
||||
})
|
||||
|
||||
return {
|
||||
'total_participants': total_participants,
|
||||
'total_invited': total_invited,
|
||||
'total_paid_amount': total_paid_amount,
|
||||
'total_unpaid': total_unpaid,
|
||||
'participants': participants_stats,
|
||||
}
|
||||
|
||||
def _get_timezone(self, contest: ReferralContest) -> ZoneInfo:
|
||||
tz_name = contest.timezone or settings.TIMEZONE
|
||||
try:
|
||||
|
||||
@@ -1945,8 +1945,8 @@ class RemnaWaveService:
|
||||
}
|
||||
|
||||
usage_data = await api._make_request(
|
||||
'GET',
|
||||
f'/api/nodes/usage/{node_uuid}/users/range',
|
||||
'GET',
|
||||
f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy',
|
||||
params=params
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from .routes import (
|
||||
health,
|
||||
main_menu_buttons,
|
||||
media,
|
||||
menu_layout,
|
||||
miniapp,
|
||||
partners,
|
||||
polls,
|
||||
@@ -56,6 +57,10 @@ OPENAPI_TAGS = [
|
||||
"name": "main-menu",
|
||||
"description": "Управление кнопками и сообщениями главного меню Telegram-бота.",
|
||||
},
|
||||
{
|
||||
"name": "menu-layout",
|
||||
"description": "API конструктор меню: управление расположением и настройками кнопок.",
|
||||
},
|
||||
{
|
||||
"name": "welcome-texts",
|
||||
"description": "Создание, редактирование и управление приветственными текстами.",
|
||||
@@ -192,6 +197,11 @@ def create_web_api_app() -> FastAPI:
|
||||
prefix="/main-menu/buttons",
|
||||
tags=["main-menu"],
|
||||
)
|
||||
app.include_router(
|
||||
menu_layout.router,
|
||||
prefix="/menu-layout",
|
||||
tags=["menu-layout"],
|
||||
)
|
||||
app.include_router(
|
||||
user_messages.router,
|
||||
prefix="/main-menu/messages",
|
||||
|
||||
@@ -51,11 +51,13 @@ from app.webapi.schemas.contests import (
|
||||
ContestTemplateUpdateRequest,
|
||||
ReferralContestCreateRequest,
|
||||
ReferralContestDetailResponse,
|
||||
ReferralContestDetailedStatsResponse,
|
||||
ReferralContestEventListResponse,
|
||||
ReferralContestEventResponse,
|
||||
ReferralContestEventUser,
|
||||
ReferralContestLeaderboardItem,
|
||||
ReferralContestListResponse,
|
||||
ReferralContestParticipant,
|
||||
ReferralContestResponse,
|
||||
ReferralContestUpdateRequest,
|
||||
StartRoundRequest,
|
||||
@@ -262,7 +264,7 @@ async def update_daily_template(
|
||||
if not tpl:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found")
|
||||
|
||||
update_fields = payload.dict(exclude_none=True)
|
||||
update_fields = payload.model_dump(exclude_none=True)
|
||||
if not update_fields:
|
||||
return _serialize_template(tpl)
|
||||
|
||||
@@ -549,7 +551,7 @@ async def get_referral(
|
||||
leaderboard = [_serialize_leaderboard_item(row) for row in leaderboard_rows]
|
||||
|
||||
return ReferralContestDetailResponse(
|
||||
**_serialize_referral_contest(contest).dict(),
|
||||
**_serialize_referral_contest(contest).model_dump(),
|
||||
total_events=int(total_events),
|
||||
leaderboard=leaderboard,
|
||||
)
|
||||
@@ -570,7 +572,7 @@ async def update_referral(
|
||||
if not contest:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
|
||||
|
||||
fields = payload.dict(exclude_none=True)
|
||||
fields = payload.model_dump(exclude_none=True)
|
||||
|
||||
if "start_at" in fields:
|
||||
fields["start_at"] = _to_utc_naive(fields["start_at"], fields.get("timezone") or contest.timezone)
|
||||
@@ -676,3 +678,22 @@ async def list_referral_events(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/referral/{contest_id}/detailed-stats",
|
||||
response_model=ReferralContestDetailedStatsResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def get_referral_detailed_stats(
|
||||
contest_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReferralContestDetailedStatsResponse:
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
|
||||
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
return ReferralContestDetailedStatsResponse(**stats)
|
||||
|
||||
@@ -0,0 +1,991 @@
|
||||
"""API эндпоинты для конструктора меню."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.menu_layout_service import (
|
||||
MenuContext,
|
||||
MenuLayoutService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.menu_layout import (
|
||||
AddCustomButtonRequest,
|
||||
AddRowRequest,
|
||||
AvailableCallback,
|
||||
AvailableCallbacksResponse,
|
||||
BuiltinButtonInfo,
|
||||
BuiltinButtonsListResponse,
|
||||
ButtonClickStats,
|
||||
ButtonClickStatsResponse,
|
||||
ButtonConditions,
|
||||
ButtonTypeStats,
|
||||
ButtonTypeStatsResponse,
|
||||
ButtonUpdateRequest,
|
||||
DynamicPlaceholder,
|
||||
DynamicPlaceholdersResponse,
|
||||
HourlyStats,
|
||||
HourlyStatsResponse,
|
||||
MenuButtonConfig,
|
||||
MenuClickStatsResponse,
|
||||
MenuLayoutConfig,
|
||||
MenuLayoutExportResponse,
|
||||
MenuLayoutHistoryEntry,
|
||||
MenuLayoutHistoryResponse,
|
||||
MenuLayoutImportRequest,
|
||||
MenuLayoutImportResponse,
|
||||
MenuLayoutResponse,
|
||||
MenuLayoutRollbackRequest,
|
||||
MenuLayoutUpdateRequest,
|
||||
MenuLayoutValidateRequest,
|
||||
MenuLayoutValidateResponse,
|
||||
MenuPreviewButton,
|
||||
MenuPreviewRequest,
|
||||
MenuPreviewResponse,
|
||||
MenuPreviewRow,
|
||||
MenuRowConfig,
|
||||
MoveButtonResponse,
|
||||
MoveButtonToRowRequest,
|
||||
PeriodComparisonResponse,
|
||||
ReorderButtonsInRowRequest,
|
||||
TopUserStats,
|
||||
TopUsersResponse,
|
||||
UserClickSequence,
|
||||
UserClickSequencesResponse,
|
||||
WeekdayStats,
|
||||
WeekdayStatsResponse,
|
||||
ReorderButtonsResponse,
|
||||
RowsReorderRequest,
|
||||
SwapButtonsRequest,
|
||||
SwapButtonsResponse,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_config(config: dict, is_enabled: bool, updated_at) -> MenuLayoutResponse:
|
||||
"""Сериализовать конфигурацию в response."""
|
||||
rows = []
|
||||
for row_data in config.get("rows", []):
|
||||
rows.append(
|
||||
MenuRowConfig(
|
||||
id=row_data["id"],
|
||||
buttons=row_data.get("buttons", []),
|
||||
conditions=ButtonConditions(**row_data["conditions"])
|
||||
if row_data.get("conditions")
|
||||
else None,
|
||||
max_per_row=row_data.get("max_per_row", 2),
|
||||
)
|
||||
)
|
||||
|
||||
buttons = {}
|
||||
for btn_id, btn_data in config.get("buttons", {}).items():
|
||||
buttons[btn_id] = MenuButtonConfig(
|
||||
type=btn_data["type"],
|
||||
builtin_id=btn_data.get("builtin_id"),
|
||||
text=btn_data.get("text", {}),
|
||||
icon=btn_data.get("icon"),
|
||||
action=btn_data.get("action", ""),
|
||||
enabled=btn_data.get("enabled", True),
|
||||
visibility=btn_data.get("visibility", "all"),
|
||||
conditions=ButtonConditions(**btn_data["conditions"])
|
||||
if btn_data.get("conditions")
|
||||
else None,
|
||||
dynamic_text=btn_data.get("dynamic_text", False),
|
||||
open_mode=btn_data.get("open_mode", "callback"),
|
||||
webapp_url=btn_data.get("webapp_url"),
|
||||
description=btn_data.get("description"),
|
||||
sort_order=btn_data.get("sort_order"),
|
||||
)
|
||||
|
||||
return MenuLayoutResponse(
|
||||
version=config.get("version", 1),
|
||||
rows=rows,
|
||||
buttons=buttons,
|
||||
is_enabled=is_enabled,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=MenuLayoutResponse)
|
||||
async def get_menu_layout(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutResponse:
|
||||
"""Получить текущую конфигурацию меню."""
|
||||
config = await MenuLayoutService.get_config(db)
|
||||
updated_at = await MenuLayoutService.get_config_updated_at(db)
|
||||
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
|
||||
|
||||
|
||||
@router.put("", response_model=MenuLayoutResponse)
|
||||
async def update_menu_layout(
|
||||
payload: MenuLayoutUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutResponse:
|
||||
"""Обновить конфигурацию меню полностью."""
|
||||
config = await MenuLayoutService.get_config(db)
|
||||
config = config.copy()
|
||||
|
||||
if payload.rows is not None:
|
||||
config["rows"] = [row.model_dump() for row in payload.rows]
|
||||
|
||||
if payload.buttons is not None:
|
||||
buttons_config = {}
|
||||
for btn_id, btn in payload.buttons.items():
|
||||
btn_dict = btn.model_dump()
|
||||
# Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен
|
||||
if not btn_dict.get("dynamic_text", False):
|
||||
from app.services.menu_layout.service import MenuLayoutService
|
||||
btn_dict["dynamic_text"] = MenuLayoutService._text_has_placeholders(btn_dict.get("text", {}))
|
||||
buttons_config[btn_id] = btn_dict
|
||||
config["buttons"] = buttons_config
|
||||
|
||||
await MenuLayoutService.save_config(db, config)
|
||||
updated_at = await MenuLayoutService.get_config_updated_at(db)
|
||||
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
|
||||
|
||||
|
||||
@router.post("/reset", response_model=MenuLayoutResponse)
|
||||
async def reset_menu_layout(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutResponse:
|
||||
"""Сбросить конфигурацию к дефолтной."""
|
||||
config = await MenuLayoutService.reset_to_default(db)
|
||||
updated_at = await MenuLayoutService.get_config_updated_at(db)
|
||||
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
|
||||
|
||||
|
||||
@router.get("/builtin-buttons", response_model=BuiltinButtonsListResponse)
|
||||
async def list_builtin_buttons(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> BuiltinButtonsListResponse:
|
||||
"""Получить список встроенных кнопок."""
|
||||
items = []
|
||||
for btn_info in MenuLayoutService.get_builtin_buttons_info():
|
||||
items.append(
|
||||
BuiltinButtonInfo(
|
||||
id=btn_info["id"],
|
||||
default_text=btn_info["default_text"],
|
||||
callback_data=btn_info["callback_data"],
|
||||
default_conditions=ButtonConditions(**btn_info["default_conditions"])
|
||||
if btn_info.get("default_conditions")
|
||||
else None,
|
||||
supports_dynamic_text=btn_info.get("supports_dynamic_text", False),
|
||||
supports_direct_open=btn_info.get("supports_direct_open", False),
|
||||
)
|
||||
)
|
||||
|
||||
return BuiltinButtonsListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.patch("/buttons/{button_id}")
|
||||
async def update_button(
|
||||
button_id: str,
|
||||
payload: ButtonUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuButtonConfig:
|
||||
"""Обновить конфигурацию отдельной кнопки."""
|
||||
try:
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
# Конвертируем visibility в строку если есть
|
||||
if "visibility" in updates and updates["visibility"] is not None:
|
||||
if hasattr(updates["visibility"], "value"):
|
||||
updates["visibility"] = updates["visibility"].value
|
||||
# Конвертируем open_mode в строку если есть
|
||||
if "open_mode" in updates and updates["open_mode"] is not None:
|
||||
if hasattr(updates["open_mode"], "value"):
|
||||
updates["open_mode"] = updates["open_mode"].value
|
||||
# Конвертируем conditions - убираем None значения если это dict
|
||||
if "conditions" in updates and updates["conditions"] is not None:
|
||||
if isinstance(updates["conditions"], dict):
|
||||
updates["conditions"] = {k: v for k, v in updates["conditions"].items() if v is not None}
|
||||
elif hasattr(updates["conditions"], "model_dump"):
|
||||
updates["conditions"] = updates["conditions"].model_dump(exclude_none=True)
|
||||
|
||||
button = await MenuLayoutService.update_button(db, button_id, updates)
|
||||
|
||||
return MenuButtonConfig(
|
||||
type=button["type"],
|
||||
builtin_id=button.get("builtin_id"),
|
||||
text=button.get("text", {}),
|
||||
icon=button.get("icon"),
|
||||
action=button.get("action", ""),
|
||||
enabled=button.get("enabled", True),
|
||||
visibility=button.get("visibility", "all"),
|
||||
conditions=ButtonConditions(**button["conditions"])
|
||||
if button.get("conditions")
|
||||
else None,
|
||||
dynamic_text=button.get("dynamic_text", False),
|
||||
open_mode=button.get("open_mode", "callback"),
|
||||
webapp_url=button.get("webapp_url"),
|
||||
description=button.get("description"),
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/rows/reorder", response_model=List[MenuRowConfig])
|
||||
async def reorder_rows(
|
||||
payload: RowsReorderRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> List[MenuRowConfig]:
|
||||
"""Изменить порядок строк."""
|
||||
try:
|
||||
rows = await MenuLayoutService.reorder_rows(db, payload.ordered_ids)
|
||||
return [
|
||||
MenuRowConfig(
|
||||
id=row["id"],
|
||||
buttons=row.get("buttons", []),
|
||||
conditions=ButtonConditions(**row["conditions"])
|
||||
if row.get("conditions")
|
||||
else None,
|
||||
max_per_row=row.get("max_per_row", 2),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/rows", response_model=MenuRowConfig, status_code=status.HTTP_201_CREATED)
|
||||
async def add_row(
|
||||
payload: AddRowRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuRowConfig:
|
||||
"""Добавить новую строку."""
|
||||
try:
|
||||
row_config = {
|
||||
"id": payload.id,
|
||||
"buttons": payload.buttons,
|
||||
"conditions": payload.conditions.model_dump(exclude_none=True)
|
||||
if payload.conditions
|
||||
else None,
|
||||
"max_per_row": payload.max_per_row,
|
||||
}
|
||||
row = await MenuLayoutService.add_row(db, row_config, payload.position)
|
||||
|
||||
return MenuRowConfig(
|
||||
id=row["id"],
|
||||
buttons=row.get("buttons", []),
|
||||
conditions=ButtonConditions(**row["conditions"])
|
||||
if row.get("conditions")
|
||||
else None,
|
||||
max_per_row=row.get("max_per_row", 2),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/rows/{row_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
async def delete_row(
|
||||
row_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
"""Удалить строку."""
|
||||
try:
|
||||
await MenuLayoutService.delete_row(db, row_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
|
||||
|
||||
@router.post(
|
||||
"/buttons", response_model=MenuButtonConfig, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def add_custom_button(
|
||||
payload: AddCustomButtonRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuButtonConfig:
|
||||
"""Добавить кастомную кнопку (URL, MiniApp или callback)."""
|
||||
try:
|
||||
# Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен
|
||||
dynamic_text = payload.dynamic_text
|
||||
if not dynamic_text:
|
||||
from app.services.menu_layout.service import MenuLayoutService
|
||||
dynamic_text = MenuLayoutService._text_has_placeholders(payload.text)
|
||||
|
||||
button_config = {
|
||||
"type": payload.type.value,
|
||||
"text": payload.text,
|
||||
"icon": payload.icon,
|
||||
"action": payload.action,
|
||||
"visibility": payload.visibility.value,
|
||||
"conditions": payload.conditions.model_dump(exclude_none=True)
|
||||
if payload.conditions
|
||||
else None,
|
||||
"dynamic_text": dynamic_text,
|
||||
"description": payload.description,
|
||||
}
|
||||
button = await MenuLayoutService.add_custom_button(
|
||||
db, payload.id, button_config, payload.row_id
|
||||
)
|
||||
|
||||
return MenuButtonConfig(
|
||||
type=button["type"],
|
||||
builtin_id=button.get("builtin_id"),
|
||||
text=button.get("text", {}),
|
||||
icon=button.get("icon"),
|
||||
action=button.get("action", ""),
|
||||
enabled=button.get("enabled", True),
|
||||
visibility=button.get("visibility", "all"),
|
||||
conditions=ButtonConditions(**button["conditions"])
|
||||
if button.get("conditions")
|
||||
else None,
|
||||
dynamic_text=button.get("dynamic_text", False),
|
||||
open_mode=button.get("open_mode", "callback"),
|
||||
webapp_url=button.get("webapp_url"),
|
||||
description=button.get("description"),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/buttons/{button_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
async def delete_custom_button(
|
||||
button_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
"""Удалить кастомную кнопку."""
|
||||
try:
|
||||
await MenuLayoutService.delete_custom_button(db, button_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/preview", response_model=MenuPreviewResponse)
|
||||
async def preview_menu(
|
||||
payload: MenuPreviewRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuPreviewResponse:
|
||||
"""Предпросмотр меню для указанного контекста пользователя."""
|
||||
context = MenuContext(
|
||||
language=payload.language,
|
||||
is_admin=payload.is_admin,
|
||||
is_moderator=payload.is_moderator,
|
||||
has_active_subscription=payload.has_active_subscription,
|
||||
subscription_is_active=payload.subscription_is_active,
|
||||
balance_kopeks=payload.balance_kopeks,
|
||||
)
|
||||
|
||||
preview_rows = await MenuLayoutService.preview_keyboard(db, context)
|
||||
|
||||
rows = []
|
||||
total_buttons = 0
|
||||
for row_data in preview_rows:
|
||||
buttons = [
|
||||
MenuPreviewButton(
|
||||
text=btn["text"],
|
||||
action=btn["action"],
|
||||
type=btn["type"],
|
||||
)
|
||||
for btn in row_data["buttons"]
|
||||
]
|
||||
total_buttons += len(buttons)
|
||||
rows.append(MenuPreviewRow(buttons=buttons))
|
||||
|
||||
return MenuPreviewResponse(rows=rows, total_buttons=total_buttons)
|
||||
|
||||
|
||||
# --- Эндпоинты для перемещения кнопок ---
|
||||
|
||||
|
||||
@router.post("/buttons/{button_id}/move-up", response_model=MoveButtonResponse)
|
||||
async def move_button_up(
|
||||
button_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MoveButtonResponse:
|
||||
"""Переместить кнопку вверх (в предыдущую строку или на позицию выше в текущей строке)."""
|
||||
try:
|
||||
result = await MenuLayoutService.move_button_up(db, button_id)
|
||||
return MoveButtonResponse(
|
||||
button_id=button_id,
|
||||
new_row_index=result.get("new_row_index"),
|
||||
position=result.get("new_position"),
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/buttons/{button_id}/move-down", response_model=MoveButtonResponse)
|
||||
async def move_button_down(
|
||||
button_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MoveButtonResponse:
|
||||
"""Переместить кнопку вниз (в следующую строку или на позицию ниже в текущей строке)."""
|
||||
try:
|
||||
result = await MenuLayoutService.move_button_down(db, button_id)
|
||||
return MoveButtonResponse(
|
||||
button_id=button_id,
|
||||
new_row_index=result.get("new_row_index"),
|
||||
position=result.get("new_position"),
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/buttons/{button_id}/move-to-row", response_model=MoveButtonResponse)
|
||||
async def move_button_to_row(
|
||||
button_id: str,
|
||||
payload: MoveButtonToRowRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MoveButtonResponse:
|
||||
"""Переместить кнопку в указанную строку."""
|
||||
try:
|
||||
result = await MenuLayoutService.move_button_to_row(
|
||||
db, button_id, payload.target_row_id, payload.position
|
||||
)
|
||||
return MoveButtonResponse(
|
||||
button_id=button_id,
|
||||
target_row_id=payload.target_row_id,
|
||||
position=result.get("new_position"),
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/rows/{row_id}/reorder-buttons", response_model=ReorderButtonsResponse)
|
||||
async def reorder_buttons_in_row(
|
||||
row_id: str,
|
||||
payload: ReorderButtonsInRowRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReorderButtonsResponse:
|
||||
"""Изменить порядок кнопок в строке."""
|
||||
try:
|
||||
result = await MenuLayoutService.reorder_buttons_in_row(
|
||||
db, row_id, payload.ordered_button_ids
|
||||
)
|
||||
return ReorderButtonsResponse(
|
||||
row_id=row_id,
|
||||
buttons=result["buttons"],
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/buttons/swap", response_model=SwapButtonsResponse)
|
||||
async def swap_buttons(
|
||||
payload: SwapButtonsRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SwapButtonsResponse:
|
||||
"""Обменять местами две кнопки (даже из разных строк)."""
|
||||
try:
|
||||
result = await MenuLayoutService.swap_buttons(
|
||||
db, payload.button_id_1, payload.button_id_2
|
||||
)
|
||||
return SwapButtonsResponse(
|
||||
button_1=result["button_1"],
|
||||
button_2=result["button_2"],
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
# --- Новые эндпоинты ---
|
||||
|
||||
|
||||
@router.get("/available-callbacks", response_model=AvailableCallbacksResponse)
|
||||
async def list_available_callbacks(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> AvailableCallbacksResponse:
|
||||
"""Получить список всех доступных callback_data для создания кнопок."""
|
||||
callbacks = await MenuLayoutService.get_available_callbacks(db)
|
||||
|
||||
items = [
|
||||
AvailableCallback(
|
||||
callback_data=cb["callback_data"],
|
||||
name=cb["name"],
|
||||
description=cb.get("description"),
|
||||
category=cb["category"],
|
||||
default_text=cb.get("default_text"),
|
||||
default_icon=cb.get("default_icon"),
|
||||
requires_subscription=cb.get("requires_subscription", False),
|
||||
is_in_menu=cb.get("is_in_menu", False),
|
||||
)
|
||||
for cb in callbacks
|
||||
]
|
||||
|
||||
categories = list(set(cb["category"] for cb in callbacks))
|
||||
|
||||
return AvailableCallbacksResponse(
|
||||
items=items,
|
||||
total=len(items),
|
||||
categories=sorted(categories),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/placeholders", response_model=DynamicPlaceholdersResponse)
|
||||
async def list_dynamic_placeholders(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> DynamicPlaceholdersResponse:
|
||||
"""Получить список доступных динамических плейсхолдеров для текста кнопок."""
|
||||
placeholders = MenuLayoutService.get_dynamic_placeholders()
|
||||
|
||||
items = [
|
||||
DynamicPlaceholder(
|
||||
placeholder=p["placeholder"],
|
||||
description=p["description"],
|
||||
example=p["example"],
|
||||
category=p["category"],
|
||||
)
|
||||
for p in placeholders
|
||||
]
|
||||
|
||||
return DynamicPlaceholdersResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/export", response_model=MenuLayoutExportResponse)
|
||||
async def export_menu_layout(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutExportResponse:
|
||||
"""Экспортировать конфигурацию меню."""
|
||||
from datetime import datetime
|
||||
|
||||
export_data = await MenuLayoutService.export_config(db)
|
||||
|
||||
rows = []
|
||||
for row_data in export_data.get("rows", []):
|
||||
rows.append(
|
||||
MenuRowConfig(
|
||||
id=row_data["id"],
|
||||
buttons=row_data.get("buttons", []),
|
||||
conditions=ButtonConditions(**row_data["conditions"])
|
||||
if row_data.get("conditions")
|
||||
else None,
|
||||
max_per_row=row_data.get("max_per_row", 2),
|
||||
)
|
||||
)
|
||||
|
||||
buttons = {}
|
||||
for btn_id, btn_data in export_data.get("buttons", {}).items():
|
||||
buttons[btn_id] = MenuButtonConfig(
|
||||
type=btn_data["type"],
|
||||
builtin_id=btn_data.get("builtin_id"),
|
||||
text=btn_data.get("text", {}),
|
||||
icon=btn_data.get("icon"),
|
||||
action=btn_data.get("action", ""),
|
||||
enabled=btn_data.get("enabled", True),
|
||||
visibility=btn_data.get("visibility", "all"),
|
||||
conditions=ButtonConditions(**btn_data["conditions"])
|
||||
if btn_data.get("conditions")
|
||||
else None,
|
||||
dynamic_text=btn_data.get("dynamic_text", False),
|
||||
open_mode=btn_data.get("open_mode", "callback"),
|
||||
webapp_url=btn_data.get("webapp_url"),
|
||||
description=btn_data.get("description"),
|
||||
)
|
||||
|
||||
return MenuLayoutExportResponse(
|
||||
version=export_data.get("version", 1),
|
||||
rows=rows,
|
||||
buttons=buttons,
|
||||
exported_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import", response_model=MenuLayoutImportResponse)
|
||||
async def import_menu_layout(
|
||||
payload: MenuLayoutImportRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutImportResponse:
|
||||
"""Импортировать конфигурацию меню."""
|
||||
import_data = {
|
||||
"version": payload.version,
|
||||
"rows": [row.model_dump() for row in payload.rows],
|
||||
"buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()},
|
||||
}
|
||||
|
||||
result = await MenuLayoutService.import_config(db, import_data, payload.merge_mode)
|
||||
|
||||
return MenuLayoutImportResponse(
|
||||
success=result["success"],
|
||||
imported_rows=result["imported_rows"],
|
||||
imported_buttons=result["imported_buttons"],
|
||||
warnings=result["warnings"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/validate", response_model=MenuLayoutValidateResponse)
|
||||
async def validate_menu_layout(
|
||||
payload: MenuLayoutValidateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutValidateResponse:
|
||||
"""Валидировать конфигурацию меню без сохранения."""
|
||||
# Если данные не переданы, валидируем текущую конфигурацию
|
||||
if payload.rows is None and payload.buttons is None:
|
||||
config = await MenuLayoutService.get_config(db)
|
||||
else:
|
||||
config = {
|
||||
"rows": [row.model_dump() for row in payload.rows] if payload.rows else [],
|
||||
"buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()}
|
||||
if payload.buttons
|
||||
else {},
|
||||
}
|
||||
|
||||
result = MenuLayoutService.validate_config(config)
|
||||
|
||||
return MenuLayoutValidateResponse(
|
||||
is_valid=result["is_valid"],
|
||||
errors=[
|
||||
ValidationError(
|
||||
field=e["field"],
|
||||
message=e["message"],
|
||||
severity=e["severity"],
|
||||
)
|
||||
for e in result["errors"]
|
||||
],
|
||||
warnings=[
|
||||
ValidationError(
|
||||
field=w["field"],
|
||||
message=w["message"],
|
||||
severity=w["severity"],
|
||||
)
|
||||
for w in result["warnings"]
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# --- Эндпоинты истории изменений ---
|
||||
|
||||
|
||||
@router.get("/history", response_model=MenuLayoutHistoryResponse)
|
||||
async def get_menu_layout_history(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutHistoryResponse:
|
||||
"""Получить историю изменений меню."""
|
||||
entries = await MenuLayoutService.get_history(db, limit, offset)
|
||||
total = await MenuLayoutService.get_history_count(db)
|
||||
|
||||
return MenuLayoutHistoryResponse(
|
||||
items=[
|
||||
MenuLayoutHistoryEntry(
|
||||
id=entry["id"],
|
||||
created_at=entry["created_at"],
|
||||
action=entry["action"],
|
||||
changes_summary=entry["changes_summary"] or "",
|
||||
user_info=entry["user_info"],
|
||||
)
|
||||
for entry in entries
|
||||
],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history/{history_id}")
|
||||
async def get_history_entry(
|
||||
history_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Получить конкретную запись истории с полной конфигурацией."""
|
||||
entry = await MenuLayoutService.get_history_entry(db, history_id)
|
||||
if not entry:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, f"History entry {history_id} not found")
|
||||
|
||||
return {
|
||||
"id": entry["id"],
|
||||
"action": entry["action"],
|
||||
"changes_summary": entry["changes_summary"],
|
||||
"user_info": entry["user_info"],
|
||||
"created_at": entry["created_at"].isoformat() if entry["created_at"] else None,
|
||||
"config": entry["config"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/history/{history_id}/rollback", response_model=MenuLayoutResponse)
|
||||
async def rollback_to_history(
|
||||
history_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutResponse:
|
||||
"""Откатить конфигурацию к записи из истории."""
|
||||
try:
|
||||
config = await MenuLayoutService.rollback_to_history(db, history_id)
|
||||
updated_at = await MenuLayoutService.get_config_updated_at(db)
|
||||
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
|
||||
|
||||
# --- Эндпоинты статистики кликов ---
|
||||
|
||||
|
||||
@router.get("/stats", response_model=MenuClickStatsResponse)
|
||||
async def get_menu_click_stats(
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuClickStatsResponse:
|
||||
"""Получить общую статистику кликов по всем кнопкам."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
stats = await MenuLayoutService.get_all_buttons_stats(db, days)
|
||||
total_clicks = await MenuLayoutService.get_total_clicks(db, days)
|
||||
|
||||
now = datetime.utcnow()
|
||||
period_start = now - timedelta(days=days)
|
||||
|
||||
return MenuClickStatsResponse(
|
||||
items=[
|
||||
ButtonClickStats(
|
||||
button_id=s["button_id"],
|
||||
clicks_total=s["clicks_total"],
|
||||
clicks_today=s.get("clicks_today", 0),
|
||||
clicks_week=s.get("clicks_week", 0),
|
||||
clicks_month=s.get("clicks_month", 0),
|
||||
unique_users=s["unique_users"],
|
||||
last_click_at=s["last_click_at"],
|
||||
)
|
||||
for s in stats
|
||||
],
|
||||
total_clicks=total_clicks,
|
||||
period_start=period_start,
|
||||
period_end=now,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/buttons/{button_id}", response_model=ButtonClickStatsResponse)
|
||||
async def get_button_click_stats(
|
||||
button_id: str,
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ButtonClickStatsResponse:
|
||||
"""Получить статистику кликов по конкретной кнопке."""
|
||||
stats = await MenuLayoutService.get_button_stats(db, button_id, days)
|
||||
clicks_by_day = await MenuLayoutService.get_button_clicks_by_day(db, button_id, days)
|
||||
|
||||
return ButtonClickStatsResponse(
|
||||
button_id=button_id,
|
||||
stats=ButtonClickStats(
|
||||
button_id=stats["button_id"],
|
||||
clicks_total=stats["clicks_total"],
|
||||
clicks_today=stats["clicks_today"],
|
||||
clicks_week=stats["clicks_week"],
|
||||
clicks_month=stats["clicks_month"],
|
||||
unique_users=stats["unique_users"],
|
||||
last_click_at=stats["last_click_at"],
|
||||
),
|
||||
clicks_by_day=clicks_by_day,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stats/log-click")
|
||||
async def log_button_click(
|
||||
button_id: str,
|
||||
user_id: Optional[int] = None,
|
||||
callback_data: Optional[str] = None,
|
||||
button_type: Optional[str] = None,
|
||||
button_text: Optional[str] = None,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Записать клик по кнопке (для внешней интеграции)."""
|
||||
await MenuLayoutService.log_button_click(
|
||||
db,
|
||||
button_id=button_id,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text,
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/stats/by-type", response_model=ButtonTypeStatsResponse)
|
||||
async def get_stats_by_button_type(
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ButtonTypeStatsResponse:
|
||||
"""Получить статистику кликов по типам кнопок (builtin, callback, url, mini_app)."""
|
||||
try:
|
||||
stats = await MenuLayoutService.get_stats_by_button_type(db, days)
|
||||
total_clicks = sum(s["clicks_total"] for s in stats)
|
||||
|
||||
return ButtonTypeStatsResponse(
|
||||
items=[
|
||||
ButtonTypeStats(
|
||||
button_type=s["button_type"],
|
||||
clicks_total=s["clicks_total"],
|
||||
unique_users=s["unique_users"],
|
||||
)
|
||||
for s in stats
|
||||
],
|
||||
total_clicks=total_clicks,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stats by type: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats/by-hour", response_model=HourlyStatsResponse)
|
||||
async def get_clicks_by_hour(
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> HourlyStatsResponse:
|
||||
"""Получить статистику кликов по часам дня (0-23)."""
|
||||
stats = await MenuLayoutService.get_clicks_by_hour(db, button_id, days)
|
||||
|
||||
return HourlyStatsResponse(
|
||||
items=[
|
||||
HourlyStats(hour=s["hour"], count=s["count"])
|
||||
for s in stats
|
||||
],
|
||||
button_id=button_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/by-weekday", response_model=WeekdayStatsResponse)
|
||||
async def get_clicks_by_weekday(
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> WeekdayStatsResponse:
|
||||
"""Получить статистику кликов по дням недели."""
|
||||
stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id, days)
|
||||
|
||||
return WeekdayStatsResponse(
|
||||
items=[
|
||||
WeekdayStats(
|
||||
weekday=s["weekday"],
|
||||
weekday_name=s["weekday_name"],
|
||||
count=s["count"]
|
||||
)
|
||||
for s in stats
|
||||
],
|
||||
button_id=button_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/top-users", response_model=TopUsersResponse)
|
||||
async def get_top_users(
|
||||
button_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TopUsersResponse:
|
||||
"""Получить топ пользователей по количеству кликов."""
|
||||
try:
|
||||
stats = await MenuLayoutService.get_top_users(db, button_id, limit, days)
|
||||
|
||||
return TopUsersResponse(
|
||||
items=[
|
||||
TopUserStats(
|
||||
user_id=s["user_id"],
|
||||
clicks_count=s["clicks_count"],
|
||||
last_click_at=s["last_click_at"],
|
||||
)
|
||||
for s in stats
|
||||
],
|
||||
button_id=button_id,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting top users: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats/compare", response_model=PeriodComparisonResponse)
|
||||
async def get_period_comparison(
|
||||
button_id: Optional[str] = None,
|
||||
current_days: int = 7,
|
||||
previous_days: int = 7,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PeriodComparisonResponse:
|
||||
"""Сравнить статистику текущего и предыдущего периода."""
|
||||
try:
|
||||
comparison = await MenuLayoutService.get_period_comparison(
|
||||
db, button_id, current_days, previous_days
|
||||
)
|
||||
|
||||
logger.debug(f"Period comparison: button_id={button_id}, current_days={current_days}, previous_days={previous_days}, trend={comparison.get('change', {}).get('trend')}")
|
||||
|
||||
return PeriodComparisonResponse(
|
||||
current_period=comparison["current_period"],
|
||||
previous_period=comparison["previous_period"],
|
||||
change=comparison["change"],
|
||||
button_id=button_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting period comparison: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats/users/{user_id}/sequences", response_model=UserClickSequencesResponse)
|
||||
async def get_user_click_sequences(
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserClickSequencesResponse:
|
||||
"""Получить последовательности кликов пользователя."""
|
||||
try:
|
||||
sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit)
|
||||
|
||||
logger.debug(f"User sequences: user_id={user_id}, limit={limit}, found={len(sequences)} sequences")
|
||||
|
||||
return UserClickSequencesResponse(
|
||||
user_id=user_id,
|
||||
items=[
|
||||
UserClickSequence(
|
||||
button_id=s["button_id"],
|
||||
button_text=s["button_text"],
|
||||
clicked_at=s["clicked_at"],
|
||||
)
|
||||
for s in sequences
|
||||
],
|
||||
total=len(sequences),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user sequences: user_id={user_id}, error={e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
@@ -14,6 +15,7 @@ from app.database.crud.user import (
|
||||
update_user,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
from app.utils.user_utils import (
|
||||
get_detailed_referral_list,
|
||||
get_effective_referral_commission_percent,
|
||||
@@ -21,14 +23,34 @@ from app.utils.user_utils import (
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.partners import (
|
||||
ChangeData,
|
||||
DailyStats,
|
||||
DailyStatsResponse,
|
||||
EarningsByPeriod,
|
||||
GlobalPartnerStats,
|
||||
GlobalPartnerSummary,
|
||||
NewReferralsByPeriod,
|
||||
PartnerReferralCommissionUpdate,
|
||||
PartnerReferralItem,
|
||||
PartnerReferralList,
|
||||
PartnerReferralCommissionUpdate,
|
||||
PartnerReferrerDetail,
|
||||
PartnerReferrerItem,
|
||||
PartnerReferrerListResponse,
|
||||
PayoutsByPeriod,
|
||||
PeriodChange,
|
||||
PeriodComparisonResponse,
|
||||
PeriodData,
|
||||
ReferralsCountByPeriod,
|
||||
ReferrerDetailedStats,
|
||||
ReferrerSummary,
|
||||
TopReferralItem,
|
||||
TopReferralsResponse,
|
||||
TopReferrerItem,
|
||||
TopReferrersResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -197,3 +219,158 @@ async def update_referrer_commission(
|
||||
|
||||
stats = await get_user_referral_stats(db, user.id)
|
||||
return _serialize_referrer(user, stats)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# РАСШИРЕННАЯ СТАТИСТИКА ПАРТНЁРОВ
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/stats", response_model=GlobalPartnerStats)
|
||||
async def get_global_partner_stats(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> GlobalPartnerStats:
|
||||
"""Глобальная статистика партнёрской программы."""
|
||||
data = await PartnerStatsService.get_global_partner_stats(db, days)
|
||||
|
||||
return GlobalPartnerStats(
|
||||
summary=GlobalPartnerSummary(**data["summary"]),
|
||||
payouts=PayoutsByPeriod(**data["payouts"]),
|
||||
new_referrals=NewReferralsByPeriod(**data["new_referrals"]),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/daily", response_model=DailyStatsResponse)
|
||||
async def get_global_daily_stats(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> DailyStatsResponse:
|
||||
"""Глобальная статистика по дням."""
|
||||
data = await PartnerStatsService.get_global_daily_stats(db, days)
|
||||
|
||||
return DailyStatsResponse(
|
||||
items=[DailyStats(**item) for item in data],
|
||||
days=days,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/top-referrers", response_model=TopReferrersResponse)
|
||||
async def get_top_referrers(
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
days: Optional[int] = Query(None, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TopReferrersResponse:
|
||||
"""Топ рефереров по заработку."""
|
||||
data = await PartnerStatsService.get_top_referrers(db, limit, days)
|
||||
|
||||
return TopReferrersResponse(
|
||||
items=[TopReferrerItem(**item) for item in data],
|
||||
days=days,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats", response_model=ReferrerDetailedStats)
|
||||
async def get_referrer_detailed_stats(
|
||||
user_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReferrerDetailedStats:
|
||||
"""Детальная статистика реферера."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_detailed_stats(db, user.id)
|
||||
|
||||
return ReferrerDetailedStats(
|
||||
user_id=data["user_id"],
|
||||
summary=ReferrerSummary(**data["summary"]),
|
||||
earnings=EarningsByPeriod(**data["earnings"]),
|
||||
referrals_count=ReferralsCountByPeriod(**data["referrals_count"]),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats/daily", response_model=DailyStatsResponse)
|
||||
async def get_referrer_daily_stats(
|
||||
user_id: int,
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> DailyStatsResponse:
|
||||
"""Статистика реферера по дням."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_daily_stats(db, user.id, days)
|
||||
|
||||
return DailyStatsResponse(
|
||||
items=[DailyStats(**item) for item in data],
|
||||
days=days,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats/top-referrals", response_model=TopReferralsResponse)
|
||||
async def get_referrer_top_referrals(
|
||||
user_id: int,
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TopReferralsResponse:
|
||||
"""Топ рефералов реферера по принесённому доходу."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_top_referrals(db, user.id, limit)
|
||||
|
||||
return TopReferralsResponse(
|
||||
items=[TopReferralItem(**item) for item in data],
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats/compare", response_model=PeriodComparisonResponse)
|
||||
async def get_referrer_period_comparison(
|
||||
user_id: int,
|
||||
current_days: int = Query(7, ge=1, le=365),
|
||||
previous_days: int = Query(7, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PeriodComparisonResponse:
|
||||
"""Сравнение периодов для реферера."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_period_comparison(
|
||||
db, user.id, current_days, previous_days
|
||||
)
|
||||
|
||||
return PeriodComparisonResponse(
|
||||
current_period=PeriodData(**data["current_period"]),
|
||||
previous_period=PeriodData(**data["previous_period"]),
|
||||
change=PeriodChange(
|
||||
referrals=ChangeData(**data["change"]["referrals"]),
|
||||
earnings=ChangeData(**data["change"]["earnings"]),
|
||||
),
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
@@ -192,3 +192,20 @@ class ReferralContestEventListResponse(BaseModel):
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ReferralContestParticipant(BaseModel):
|
||||
referrer_id: int
|
||||
full_name: str
|
||||
total_referrals: int
|
||||
paid_referrals: int
|
||||
unpaid_referrals: int
|
||||
total_paid_amount: int
|
||||
|
||||
|
||||
class ReferralContestDetailedStatsResponse(BaseModel):
|
||||
total_participants: int
|
||||
total_invited: int
|
||||
total_paid_amount: int
|
||||
total_unpaid: int
|
||||
participants: List[ReferralContestParticipant]
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
"""Pydantic схемы для API конструктора меню."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ButtonType(str, Enum):
|
||||
"""Тип кнопки меню."""
|
||||
|
||||
BUILTIN = "builtin" # Встроенная кнопка с callback_data
|
||||
URL = "url" # Внешняя ссылка
|
||||
MINI_APP = "mini_app" # Telegram Mini App
|
||||
CALLBACK = "callback" # Кастомная кнопка с любым callback_data
|
||||
|
||||
|
||||
class ButtonVisibility(str, Enum):
|
||||
"""Видимость кнопки."""
|
||||
|
||||
ALL = "all" # Видна всем
|
||||
ADMINS = "admins" # Только админам
|
||||
MODERATORS = "moderators" # Только модераторам
|
||||
SUBSCRIBERS = "subscribers" # Только подписчикам
|
||||
|
||||
|
||||
class ButtonOpenMode(str, Enum):
|
||||
"""Режим открытия кнопки."""
|
||||
|
||||
CALLBACK = "callback" # Отправляет callback_data боту (по умолчанию)
|
||||
DIRECT = "direct" # Сразу открывает Mini App через WebAppInfo
|
||||
|
||||
|
||||
class ButtonConditions(BaseModel):
|
||||
"""Условия показа кнопки."""
|
||||
|
||||
# Существующие условия
|
||||
has_active_subscription: Optional[bool] = Field(
|
||||
default=None, description="Требуется активная подписка"
|
||||
)
|
||||
subscription_is_active: Optional[bool] = Field(
|
||||
default=None, description="Подписка должна быть активна (не приостановлена)"
|
||||
)
|
||||
has_traffic_limit: Optional[bool] = Field(
|
||||
default=None, description="Подписка с лимитом трафика"
|
||||
)
|
||||
is_admin: Optional[bool] = Field(default=None, description="Пользователь - админ")
|
||||
is_moderator: Optional[bool] = Field(
|
||||
default=None, description="Пользователь - модератор"
|
||||
)
|
||||
referral_enabled: Optional[bool] = Field(
|
||||
default=None, description="Реферальная программа включена"
|
||||
)
|
||||
contests_visible: Optional[bool] = Field(
|
||||
default=None, description="Конкурсы видимы"
|
||||
)
|
||||
support_enabled: Optional[bool] = Field(
|
||||
default=None, description="Поддержка включена"
|
||||
)
|
||||
language_selection_enabled: Optional[bool] = Field(
|
||||
default=None, description="Выбор языка включен"
|
||||
)
|
||||
happ_enabled: Optional[bool] = Field(
|
||||
default=None, description="Кнопка Happ включена"
|
||||
)
|
||||
simple_subscription_enabled: Optional[bool] = Field(
|
||||
default=None, description="Простая подписка включена"
|
||||
)
|
||||
show_trial: Optional[bool] = Field(
|
||||
default=None, description="Показать пробный период"
|
||||
)
|
||||
show_buy: Optional[bool] = Field(
|
||||
default=None, description="Показать кнопку покупки"
|
||||
)
|
||||
has_saved_cart: Optional[bool] = Field(
|
||||
default=None, description="Есть сохраненная корзина"
|
||||
)
|
||||
|
||||
# Расширенные условия
|
||||
min_balance_kopeks: Optional[int] = Field(
|
||||
default=None, ge=0, description="Минимальный баланс в копейках"
|
||||
)
|
||||
max_balance_kopeks: Optional[int] = Field(
|
||||
default=None, ge=0, description="Максимальный баланс в копейках"
|
||||
)
|
||||
min_registration_days: Optional[int] = Field(
|
||||
default=None, ge=0, description="Минимум дней с регистрации"
|
||||
)
|
||||
max_registration_days: Optional[int] = Field(
|
||||
default=None, ge=0, description="Максимум дней с регистрации"
|
||||
)
|
||||
min_referrals: Optional[int] = Field(
|
||||
default=None, ge=0, description="Минимальное количество рефералов"
|
||||
)
|
||||
has_referrals: Optional[bool] = Field(
|
||||
default=None, description="Есть рефералы"
|
||||
)
|
||||
promo_group_ids: Optional[List[str]] = Field(
|
||||
default=None, description="Список ID промо-групп (пользователь должен быть в одной из них)"
|
||||
)
|
||||
exclude_promo_group_ids: Optional[List[str]] = Field(
|
||||
default=None, description="Исключить пользователей из этих промо-групп"
|
||||
)
|
||||
has_subscription_days_left: Optional[int] = Field(
|
||||
default=None, ge=0, description="Минимум дней до окончания подписки"
|
||||
)
|
||||
max_subscription_days_left: Optional[int] = Field(
|
||||
default=None, ge=0, description="Максимум дней до окончания подписки"
|
||||
)
|
||||
is_trial_user: Optional[bool] = Field(
|
||||
default=None, description="Пользователь на пробном периоде"
|
||||
)
|
||||
has_autopay: Optional[bool] = Field(
|
||||
default=None, description="Автоплатёж включён"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuButtonConfig(BaseModel):
|
||||
"""Конфигурация отдельной кнопки."""
|
||||
|
||||
type: ButtonType = Field(..., description="Тип кнопки")
|
||||
builtin_id: Optional[str] = Field(
|
||||
default=None, description="ID встроенной кнопки (для type=builtin)"
|
||||
)
|
||||
text: Dict[str, str] = Field(
|
||||
..., description="Локализованные тексты кнопки: {lang_code: text}"
|
||||
)
|
||||
icon: Optional[str] = Field(
|
||||
default=None, max_length=10, description="Эмодзи/иконка кнопки (отдельно от текста)"
|
||||
)
|
||||
action: str = Field(
|
||||
..., description="callback_data или URL в зависимости от типа"
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Кнопка активна")
|
||||
visibility: ButtonVisibility = Field(
|
||||
default=ButtonVisibility.ALL, description="Видимость кнопки"
|
||||
)
|
||||
conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Дополнительные условия показа"
|
||||
)
|
||||
dynamic_text: bool = Field(
|
||||
default=False, description="Текст содержит плейсхолдеры ({balance}, {username} и т.д.)"
|
||||
)
|
||||
open_mode: ButtonOpenMode = Field(
|
||||
default=ButtonOpenMode.CALLBACK,
|
||||
description="Режим открытия: callback (через бота) или direct (сразу Mini App)",
|
||||
)
|
||||
webapp_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="URL для Mini App при open_mode=direct",
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None, max_length=200, description="Описание кнопки для админ-панели"
|
||||
)
|
||||
sort_order: Optional[int] = Field(
|
||||
default=None, description="Порядок сортировки (для отображения в админке)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuRowConfig(BaseModel):
|
||||
"""Конфигурация строки меню."""
|
||||
|
||||
id: str = Field(..., min_length=1, max_length=50, description="Уникальный ID строки")
|
||||
buttons: List[str] = Field(
|
||||
..., description="Список ID кнопок в строке"
|
||||
)
|
||||
conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Условия показа всей строки"
|
||||
)
|
||||
max_per_row: int = Field(
|
||||
default=2, ge=1, le=4, description="Максимум кнопок в строке"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuLayoutConfig(BaseModel):
|
||||
"""Полная конфигурация меню."""
|
||||
|
||||
version: int = Field(default=1, description="Версия формата конфигурации")
|
||||
rows: List[MenuRowConfig] = Field(
|
||||
default_factory=list, description="Строки меню"
|
||||
)
|
||||
buttons: Dict[str, MenuButtonConfig] = Field(
|
||||
default_factory=dict, description="Конфигурации кнопок"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
# --- Response schemas ---
|
||||
|
||||
|
||||
class MenuLayoutResponse(BaseModel):
|
||||
"""Ответ с конфигурацией меню."""
|
||||
|
||||
version: int
|
||||
rows: List[MenuRowConfig]
|
||||
buttons: Dict[str, MenuButtonConfig]
|
||||
is_enabled: bool = Field(description="Включен ли конструктор меню")
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class BuiltinButtonInfo(BaseModel):
|
||||
"""Информация о встроенной кнопке."""
|
||||
|
||||
id: str = Field(description="Идентификатор кнопки")
|
||||
default_text: Dict[str, str] = Field(description="Текст по умолчанию")
|
||||
callback_data: str = Field(description="callback_data кнопки")
|
||||
default_conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Условия показа по умолчанию"
|
||||
)
|
||||
supports_dynamic_text: bool = Field(
|
||||
default=False, description="Поддерживает ли динамический текст"
|
||||
)
|
||||
supports_direct_open: bool = Field(
|
||||
default=False, description="Поддерживает ли прямое открытие Mini App"
|
||||
)
|
||||
|
||||
|
||||
class BuiltinButtonsListResponse(BaseModel):
|
||||
"""Список встроенных кнопок."""
|
||||
|
||||
items: List[BuiltinButtonInfo]
|
||||
total: int
|
||||
|
||||
|
||||
# --- Request schemas ---
|
||||
|
||||
|
||||
class MenuLayoutUpdateRequest(BaseModel):
|
||||
"""Запрос на обновление конфигурации меню."""
|
||||
|
||||
rows: Optional[List[MenuRowConfig]] = Field(
|
||||
default=None, description="Новая конфигурация строк"
|
||||
)
|
||||
buttons: Optional[Dict[str, MenuButtonConfig]] = Field(
|
||||
default=None, description="Новая конфигурация кнопок"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ButtonUpdateRequest(BaseModel):
|
||||
"""Запрос на обновление отдельной кнопки."""
|
||||
|
||||
text: Optional[Dict[str, str]] = Field(
|
||||
default=None, description="Новые локализованные тексты"
|
||||
)
|
||||
icon: Optional[str] = Field(
|
||||
default=None, max_length=10, description="Эмодзи/иконка кнопки"
|
||||
)
|
||||
enabled: Optional[bool] = Field(default=None, description="Включить/выключить")
|
||||
visibility: Optional[ButtonVisibility] = Field(
|
||||
default=None, description="Новая видимость"
|
||||
)
|
||||
conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Новые условия показа"
|
||||
)
|
||||
action: Optional[str] = Field(
|
||||
default=None, description="Новый action (callback_data или URL)"
|
||||
)
|
||||
dynamic_text: Optional[bool] = Field(
|
||||
default=None, description="Текст содержит плейсхолдеры"
|
||||
)
|
||||
open_mode: Optional[ButtonOpenMode] = Field(
|
||||
default=None, description="Режим открытия: callback или direct"
|
||||
)
|
||||
webapp_url: Optional[str] = Field(
|
||||
default=None, description="URL для Mini App при open_mode=direct"
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None, max_length=200, description="Описание кнопки"
|
||||
)
|
||||
sort_order: Optional[int] = Field(
|
||||
default=None, description="Порядок сортировки"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RowsReorderRequest(BaseModel):
|
||||
"""Запрос на изменение порядка строк."""
|
||||
|
||||
ordered_ids: List[str] = Field(
|
||||
..., min_length=1, description="Список ID строк в новом порядке"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AddRowRequest(BaseModel):
|
||||
"""Запрос на добавление новой строки."""
|
||||
|
||||
id: str = Field(..., min_length=1, max_length=50, description="ID новой строки")
|
||||
buttons: List[str] = Field(..., description="Список ID кнопок")
|
||||
conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Условия показа"
|
||||
)
|
||||
max_per_row: int = Field(default=2, ge=1, le=4, description="Макс. кнопок в строке")
|
||||
position: Optional[int] = Field(
|
||||
default=None, ge=0, description="Позиция вставки (по умолчанию - в конец)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AddCustomButtonRequest(BaseModel):
|
||||
"""Запрос на добавление кастомной кнопки."""
|
||||
|
||||
id: str = Field(
|
||||
..., min_length=1, max_length=50, description="ID кнопки (уникальный)"
|
||||
)
|
||||
type: ButtonType = Field(..., description="Тип кнопки (url, mini_app или callback)")
|
||||
text: Dict[str, str] = Field(..., description="Локализованные тексты")
|
||||
icon: Optional[str] = Field(
|
||||
default=None, max_length=10, description="Эмодзи/иконка кнопки"
|
||||
)
|
||||
action: str = Field(..., min_length=1, description="URL или callback_data")
|
||||
visibility: ButtonVisibility = Field(
|
||||
default=ButtonVisibility.ALL, description="Видимость"
|
||||
)
|
||||
conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Условия показа"
|
||||
)
|
||||
dynamic_text: bool = Field(
|
||||
default=False, description="Текст содержит плейсхолдеры"
|
||||
)
|
||||
row_id: Optional[str] = Field(
|
||||
default=None, description="ID строки для добавления кнопки"
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None, max_length=200, description="Описание кнопки для админ-панели"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuPreviewRequest(BaseModel):
|
||||
"""Запрос на предпросмотр меню."""
|
||||
|
||||
language: str = Field(default="ru", description="Язык для предпросмотра")
|
||||
is_admin: bool = Field(default=False, description="Режим админа")
|
||||
is_moderator: bool = Field(default=False, description="Режим модератора")
|
||||
has_active_subscription: bool = Field(
|
||||
default=False, description="Есть активная подписка"
|
||||
)
|
||||
subscription_is_active: bool = Field(
|
||||
default=False, description="Подписка активна"
|
||||
)
|
||||
balance_kopeks: int = Field(default=0, ge=0, description="Баланс в копейках")
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuPreviewButton(BaseModel):
|
||||
"""Кнопка в предпросмотре."""
|
||||
|
||||
text: str
|
||||
action: str
|
||||
type: ButtonType
|
||||
|
||||
|
||||
class MenuPreviewRow(BaseModel):
|
||||
"""Строка в предпросмотре."""
|
||||
|
||||
buttons: List[MenuPreviewButton]
|
||||
|
||||
|
||||
class MenuPreviewResponse(BaseModel):
|
||||
"""Ответ с предпросмотром меню."""
|
||||
|
||||
rows: List[MenuPreviewRow]
|
||||
total_buttons: int
|
||||
|
||||
|
||||
# --- Схемы для перемещения кнопок ---
|
||||
|
||||
|
||||
class MoveButtonToRowRequest(BaseModel):
|
||||
"""Запрос на перемещение кнопки в другую строку."""
|
||||
|
||||
target_row_id: str = Field(..., description="ID целевой строки")
|
||||
position: Optional[int] = Field(
|
||||
default=None, ge=0, description="Позиция в строке (по умолчанию - в конец)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ReorderButtonsInRowRequest(BaseModel):
|
||||
"""Запрос на изменение порядка кнопок в строке."""
|
||||
|
||||
ordered_button_ids: List[str] = Field(
|
||||
..., min_length=1, description="Список ID кнопок в новом порядке"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SwapButtonsRequest(BaseModel):
|
||||
"""Запрос на обмен местами двух кнопок."""
|
||||
|
||||
button_id_1: str = Field(..., description="ID первой кнопки")
|
||||
button_id_2: str = Field(..., description="ID второй кнопки")
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MoveButtonResponse(BaseModel):
|
||||
"""Ответ на перемещение кнопки."""
|
||||
|
||||
button_id: str
|
||||
new_row_index: Optional[int] = None
|
||||
target_row_id: Optional[str] = None
|
||||
position: Optional[int] = None
|
||||
|
||||
|
||||
class SwapButtonsResponse(BaseModel):
|
||||
"""Ответ на обмен кнопок."""
|
||||
|
||||
button_1: Dict[str, Any]
|
||||
button_2: Dict[str, Any]
|
||||
|
||||
|
||||
class ReorderButtonsResponse(BaseModel):
|
||||
"""Ответ на изменение порядка кнопок."""
|
||||
|
||||
row_id: str
|
||||
buttons: List[str]
|
||||
|
||||
|
||||
# --- Схемы для доступных callback_data ---
|
||||
|
||||
|
||||
class AvailableCallback(BaseModel):
|
||||
"""Информация о доступном callback_data."""
|
||||
|
||||
callback_data: str = Field(description="callback_data для кнопки")
|
||||
name: str = Field(description="Человекочитаемое название")
|
||||
description: Optional[str] = Field(default=None, description="Описание действия")
|
||||
category: str = Field(description="Категория: menu, subscription, balance, referral, support, etc.")
|
||||
default_text: Optional[Dict[str, str]] = Field(default=None, description="Текст по умолчанию")
|
||||
default_icon: Optional[str] = Field(default=None, description="Иконка по умолчанию")
|
||||
requires_subscription: bool = Field(default=False, description="Требует активную подписку")
|
||||
is_in_menu: bool = Field(default=False, description="Уже добавлена в меню")
|
||||
|
||||
|
||||
class AvailableCallbacksResponse(BaseModel):
|
||||
"""Список всех доступных callback_data."""
|
||||
|
||||
items: List[AvailableCallback]
|
||||
total: int
|
||||
categories: List[str] = Field(description="Список всех категорий")
|
||||
|
||||
|
||||
# --- Схемы для импорта/экспорта ---
|
||||
|
||||
|
||||
class MenuLayoutExportResponse(BaseModel):
|
||||
"""Экспорт конфигурации меню."""
|
||||
|
||||
version: int
|
||||
rows: List[MenuRowConfig]
|
||||
buttons: Dict[str, MenuButtonConfig]
|
||||
exported_at: datetime
|
||||
bot_version: Optional[str] = None
|
||||
|
||||
|
||||
class MenuLayoutImportRequest(BaseModel):
|
||||
"""Импорт конфигурации меню."""
|
||||
|
||||
version: int
|
||||
rows: List[MenuRowConfig]
|
||||
buttons: Dict[str, MenuButtonConfig]
|
||||
merge_mode: str = Field(
|
||||
default="replace",
|
||||
description="Режим импорта: replace (заменить всё), merge (объединить)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuLayoutImportResponse(BaseModel):
|
||||
"""Результат импорта."""
|
||||
|
||||
success: bool
|
||||
imported_rows: int
|
||||
imported_buttons: int
|
||||
warnings: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# --- Схемы для истории изменений ---
|
||||
|
||||
|
||||
class MenuLayoutHistoryEntry(BaseModel):
|
||||
"""Запись в истории изменений."""
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
action: str = Field(description="Тип действия: update, reset, import")
|
||||
changes_summary: str = Field(description="Краткое описание изменений")
|
||||
user_info: Optional[str] = Field(default=None, description="Информация о пользователе")
|
||||
|
||||
|
||||
class MenuLayoutHistoryResponse(BaseModel):
|
||||
"""История изменений."""
|
||||
|
||||
items: List[MenuLayoutHistoryEntry]
|
||||
total: int
|
||||
|
||||
|
||||
class MenuLayoutRollbackRequest(BaseModel):
|
||||
"""Запрос на откат к предыдущей версии."""
|
||||
|
||||
history_id: int = Field(description="ID записи в истории для отката")
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
# --- Схемы для валидации ---
|
||||
|
||||
|
||||
class ValidationError(BaseModel):
|
||||
"""Ошибка валидации."""
|
||||
|
||||
field: str
|
||||
message: str
|
||||
severity: str = Field(description="error или warning")
|
||||
|
||||
|
||||
class MenuLayoutValidateRequest(BaseModel):
|
||||
"""Запрос на валидацию конфигурации."""
|
||||
|
||||
rows: Optional[List[MenuRowConfig]] = None
|
||||
buttons: Optional[Dict[str, MenuButtonConfig]] = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuLayoutValidateResponse(BaseModel):
|
||||
"""Результат валидации."""
|
||||
|
||||
is_valid: bool
|
||||
errors: List[ValidationError] = Field(default_factory=list)
|
||||
warnings: List[ValidationError] = Field(default_factory=list)
|
||||
|
||||
|
||||
# --- Схемы для статистики кликов ---
|
||||
|
||||
|
||||
class ButtonClickStats(BaseModel):
|
||||
"""Статистика кликов по кнопке."""
|
||||
|
||||
button_id: str
|
||||
clicks_total: int = Field(default=0)
|
||||
clicks_today: int = Field(default=0)
|
||||
clicks_week: int = Field(default=0)
|
||||
clicks_month: int = Field(default=0)
|
||||
last_click_at: Optional[datetime] = None
|
||||
unique_users: int = Field(default=0, description="Уникальные пользователи")
|
||||
|
||||
|
||||
class ButtonClickStatsResponse(BaseModel):
|
||||
"""Статистика кликов для одной кнопки."""
|
||||
|
||||
button_id: str
|
||||
stats: ButtonClickStats
|
||||
clicks_by_day: List[Dict[str, Any]] = Field(
|
||||
default_factory=list, description="Клики по дням [{date, count}]"
|
||||
)
|
||||
|
||||
|
||||
class MenuClickStatsResponse(BaseModel):
|
||||
"""Общая статистика кликов по всем кнопкам."""
|
||||
|
||||
items: List[ButtonClickStats]
|
||||
total_clicks: int
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
|
||||
|
||||
class ButtonTypeStats(BaseModel):
|
||||
"""Статистика по типу кнопки."""
|
||||
|
||||
button_type: str
|
||||
clicks_total: int
|
||||
unique_users: int
|
||||
|
||||
|
||||
class ButtonTypeStatsResponse(BaseModel):
|
||||
"""Статистика кликов по типам кнопок."""
|
||||
|
||||
items: List[ButtonTypeStats]
|
||||
total_clicks: int
|
||||
|
||||
|
||||
class HourlyStats(BaseModel):
|
||||
"""Статистика по часам."""
|
||||
|
||||
hour: int
|
||||
count: int
|
||||
|
||||
|
||||
class HourlyStatsResponse(BaseModel):
|
||||
"""Статистика кликов по часам дня."""
|
||||
|
||||
items: List[HourlyStats]
|
||||
button_id: Optional[str] = None
|
||||
|
||||
|
||||
class WeekdayStats(BaseModel):
|
||||
"""Статистика по дням недели."""
|
||||
|
||||
weekday: int
|
||||
weekday_name: str
|
||||
count: int
|
||||
|
||||
|
||||
class WeekdayStatsResponse(BaseModel):
|
||||
"""Статистика кликов по дням недели."""
|
||||
|
||||
items: List[WeekdayStats]
|
||||
button_id: Optional[str] = None
|
||||
|
||||
|
||||
class TopUserStats(BaseModel):
|
||||
"""Статистика пользователя."""
|
||||
|
||||
user_id: int
|
||||
clicks_count: int
|
||||
last_click_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TopUsersResponse(BaseModel):
|
||||
"""Топ пользователей по кликам."""
|
||||
|
||||
items: List[TopUserStats]
|
||||
button_id: Optional[str] = None
|
||||
limit: int
|
||||
|
||||
|
||||
class PeriodComparisonResponse(BaseModel):
|
||||
"""Сравнение периодов."""
|
||||
|
||||
current_period: Dict[str, Any]
|
||||
previous_period: Dict[str, Any]
|
||||
change: Dict[str, Any]
|
||||
button_id: Optional[str] = None
|
||||
|
||||
|
||||
class UserClickSequence(BaseModel):
|
||||
"""Последовательность кликов пользователя."""
|
||||
|
||||
button_id: str
|
||||
button_text: Optional[str] = None
|
||||
clicked_at: datetime
|
||||
|
||||
|
||||
class UserClickSequencesResponse(BaseModel):
|
||||
"""Последовательности кликов пользователя."""
|
||||
|
||||
user_id: int
|
||||
items: List[UserClickSequence]
|
||||
total: int
|
||||
|
||||
|
||||
# --- Схемы для плейсхолдеров ---
|
||||
|
||||
|
||||
class DynamicPlaceholder(BaseModel):
|
||||
"""Информация о динамическом плейсхолдере."""
|
||||
|
||||
placeholder: str = Field(description="Плейсхолдер, например {balance}")
|
||||
description: str = Field(description="Описание")
|
||||
example: str = Field(description="Пример значения")
|
||||
category: str = Field(description="Категория: user, subscription, referral, etc.")
|
||||
|
||||
|
||||
class DynamicPlaceholdersResponse(BaseModel):
|
||||
"""Список доступных плейсхолдеров."""
|
||||
|
||||
items: List[DynamicPlaceholder]
|
||||
total: int
|
||||
@@ -73,3 +73,159 @@ class PartnerReferralCommissionUpdate(BaseModel):
|
||||
le=100,
|
||||
description="Индивидуальный процент реферальной комиссии для пользователя",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# РАСШИРЕННАЯ СТАТИСТИКА ПАРТНЁРОВ
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class EarningsByPeriod(BaseModel):
|
||||
"""Заработки по периодам."""
|
||||
all_time_kopeks: int
|
||||
year_kopeks: int
|
||||
month_kopeks: int
|
||||
week_kopeks: int
|
||||
today_kopeks: int
|
||||
|
||||
|
||||
class ReferralsCountByPeriod(BaseModel):
|
||||
"""Количество рефералов по периодам."""
|
||||
all_time: int
|
||||
year: int
|
||||
month: int
|
||||
week: int
|
||||
today: int
|
||||
|
||||
|
||||
class ReferrerSummary(BaseModel):
|
||||
"""Сводка по рефереру."""
|
||||
total_referrals: int
|
||||
paid_referrals: int
|
||||
active_referrals: int
|
||||
conversion_to_paid_percent: float
|
||||
conversion_to_active_percent: float
|
||||
avg_earnings_per_referral_kopeks: float
|
||||
|
||||
|
||||
class ReferrerDetailedStats(BaseModel):
|
||||
"""Детальная статистика реферера."""
|
||||
user_id: int
|
||||
summary: ReferrerSummary
|
||||
earnings: EarningsByPeriod
|
||||
referrals_count: ReferralsCountByPeriod
|
||||
|
||||
|
||||
class DailyStats(BaseModel):
|
||||
"""Статистика за день."""
|
||||
date: str
|
||||
referrals_count: int
|
||||
earnings_kopeks: int
|
||||
|
||||
|
||||
class DailyStatsResponse(BaseModel):
|
||||
"""Ответ со статистикой по дням."""
|
||||
items: List[DailyStats]
|
||||
days: int
|
||||
user_id: Optional[int] = None
|
||||
|
||||
|
||||
class TopReferralItem(BaseModel):
|
||||
"""Топ реферал."""
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
full_name: str
|
||||
created_at: datetime
|
||||
has_made_first_topup: bool
|
||||
is_active: bool
|
||||
total_earnings_kopeks: int
|
||||
|
||||
|
||||
class TopReferralsResponse(BaseModel):
|
||||
"""Топ рефералов реферера."""
|
||||
items: List[TopReferralItem]
|
||||
user_id: int
|
||||
|
||||
|
||||
class PeriodData(BaseModel):
|
||||
"""Данные за период."""
|
||||
days: int
|
||||
start: str
|
||||
end: str
|
||||
referrals_count: int
|
||||
earnings_kopeks: int
|
||||
|
||||
|
||||
class ChangeData(BaseModel):
|
||||
"""Данные об изменении."""
|
||||
absolute: int
|
||||
percent: float
|
||||
trend: str # up, down, stable
|
||||
|
||||
|
||||
class PeriodChange(BaseModel):
|
||||
"""Изменения между периодами."""
|
||||
referrals: ChangeData
|
||||
earnings: ChangeData
|
||||
|
||||
|
||||
class PeriodComparisonResponse(BaseModel):
|
||||
"""Сравнение периодов."""
|
||||
current_period: PeriodData
|
||||
previous_period: PeriodData
|
||||
change: PeriodChange
|
||||
user_id: Optional[int] = None
|
||||
|
||||
|
||||
class GlobalPartnerSummary(BaseModel):
|
||||
"""Глобальная сводка партнёрской программы."""
|
||||
total_referrers: int
|
||||
total_referrals: int
|
||||
paid_referrals: int
|
||||
conversion_rate_percent: float
|
||||
avg_earnings_per_referral_kopeks: float
|
||||
|
||||
|
||||
class PayoutsByPeriod(BaseModel):
|
||||
"""Выплаты по периодам."""
|
||||
all_time_kopeks: int
|
||||
year_kopeks: int
|
||||
month_kopeks: int
|
||||
week_kopeks: int
|
||||
today_kopeks: int
|
||||
|
||||
|
||||
class NewReferralsByPeriod(BaseModel):
|
||||
"""Новые рефералы по периодам."""
|
||||
today: int
|
||||
week: int
|
||||
month: int
|
||||
|
||||
|
||||
class GlobalPartnerStats(BaseModel):
|
||||
"""Глобальная статистика партнёрской программы."""
|
||||
summary: GlobalPartnerSummary
|
||||
payouts: PayoutsByPeriod
|
||||
new_referrals: NewReferralsByPeriod
|
||||
|
||||
|
||||
class TopReferrerItem(BaseModel):
|
||||
"""Топ реферер."""
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
full_name: str
|
||||
referral_code: Optional[str] = None
|
||||
referrals_count: int
|
||||
total_earnings_kopeks: int
|
||||
|
||||
|
||||
class TopReferrersResponse(BaseModel):
|
||||
"""Топ рефереров."""
|
||||
items: List[TopReferrerItem]
|
||||
days: Optional[int] = None
|
||||
|
||||
@@ -74,12 +74,22 @@ class PromoOfferBroadcastRequest(PromoOfferCreateRequest):
|
||||
"all",
|
||||
"active",
|
||||
"trial",
|
||||
"trial_ending",
|
||||
"trial_expired",
|
||||
"no",
|
||||
"expiring",
|
||||
"expiring_subscribers",
|
||||
"expired",
|
||||
"expired_subscribers",
|
||||
"canceled_subscribers",
|
||||
"active_zero",
|
||||
"trial_zero",
|
||||
"zero",
|
||||
"autopay_failed",
|
||||
"low_balance",
|
||||
"inactive_30d",
|
||||
"inactive_60d",
|
||||
"inactive_90d",
|
||||
}
|
||||
_CUSTOM_TARGETS: ClassVar[set[str]] = {
|
||||
"today",
|
||||
@@ -93,6 +103,9 @@ class PromoOfferBroadcastRequest(PromoOfferCreateRequest):
|
||||
}
|
||||
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
|
||||
"no_sub": "no",
|
||||
"all_users": "all",
|
||||
"active_subscribers": "active",
|
||||
"trial_users": "trial",
|
||||
}
|
||||
|
||||
@validator("target")
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"created_by": 1
|
||||
}
|
||||
```
|
||||
- `GET /contests/referral/{id}` — детали + `total_events`, `leaderboard`.
|
||||
- `GET /contests/referral/{id}/detailed-stats` — детальная статистика конкурса с разбивкой по участникам (total_participants, total_invited, total_paid_amount, total_unpaid, participants).
|
||||
- `PATCH /contests/referral/{id}` — частичное обновление (те же поля + `final_summary_sent`, `is_active`, `daily_summary_times` с несколькими временами через запятую).
|
||||
- `POST /contests/referral/{id}/toggle?is_active=true|false` — быстро включить/остановить.
|
||||
- `GET /contests/referral/{id}/events?limit&offset` — события (referrer/referral, тип, суммы).
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# Использование API статистики кнопок меню
|
||||
|
||||
## Обзор
|
||||
|
||||
Система статистики кликов по кнопкам меню позволяет отслеживать, какие кнопки чаще всего нажимают пользователи.
|
||||
|
||||
## API Эндпоинты
|
||||
|
||||
### 1. Логирование клика по кнопке
|
||||
|
||||
**POST** `/menu-layout/stats/log-click`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (str) - ID кнопки
|
||||
- `user_id` (int, optional) - ID пользователя (telegram_id)
|
||||
- `callback_data` (str, optional) - callback_data кнопки
|
||||
- `button_type` (str, optional) - тип кнопки: `builtin`, `callback`, `url`, `mini_app`
|
||||
- `button_text` (str, optional) - текст кнопки на момент клика
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
await MenuLayoutService.log_button_click(
|
||||
db,
|
||||
button_id="menu_balance",
|
||||
user_id=123456789,
|
||||
callback_data="menu_balance",
|
||||
button_type="builtin",
|
||||
button_text="💰 Баланс"
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Получение статистики по конкретной кнопке
|
||||
|
||||
**GET** `/menu-layout/stats/buttons/{button_id}?days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- `clicks_total` - общее количество кликов
|
||||
- `clicks_today` - клики сегодня
|
||||
- `clicks_week` - клики за неделю
|
||||
- `clicks_month` - клики за месяц
|
||||
- `unique_users` - уникальные пользователи
|
||||
- `last_click_at` - последний клик
|
||||
- `clicks_by_day` - клики по дням
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_button_stats(db, "menu_balance", days=30)
|
||||
# Возвращает:
|
||||
# {
|
||||
# "button_id": "menu_balance",
|
||||
# "clicks_total": 150,
|
||||
# "clicks_today": 5,
|
||||
# "clicks_week": 25,
|
||||
# "clicks_month": 150,
|
||||
# "unique_users": 45,
|
||||
# "last_click_at": datetime(...)
|
||||
# }
|
||||
```
|
||||
|
||||
### 3. Получение общей статистики по всем кнопкам
|
||||
|
||||
**GET** `/menu-layout/stats?days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- `items` - список статистики по каждой кнопке
|
||||
- `total_clicks` - общее количество кликов
|
||||
- `period_start` - начало периода
|
||||
- `period_end` - конец периода
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
all_stats = await MenuLayoutService.get_all_buttons_stats(db, days=30)
|
||||
total = await MenuLayoutService.get_total_clicks(db, days=30)
|
||||
```
|
||||
|
||||
## Автоматическое логирование
|
||||
|
||||
✅ **Логирование кликов происходит автоматически!**
|
||||
|
||||
Все клики по кнопкам автоматически логируются через `ButtonStatsMiddleware`. Middleware перехватывает все `CallbackQuery` события и логирует их в базу данных.
|
||||
|
||||
### Как это работает
|
||||
|
||||
1. При каждом клике по кнопке middleware автоматически:
|
||||
- Извлекает `callback_data` (используется как `button_id`)
|
||||
- Получает `user_id` из события
|
||||
- Определяет тип кнопки (`builtin`, `callback`, `url`)
|
||||
- Извлекает текст кнопки из клавиатуры (если доступен)
|
||||
- Логирует в базу данных асинхронно (не блокирует обработку)
|
||||
|
||||
2. Middleware активируется автоматически, если `MENU_LAYOUT_ENABLED=True`
|
||||
|
||||
3. Логирование происходит в фоновом режиме и не влияет на производительность
|
||||
|
||||
### Ручное логирование (опционально)
|
||||
|
||||
Если нужно логировать клики вручную (например, для внешних интеграций), можно использовать API:
|
||||
|
||||
```python
|
||||
# Через сервис
|
||||
await MenuLayoutService.log_button_click(
|
||||
db,
|
||||
button_id="custom_button",
|
||||
user_id=user_id,
|
||||
callback_data="custom_callback",
|
||||
button_type="callback",
|
||||
button_text="Кастомная кнопка"
|
||||
)
|
||||
|
||||
# Или через API эндпоинт
|
||||
POST /menu-layout/stats/log-click
|
||||
{
|
||||
"button_id": "custom_button",
|
||||
"user_id": 123456789,
|
||||
"callback_data": "custom_callback",
|
||||
"button_type": "callback",
|
||||
"button_text": "Кастомная кнопка"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Статистика по типам кнопок
|
||||
|
||||
**GET** `/menu-layout/stats/by-type?days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- Статистику кликов по каждому типу кнопок (builtin, callback, url, mini_app)
|
||||
- Общее количество кликов по типам
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_stats_by_button_type(db, days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"button_type": "builtin", "clicks_total": 500, "unique_users": 100},
|
||||
# {"button_type": "callback", "clicks_total": 200, "unique_users": 50},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 5. Статистика по часам дня
|
||||
|
||||
**GET** `/menu-layout/stats/by-hour?button_id=menu_balance&days=30`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `days` (default: 30) - период в днях
|
||||
|
||||
**Возвращает:**
|
||||
- Распределение кликов по часам дня (0-23)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_clicks_by_hour(db, button_id="menu_balance", days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"hour": 9, "count": 50},
|
||||
# {"hour": 10, "count": 75},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 6. Статистика по дням недели
|
||||
|
||||
**GET** `/menu-layout/stats/by-weekday?button_id=menu_balance&days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- Распределение кликов по дням недели (0=понедельник, 6=воскресенье)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id="menu_balance", days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"weekday": 0, "weekday_name": "Понедельник", "count": 100},
|
||||
# {"weekday": 1, "weekday_name": "Вторник", "count": 120},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 7. Топ пользователей по кликам
|
||||
|
||||
**GET** `/menu-layout/stats/top-users?button_id=menu_balance&limit=10&days=30`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `limit` (default: 10) - количество пользователей
|
||||
- `days` (default: 30) - период в днях
|
||||
|
||||
**Возвращает:**
|
||||
- Список пользователей с наибольшим количеством кликов
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
top_users = await MenuLayoutService.get_top_users(db, button_id="menu_balance", limit=10, days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"user_id": 123456789, "clicks_count": 50, "last_click_at": datetime(...)},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 8. Сравнение периодов
|
||||
|
||||
**GET** `/menu-layout/stats/compare?button_id=menu_balance¤t_days=7&previous_days=7`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `current_days` (default: 7) - период текущего сравнения
|
||||
- `previous_days` (default: 7) - период предыдущего сравнения
|
||||
|
||||
**Возвращает:**
|
||||
- Сравнение текущего и предыдущего периода
|
||||
- Изменение в абсолютных числах и процентах
|
||||
- Тренд (up/down/stable)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
comparison = await MenuLayoutService.get_period_comparison(
|
||||
db, button_id="menu_balance", current_days=7, previous_days=7
|
||||
)
|
||||
# Возвращает:
|
||||
# {
|
||||
# "current_period": {"clicks": 100, "days": 7, ...},
|
||||
# "previous_period": {"clicks": 80, "days": 7, ...},
|
||||
# "change": {"absolute": 20, "percent": 25.0, "trend": "up"}
|
||||
# }
|
||||
```
|
||||
|
||||
### 9. Последовательности кликов пользователя
|
||||
|
||||
**GET** `/menu-layout/stats/users/{user_id}/sequences?limit=50`
|
||||
|
||||
**Параметры:**
|
||||
- `user_id` (path) - ID пользователя
|
||||
- `limit` (default: 50) - максимальное количество записей
|
||||
|
||||
**Возвращает:**
|
||||
- Хронологическую последовательность кликов пользователя
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
sequences = await MenuLayoutService.get_user_click_sequences(db, user_id=123456789, limit=50)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"button_id": "menu_balance", "button_text": "💰 Баланс", "clicked_at": datetime(...)},
|
||||
# {"button_id": "menu_subscription", "button_text": "📊 Подписка", "clicked_at": datetime(...)},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
## Важные замечания
|
||||
|
||||
1. **Автоматическое логирование**: Все клики по кнопкам логируются автоматически через `ButtonStatsMiddleware`
|
||||
2. **Требуется авторизация**: API эндпоинты для получения статистики требуют токен авторизации (`require_api_token`)
|
||||
3. **button_id**: Используется `callback_data` кнопки как идентификатор
|
||||
4. **Производительность**: Логирование выполняется асинхронно в фоне и не блокирует обработку запросов
|
||||
5. **Активация**: Middleware работает только если `MENU_LAYOUT_ENABLED=True` в настройках
|
||||
6. **Временные зоны**: Все временные метрики используют локальное время сервера
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ uvicorn==0.32.1
|
||||
python-multipart==0.0.9
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.7.0
|
||||
yookassa==3.9.0
|
||||
|
||||
# NaloGO для чеков в налоговую
|
||||
nalogo
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Тесты для MenuLayoutService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
|
||||
from app.services.menu_layout.service import MenuLayoutService
|
||||
from app.services.menu_layout.context import MenuContext
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_build_button_connect_direct_mode_with_url():
|
||||
"""Тест: кнопка connect с open_mode=direct и валидным URL должна создавать WebAppInfo."""
|
||||
button_config = {
|
||||
"type": "builtin",
|
||||
"builtin_id": "connect",
|
||||
"text": {"ru": "🔗 Подключиться"},
|
||||
"action": "subscription_connect",
|
||||
"open_mode": "direct",
|
||||
"webapp_url": "https://example.com/miniapp",
|
||||
}
|
||||
|
||||
context = MenuContext(
|
||||
language="ru",
|
||||
has_active_subscription=True,
|
||||
subscription_is_active=True,
|
||||
)
|
||||
|
||||
texts = MagicMock()
|
||||
texts.t = lambda key, default: default
|
||||
|
||||
button = MenuLayoutService._build_button(button_config, context, texts)
|
||||
|
||||
assert button is not None
|
||||
assert isinstance(button, InlineKeyboardButton)
|
||||
assert button.web_app is not None
|
||||
assert button.web_app.url == "https://example.com/miniapp"
|
||||
assert button.callback_data is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_build_button_connect_direct_mode_with_subscription_url():
|
||||
"""Тест: кнопка connect с open_mode=direct должна получать URL из подписки."""
|
||||
button_config = {
|
||||
"type": "builtin",
|
||||
"builtin_id": "connect",
|
||||
"text": {"ru": "🔗 Подключиться"},
|
||||
"action": "subscription_connect",
|
||||
"open_mode": "direct",
|
||||
"webapp_url": None,
|
||||
}
|
||||
|
||||
# Мокаем подписку с URL
|
||||
mock_subscription = MagicMock()
|
||||
mock_subscription.subscription_url = "https://subscription.example.com/link"
|
||||
mock_subscription.subscription_crypto_link = None
|
||||
|
||||
context = MenuContext(
|
||||
language="ru",
|
||||
has_active_subscription=True,
|
||||
subscription_is_active=True,
|
||||
subscription=mock_subscription,
|
||||
)
|
||||
|
||||
texts = MagicMock()
|
||||
texts.t = lambda key, default: default
|
||||
|
||||
with patch('app.utils.subscription_utils.get_display_subscription_link') as mock_get_link:
|
||||
mock_get_link.return_value = "https://subscription.example.com/link"
|
||||
|
||||
button = MenuLayoutService._build_button(button_config, context, texts)
|
||||
|
||||
assert button is not None
|
||||
assert isinstance(button, InlineKeyboardButton)
|
||||
assert button.web_app is not None
|
||||
assert button.web_app.url == "https://subscription.example.com/link"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_build_button_connect_callback_mode():
|
||||
"""Тест: кнопка connect с open_mode=callback должна создавать callback кнопку."""
|
||||
button_config = {
|
||||
"type": "builtin",
|
||||
"builtin_id": "connect",
|
||||
"text": {"ru": "🔗 Подключиться"},
|
||||
"action": "subscription_connect",
|
||||
"open_mode": "callback",
|
||||
"webapp_url": None,
|
||||
}
|
||||
|
||||
context = MenuContext(
|
||||
language="ru",
|
||||
has_active_subscription=True,
|
||||
subscription_is_active=True,
|
||||
)
|
||||
|
||||
texts = MagicMock()
|
||||
texts.t = lambda key, default: default
|
||||
|
||||
button = MenuLayoutService._build_button(button_config, context, texts)
|
||||
|
||||
assert button is not None
|
||||
assert isinstance(button, InlineKeyboardButton)
|
||||
assert button.callback_data == "subscription_connect"
|
||||
assert button.web_app is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_build_button_connect_direct_mode_fallback_to_callback():
|
||||
"""Тест: кнопка connect с open_mode=direct без URL должна fallback на callback."""
|
||||
button_config = {
|
||||
"type": "builtin",
|
||||
"builtin_id": "connect",
|
||||
"text": {"ru": "🔗 Подключиться"},
|
||||
"action": "subscription_connect",
|
||||
"open_mode": "direct",
|
||||
"webapp_url": None,
|
||||
}
|
||||
|
||||
context = MenuContext(
|
||||
language="ru",
|
||||
has_active_subscription=True,
|
||||
subscription_is_active=True,
|
||||
subscription=None, # Нет подписки
|
||||
)
|
||||
|
||||
texts = MagicMock()
|
||||
texts.t = lambda key, default: default
|
||||
|
||||
with patch('app.services.menu_layout.service.settings') as mock_settings:
|
||||
mock_settings.MINIAPP_CUSTOM_URL = None
|
||||
|
||||
button = MenuLayoutService._build_button(button_config, context, texts)
|
||||
|
||||
assert button is not None
|
||||
assert isinstance(button, InlineKeyboardButton)
|
||||
# Должен fallback на callback_data, так как URL не найден
|
||||
assert button.callback_data == "subscription_connect"
|
||||
|
||||
Reference in New Issue
Block a user