From 3cbb304e9bf87c6aad7a7e859eb3b7d442cb7e83 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 31 Oct 2025 22:31:22 +0300 Subject: [PATCH 1/8] Document payment verification auto-check settings --- .env.example | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 829c6830..50b4e0b9 100644 --- a/.env.example +++ b/.env.example @@ -252,8 +252,13 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true # Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную) DISABLE_TOPUP_BUTTONS=false +# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам +PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false +# Интервал (в минутах) между автоматическими проверками пополнений +PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10 + # ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ ===== -# Эти настройки позволяют изменить описания платежей, +# Эти настройки позволяют изменить описания платежей, # чтобы избежать блокировок платежных систем PAYMENT_SERVICE_NAME=Интернет-сервис PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса From 7df64d8edf3a00960907c9ac3df8fc0a39c6990c Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 31 Oct 2025 22:52:11 +0300 Subject: [PATCH 2/8] Fix admin notification service f-string syntax --- app/services/admin_notification_service.py | 39 +++++++++++++++++----- app/webapi/routes/promo_groups.py | 21 ++++++++---- app/webapi/schemas/promo_groups.py | 8 +++-- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 5da6c2cb..de7407b9 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -77,6 +77,20 @@ class AdminNotificationService: ) return None + def _get_user_display(self, user: User) -> str: + first_name = getattr(user, "first_name", "") or "" + if first_name: + return first_name + + username = getattr(user, "username", "") or "" + if username: + return username + + telegram_id = getattr(user, "telegram_id", None) + if telegram_id is None: + return "IDUnknown" + return f"ID{telegram_id}" + def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]: discount_lines: List[str] = [] @@ -185,6 +199,7 @@ class AdminNotificationService: referrer_info = await self._get_referrer_info(db, user.referred_by_id) promo_group = await self._get_user_promo_group(db, user) promo_block = self._format_promo_group_block(promo_group) + user_display = self._get_user_display(user) trial_device_limit = subscription.device_limit if trial_device_limit is None: @@ -196,7 +211,7 @@ class AdminNotificationService: message = f"""🎯 АКТИВАЦИЯ ТРИАЛА -👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"} +👤 Пользователь: {user_display} 🆔 Telegram ID: {user.telegram_id} 📱 Username: @{getattr(user, 'username', None) or 'отсутствует'} 👥 Статус: {user_status} @@ -235,26 +250,27 @@ class AdminNotificationService: try: event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ" - + if was_trial_conversion: user_status = "🎯 Конверсия из триала" elif user.has_had_paid_subscription: user_status = "🔄 Продление/Обновление" else: user_status = "🆕 Первая покупка" - + servers_info = await self._get_servers_info(subscription.connected_squads) payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс" referrer_info = await self._get_referrer_info(db, user.referred_by_id) promo_group = await self._get_user_promo_group(db, user) promo_block = self._format_promo_group_block(promo_group) + user_display = self._get_user_display(user) total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0) transaction_id = transaction.id if transaction else "—" message = f"""💎 {event_type} -👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"} +👤 Пользователь: {user_display} 🆔 Telegram ID: {user.telegram_id} 📱 Username: @{getattr(user, 'username', None) or 'отсутствует'} 👥 Статус: {user_status} @@ -372,10 +388,11 @@ class AdminNotificationService: subscription_status = self._get_subscription_status(subscription) promo_block = self._format_promo_group_block(promo_group) timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S') + user_display = self._get_user_display(user) return f"""💰 ПОПОЛНЕНИЕ БАЛАНСА -👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"} +👤 Пользователь: {user_display} 🆔 Telegram ID: {user.telegram_id} 📱 Username: @{getattr(user, 'username', None) or 'отсутствует'} 💳 Статус: {topup_status} @@ -548,13 +565,14 @@ class AdminNotificationService: servers_info = await self._get_servers_info(subscription.connected_squads) promo_group = await self._get_user_promo_group(db, user) promo_block = self._format_promo_group_block(promo_group) + user_display = self._get_user_display(user) current_end_date = new_end_date or subscription.end_date current_balance = balance_after if balance_after is not None else user.balance_kopeks message = f"""⏰ ПРОДЛЕНИЕ ПОДПИСКИ -👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"} +👤 Пользователь: {user_display} 🆔 Telegram ID: {user.telegram_id} 📱 Username: @{getattr(user, 'username', None) or 'отсутствует'} @@ -600,11 +618,12 @@ class AdminNotificationService: promo_block = self._format_promo_group_block(promo_group) type_display = self._get_promocode_type_display(promocode_data.get("type")) usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}" + user_display = self._get_user_display(user) message_lines = [ "🎫 АКТИВАЦИЯ ПРОМОКОДА", "", - f"👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}", + f"👤 Пользователь: {user_display}", f"🆔 Telegram ID: {user.telegram_id}", f"📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}", "", @@ -727,11 +746,12 @@ class AdminNotificationService: ) elif automatic: initiator_line = "🤖 Автоматическое назначение" + user_display = self._get_user_display(user) message_lines = [ f"{title}", "", - f"👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}", + f"👤 Пользователь: {user_display}", f"🆔 Telegram ID: {user.telegram_id}", f"📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}", "", @@ -1103,6 +1123,7 @@ class AdminNotificationService: referrer_info = await self._get_referrer_info(db, user.referred_by_id) promo_group = await self._get_user_promo_group(db, user) promo_block = self._format_promo_group_block(promo_group) + user_display = self._get_user_display(user) update_types = { "traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"), @@ -1115,7 +1136,7 @@ class AdminNotificationService: message_lines = [ f"{title}", "", - f"👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}", + f"👤 Пользователь: {user_display}", f"🆔 Telegram ID: {user.telegram_id}", f"📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}", "", diff --git a/app/webapi/routes/promo_groups.py b/app/webapi/routes/promo_groups.py index cec284d8..07ffc9e8 100644 --- a/app/webapi/routes/promo_groups.py +++ b/app/webapi/routes/promo_groups.py @@ -52,12 +52,12 @@ def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse: apply_discounts_to_addons=group.apply_discounts_to_addons, is_default=group.is_default, members_count=members_count, - created_at=group.created_at, - updated_at=group.updated_at, + created_at=getattr(group, "created_at", None), + updated_at=getattr(group, "updated_at", None), ) -@router.get("", response_model=PromoGroupListResponse) +@router.get("", response_model=PromoGroupListResponse, response_model_exclude_none=True) async def list_promo_groups( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -79,7 +79,7 @@ async def list_promo_groups( ) -@router.get("/{group_id}", response_model=PromoGroupResponse) +@router.get("/{group_id}", response_model=PromoGroupResponse, response_model_exclude_none=True) async def get_promo_group( group_id: int, _: Any = Security(require_api_token), @@ -93,7 +93,12 @@ async def get_promo_group( return _serialize(group, members_count=members_count) -@router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "", + response_model=PromoGroupResponse, + response_model_exclude_none=True, + status_code=status.HTTP_201_CREATED, +) async def create_promo_group_endpoint( payload: PromoGroupCreateRequest, _: Any = Security(require_api_token), @@ -120,7 +125,11 @@ async def create_promo_group_endpoint( return _serialize(group, members_count=0) -@router.patch("/{group_id}", response_model=PromoGroupResponse) +@router.patch( + "/{group_id}", + response_model=PromoGroupResponse, + response_model_exclude_none=True, +) async def update_promo_group_endpoint( group_id: int, payload: PromoGroupUpdateRequest, diff --git a/app/webapi/schemas/promo_groups.py b/app/webapi/schemas/promo_groups.py index ffd2e68e..d006f9e7 100644 --- a/app/webapi/schemas/promo_groups.py +++ b/app/webapi/schemas/promo_groups.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from typing import Dict, Optional -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, ConfigDict, Field, validator def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Optional[Dict[int, int]]: @@ -23,6 +23,8 @@ def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Option class PromoGroupResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int name: str server_discount_percent: int @@ -33,8 +35,8 @@ class PromoGroupResponse(BaseModel): apply_discounts_to_addons: bool is_default: bool members_count: int = 0 - created_at: datetime - updated_at: datetime + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None class _PromoGroupBase(BaseModel): From 61a93f86b3a19a014efbc57654b1f9748c8f9f76 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 1 Nov 2025 00:25:55 +0300 Subject: [PATCH 3/8] Add dynamic Telegram Stars rate with auto refresh --- .env.example | 1 + README.md | 1 + app/config.py | 18 + app/services/system_settings_service.py | 1 + app/services/telegram_stars_rate_service.py | 387 ++++++++++++++++++ app/webapi/app.py | 19 + main.py | 23 ++ .../test_telegram_stars_rate_service.py | 100 +++++ 8 files changed, 550 insertions(+) create mode 100644 app/services/telegram_stars_rate_service.py create mode 100644 tests/services/test_telegram_stars_rate_service.py diff --git a/.env.example b/.env.example index 50b4e0b9..7621176f 100644 --- a/.env.example +++ b/.env.example @@ -181,6 +181,7 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000 # Telegram Stars (работает автоматически) TELEGRAM_STARS_ENABLED=true TELEGRAM_STARS_RATE_RUB=1.3 +TELEGRAM_STARS_CUSTOM_RATE_ENABLED=false # Tribute (https://tribute.app) TRIBUTE_ENABLED=false diff --git a/README.md b/README.md index 91e33c7b..4a438696 100644 --- a/README.md +++ b/README.md @@ -595,6 +595,7 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000 # Telegram Stars TELEGRAM_STARS_ENABLED=true TELEGRAM_STARS_RATE_RUB=1.3 +TELEGRAM_STARS_CUSTOM_RATE_ENABLED=false # Tribute TRIBUTE_ENABLED=false diff --git a/app/config.py b/app/config.py index 6da1388c..3f81b770 100644 --- a/app/config.py +++ b/app/config.py @@ -164,6 +164,7 @@ class Settings(BaseSettings): TELEGRAM_STARS_ENABLED: bool = True TELEGRAM_STARS_RATE_RUB: float = 1.3 + TELEGRAM_STARS_CUSTOM_RATE_ENABLED: bool = False TRIBUTE_ENABLED: bool = False TRIBUTE_API_KEY: Optional[str] = None @@ -1121,6 +1122,23 @@ class Settings(BaseSettings): ) def get_stars_rate(self) -> float: + """Возвращает актуальный курс Telegram Stars в рублях.""" + + if self.TELEGRAM_STARS_CUSTOM_RATE_ENABLED: + return self.TELEGRAM_STARS_RATE_RUB + + try: + from app.services.telegram_stars_rate_service import ( # pylint: disable=import-outside-toplevel + telegram_stars_rate_service, + ) + + telegram_stars_rate_service.ensure_refresh() + cached_rate = telegram_stars_rate_service.get_cached_rate() + if cached_rate: + return cached_rate + except Exception as error: # pragma: no cover - защитный блок от циклических импортов + logger.debug("Не удалось получить актуальный курс Stars: %s", error) + return self.TELEGRAM_STARS_RATE_RUB def stars_to_rubles(self, stars: int) -> float: diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 34a74bae..394233d7 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -269,6 +269,7 @@ class BotConfigurationService: "VERSION_CHECK_REPO": "VERSION", "VERSION_CHECK_INTERVAL_HOURS": "VERSION", "TELEGRAM_STARS_RATE_RUB": "TELEGRAM", + "TELEGRAM_STARS_CUSTOM_RATE_ENABLED": "TELEGRAM", "REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE", "REMNAWAVE_USER_USERNAME_TEMPLATE": "REMNAWAVE", "REMNAWAVE_AUTO_SYNC_ENABLED": "REMNAWAVE", diff --git a/app/services/telegram_stars_rate_service.py b/app/services/telegram_stars_rate_service.py new file mode 100644 index 00000000..1dac8c41 --- /dev/null +++ b/app/services/telegram_stars_rate_service.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import asyncio + +import json +import logging +import math +import re +import time +from dataclasses import dataclass +from typing import Any, Optional, Sequence, Tuple + +import aiohttp + +from app.config import settings + + +logger = logging.getLogger(__name__) + + +_RATE_KEYS: Tuple[str, ...] = ( + "rub_per_star", + "rubperstar", + "price_per_star", + "per_star_price", + "perstarprice", + "priceperstar", + "per_star", + "perstar", + "rate", + "exchange_rate", + "exchangerate", +) + +_STAR_KEYS: Tuple[str, ...] = ( + "stars", + "star", + "star_count", + "stars_count", + "starcount", + "starscount", + "starQuantity", + "starquantity", +) + +_PRICE_KEYS: Tuple[str, ...] = ( + "price", + "amount", + "total", + "total_price", + "totalPrice", + "total_amount", + "totalAmount", + "value", +) + + +@dataclass(frozen=True, slots=True) +class _RequestSpec: + method: str + url: str + params: Optional[dict[str, Any]] = None + json_payload: Optional[dict[str, Any]] = None + + +class TelegramStarsRateService: + """Получает и кэширует актуальный курс Telegram Stars.""" + + _REQUESTS: Sequence[_RequestSpec] = ( + _RequestSpec( + method="POST", + url="https://pay.telegram.org/api/index", + json_payload={ + "method": "getStarsExchangeRates", + "params": {"currency": "RUB"}, + }, + ), + _RequestSpec( + method="GET", + url="https://pay.telegram.org/api/index", + params={"act": "pack", "type": "stars"}, + ), + ) + + _REQUEST_HEADERS = { + "User-Agent": "Mozilla/5.0 (compatible; RemnawaveBot/1.0)", + "Accept": "application/json, text/plain, */*", + "Referer": "https://pay.telegram.org/", + "Origin": "https://pay.telegram.org", + } + + _REFRESH_INTERVAL_SECONDS = 15 * 60 + _MIN_RETRY_INTERVAL_SECONDS = 60 + _MIN_REASONABLE_RATE = 0.01 + _MAX_REASONABLE_RATE = 100.0 + + def __init__(self) -> None: + self._rate: Optional[float] = float(settings.TELEGRAM_STARS_RATE_RUB or 0) + self._last_update: float = 0.0 + self._last_attempt: float = 0.0 + self._lock = asyncio.Lock() + self._background_task: Optional[asyncio.Task[Optional[float]]] = None + + def get_cached_rate(self) -> Optional[float]: + """Возвращает закэшированный курс Stars.""" + + if self._rate and self._rate >= self._MIN_REASONABLE_RATE: + return self._rate + return None + + def ensure_refresh(self, force: bool = False) -> None: + """Гарантирует запуск обновления курса в фоне при необходимости.""" + + if settings.TELEGRAM_STARS_CUSTOM_RATE_ENABLED: + return + + if not force and not self._is_refresh_needed(): + return + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + try: + asyncio.run(self.refresh_rate(force=force)) + except RuntimeError: + logger.debug("Не удалось синхронно обновить курс Stars") + return + + if self._background_task and not self._background_task.done(): + return + + self._background_task = loop.create_task( + self.refresh_rate(force=force), + name="telegram-stars-rate-refresh", + ) + + async def refresh_rate(self, force: bool = False) -> Optional[float]: + """Асинхронно обновляет курс Stars.""" + + if settings.TELEGRAM_STARS_CUSTOM_RATE_ENABLED: + return float(settings.TELEGRAM_STARS_RATE_RUB) + + if not force and not self._is_refresh_needed(): + return self.get_cached_rate() + + async with self._lock: + if not force and not self._is_refresh_needed(): + return self.get_cached_rate() + + self._last_attempt = time.monotonic() + + try: + rate = await self._fetch_rate() + except Exception as error: + logger.warning("Не удалось получить курс Telegram Stars: %s", error) + return self.get_cached_rate() + + if rate is None: + logger.debug("API Telegram Stars не вернуло валидный курс") + return self.get_cached_rate() + + self._rate = rate + self._last_update = time.monotonic() + settings.TELEGRAM_STARS_RATE_RUB = rate + logger.info("Актуальный курс Telegram Stars обновлён: %.4f ₽/⭐", rate) + return rate + + def _is_refresh_needed(self) -> bool: + now = time.monotonic() + + if self._rate is None or self._rate < self._MIN_REASONABLE_RATE: + return now - self._last_attempt >= self._MIN_RETRY_INTERVAL_SECONDS + + if now - self._last_update >= self._REFRESH_INTERVAL_SECONDS: + return now - self._last_attempt >= self._MIN_RETRY_INTERVAL_SECONDS + + return False + + async def _fetch_rate(self) -> Optional[float]: + timeout = aiohttp.ClientTimeout(total=10) + + async with aiohttp.ClientSession(timeout=timeout) as session: + for request in self._REQUESTS: + try: + response = await session.request( + method=request.method, + url=request.url, + headers=self._REQUEST_HEADERS, + params=request.params, + json=request.json_payload, + ) + except aiohttp.ClientError as error: + logger.debug( + "Ошибка запроса курса Stars (%s %s): %s", + request.method, + request.url, + error, + ) + continue + + if response.status >= 400: + body = await response.text() + logger.debug( + "API Telegram Stars ответило %s: %s", + response.status, + _truncate(body, 200), + ) + continue + + data = await self._read_json(response) + if data is None: + continue + + rate = self._extract_rate(data) + if rate is not None: + return rate + + return None + + async def _read_json(self, response: aiohttp.ClientResponse) -> Optional[Any]: + try: + return await response.json(content_type=None) + except (aiohttp.ContentTypeError, json.JSONDecodeError, ValueError): + text = await response.text() + + try: + return json.loads(text) + except json.JSONDecodeError: + logger.debug("Не удалось распарсить ответ Telegram Stars как JSON") + return None + + @classmethod + def _extract_rate(cls, data: Any) -> Optional[float]: + candidates: list[Tuple[float, float]] = [] + cls._collect_rate_candidates(data, candidates) + + if not candidates: + return None + + best_rate, _ = max( + candidates, + key=lambda item: (item[1], -item[0]), + ) + return best_rate + + @classmethod + def _collect_rate_candidates( + cls, + payload: Any, + result: list[Tuple[float, float]], + ) -> None: + if isinstance(payload, dict): + direct_rate = cls._parse_direct_rate(payload) + if direct_rate is not None: + result.append((direct_rate, math.inf)) + + pack_rate = cls._parse_pack_rate(payload) + if pack_rate is not None: + result.append(pack_rate) + + for value in payload.values(): + cls._collect_rate_candidates(value, result) + + elif isinstance(payload, list): + for item in payload: + cls._collect_rate_candidates(item, result) + + @classmethod + def _parse_direct_rate(cls, data: dict[str, Any]) -> Optional[float]: + for key, value in data.items(): + normalized_key = key.lower() + if normalized_key in _RATE_KEYS: + if isinstance(value, dict): + price = cls._parse_price_value(value) + normalized_rate = cls._normalize_rate(price) + if normalized_rate is not None: + return normalized_rate + continue + + numeric = cls._coerce_number(value) + normalized_rate = cls._normalize_rate(numeric) + if normalized_rate is not None: + return normalized_rate + + return None + + @classmethod + def _parse_pack_rate(cls, data: dict[str, Any]) -> Optional[Tuple[float, float]]: + stars = cls._parse_stars_value(data) + if stars is None or stars <= 0: + return None + + price = cls._parse_price_value(data) + if price is None or price <= 0: + return None + + rate = cls._normalize_rate(price / stars) + if rate is None: + return None + + return rate, float(stars) + + @classmethod + def _parse_stars_value(cls, data: dict[str, Any]) -> Optional[float]: + for key in data.keys(): + normalized_key = key.lower() + if normalized_key in _STAR_KEYS: + candidate = cls._coerce_number(data[key]) + if candidate and candidate > 0: + return candidate + return None + + @classmethod + def _parse_price_value(cls, value: Any) -> Optional[float]: + if isinstance(value, dict): + currency = value.get("currency") or value.get("code") + if currency and str(currency).upper() not in {"RUB", "RUR"}: + return None + + for key in _PRICE_KEYS: + if key in value: + candidate = cls._parse_price_value(value[key]) + if candidate is not None: + return candidate + + return None + + if isinstance(value, list): + for item in value: + candidate = cls._parse_price_value(item) + if candidate is not None: + return candidate + return None + + return cls._coerce_number(value) + + @classmethod + def _coerce_number(cls, value: Any) -> Optional[float]: + if value is None: + return None + + if isinstance(value, (int, float)): + return float(value) + + if isinstance(value, str): + cleaned = value.strip().replace(" ", "").replace(" ", "") + if not cleaned: + return None + + match = re.search(r"-?\d+[\.,]?\d*", cleaned) + if not match: + return None + + normalized = match.group(0).replace(",", ".") + try: + return float(normalized) + except ValueError: + return None + + return None + + @classmethod + def _normalize_rate(cls, value: Optional[float]) -> Optional[float]: + if value is None or value <= 0: + return None + + rate = float(value) + attempts = 0 + while rate > cls._MAX_REASONABLE_RATE and attempts < 3: + rate /= 100 + attempts += 1 + + if cls._MIN_REASONABLE_RATE <= rate <= cls._MAX_REASONABLE_RATE: + return round(rate, 4) + + return None + + +def _truncate(value: str, max_len: int) -> str: + value = value.strip() + if len(value) <= max_len: + return value + return value[: max_len - 1] + "…" + + +telegram_stars_rate_service = TelegramStarsRateService() + diff --git a/app/webapi/app.py b/app/webapi/app.py index d5848663..4ab6c2b4 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -30,6 +32,9 @@ from .routes import ( ) +logger = logging.getLogger(__name__) + + OPENAPI_TAGS = [ { "name": "health", @@ -153,4 +158,18 @@ def create_web_api_app() -> FastAPI: app.include_router(polls.router, prefix="/polls", tags=["polls"]) app.include_router(logs.router, prefix="/logs", tags=["logs"]) + @app.on_event("startup") + async def refresh_stars_rate() -> None: # pragma: no cover - инфраструктурный хук + if settings.TELEGRAM_STARS_CUSTOM_RATE_ENABLED: + return + + try: + from app.services.telegram_stars_rate_service import ( # pylint: disable=import-outside-toplevel + telegram_stars_rate_service, + ) + + await telegram_stars_rate_service.refresh_rate() + except Exception as error: + logger.debug("Не удалось обновить курс Stars при старте web api: %s", error) + return app diff --git a/main.py b/main.py index d50e4689..892752cd 100644 --- a/main.py +++ b/main.py @@ -35,6 +35,7 @@ from app.localization.loader import ensure_locale_templates from app.services.system_settings_service import bot_configuration_service from app.services.external_admin_service import ensure_external_admin_token from app.services.broadcast_service import broadcast_service +from app.services.telegram_stars_rate_service import telegram_stars_rate_service from app.utils.startup_timeline import StartupTimeline @@ -141,6 +142,28 @@ async def main(): stage.warning(f"Не удалось загрузить конфигурацию: {error}") logger.error(f"❌ Не удалось загрузить конфигурацию: {error}") + async with timeline.stage( + "Актуализация курса Telegram Stars", + "⭐", + success_message="Курс Stars актуален", + ) as stage: + try: + rate = await telegram_stars_rate_service.refresh_rate(force=True) + if settings.TELEGRAM_STARS_CUSTOM_RATE_ENABLED: + stage.log( + "Используется пользовательский курс Stars: " + f"{settings.TELEGRAM_STARS_RATE_RUB:.2f} ₽/⭐" + ) + elif rate: + stage.log(f"Текущий курс Stars: {rate:.2f} ₽/⭐") + else: + stage.warning( + "Не удалось получить курс из Telegram, используется значение из настроек" + ) + except Exception as error: + stage.warning(f"Ошибка обновления курса Stars: {error}") + logger.warning("Ошибка обновления курса Telegram Stars: %s", error) + bot = None dp = None async with timeline.stage("Настройка бота", "🤖", success_message="Бот настроен") as stage: diff --git a/tests/services/test_telegram_stars_rate_service.py b/tests/services/test_telegram_stars_rate_service.py new file mode 100644 index 00000000..c0bdec5b --- /dev/null +++ b/tests/services/test_telegram_stars_rate_service.py @@ -0,0 +1,100 @@ +from pathlib import Path +from unittest.mock import AsyncMock +import sys + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.config import settings +from app.services import telegram_stars_rate_service as rate_module +from app.services.telegram_stars_rate_service import TelegramStarsRateService + + +@pytest.mark.parametrize( + "payload,expected", + [ + ( + {"rate": 1.79}, + pytest.approx(1.79), + ), + ( + {"pricePerStar": "1.85"}, + pytest.approx(1.85), + ), + ( + { + "packs": [ + { + "stars": 250, + "price": {"currency": "RUB", "amount": 44900}, + } + ] + }, + pytest.approx(1.796, rel=1e-3), + ), + ( + { + "data": { + "options": [ + { + "star_count": "500", + "total_price": {"value": "895", "currency": "RUB"}, + } + ] + } + }, + pytest.approx(1.79, rel=1e-3), + ), + ], +) +def test_extract_rate(payload, expected): + assert TelegramStarsRateService._extract_rate(payload) == expected + + +@pytest.mark.asyncio +async def test_refresh_rate_updates_settings(monkeypatch): + service = TelegramStarsRateService() + monkeypatch.setattr(settings, "TELEGRAM_STARS_CUSTOM_RATE_ENABLED", False, raising=False) + monkeypatch.setattr(settings, "TELEGRAM_STARS_RATE_RUB", 1.3, raising=False) + monkeypatch.setattr(service, "_fetch_rate", AsyncMock(return_value=1.91)) + + rate = await service.refresh_rate(force=True) + + assert rate == pytest.approx(1.91) + assert settings.TELEGRAM_STARS_RATE_RUB == pytest.approx(1.91) + + +@pytest.mark.asyncio +async def test_refresh_rate_respects_custom_setting(monkeypatch): + service = TelegramStarsRateService() + monkeypatch.setattr(settings, "TELEGRAM_STARS_CUSTOM_RATE_ENABLED", True, raising=False) + monkeypatch.setattr(settings, "TELEGRAM_STARS_RATE_RUB", 2.05, raising=False) + fetch_mock = AsyncMock(return_value=1.5) + monkeypatch.setattr(service, "_fetch_rate", fetch_mock) + + rate = await service.refresh_rate(force=True) + + assert rate == pytest.approx(2.05) + fetch_mock.assert_not_awaited() + + +def test_settings_get_stars_rate_uses_dynamic(monkeypatch): + custom_service = TelegramStarsRateService() + monkeypatch.setattr(rate_module, "telegram_stars_rate_service", custom_service, raising=False) + monkeypatch.setattr(settings, "TELEGRAM_STARS_CUSTOM_RATE_ENABLED", False, raising=False) + monkeypatch.setattr(settings, "TELEGRAM_STARS_RATE_RUB", 1.3, raising=False) + + custom_service._rate = 2.34 + + assert settings.get_stars_rate() == pytest.approx(2.34) + + +def test_settings_get_stars_rate_uses_custom(monkeypatch): + monkeypatch.setattr(settings, "TELEGRAM_STARS_CUSTOM_RATE_ENABLED", True, raising=False) + monkeypatch.setattr(settings, "TELEGRAM_STARS_RATE_RUB", 3.21, raising=False) + + assert settings.get_stars_rate() == pytest.approx(3.21) + From da46c18210c2969dbdb5fc99459272009ab427d0 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 1 Nov 2025 00:55:55 +0300 Subject: [PATCH 4/8] Revert "Add automatic Telegram Stars rate fetching and toggle" --- .env.example | 1 - README.md | 1 - app/config.py | 18 - app/services/system_settings_service.py | 1 - app/services/telegram_stars_rate_service.py | 387 ------------------ app/webapi/app.py | 19 - main.py | 23 -- .../test_telegram_stars_rate_service.py | 100 ----- 8 files changed, 550 deletions(-) delete mode 100644 app/services/telegram_stars_rate_service.py delete mode 100644 tests/services/test_telegram_stars_rate_service.py diff --git a/.env.example b/.env.example index 7621176f..50b4e0b9 100644 --- a/.env.example +++ b/.env.example @@ -181,7 +181,6 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000 # Telegram Stars (работает автоматически) TELEGRAM_STARS_ENABLED=true TELEGRAM_STARS_RATE_RUB=1.3 -TELEGRAM_STARS_CUSTOM_RATE_ENABLED=false # Tribute (https://tribute.app) TRIBUTE_ENABLED=false diff --git a/README.md b/README.md index 4a438696..91e33c7b 100644 --- a/README.md +++ b/README.md @@ -595,7 +595,6 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000 # Telegram Stars TELEGRAM_STARS_ENABLED=true TELEGRAM_STARS_RATE_RUB=1.3 -TELEGRAM_STARS_CUSTOM_RATE_ENABLED=false # Tribute TRIBUTE_ENABLED=false diff --git a/app/config.py b/app/config.py index 3f81b770..6da1388c 100644 --- a/app/config.py +++ b/app/config.py @@ -164,7 +164,6 @@ class Settings(BaseSettings): TELEGRAM_STARS_ENABLED: bool = True TELEGRAM_STARS_RATE_RUB: float = 1.3 - TELEGRAM_STARS_CUSTOM_RATE_ENABLED: bool = False TRIBUTE_ENABLED: bool = False TRIBUTE_API_KEY: Optional[str] = None @@ -1122,23 +1121,6 @@ class Settings(BaseSettings): ) def get_stars_rate(self) -> float: - """Возвращает актуальный курс Telegram Stars в рублях.""" - - if self.TELEGRAM_STARS_CUSTOM_RATE_ENABLED: - return self.TELEGRAM_STARS_RATE_RUB - - try: - from app.services.telegram_stars_rate_service import ( # pylint: disable=import-outside-toplevel - telegram_stars_rate_service, - ) - - telegram_stars_rate_service.ensure_refresh() - cached_rate = telegram_stars_rate_service.get_cached_rate() - if cached_rate: - return cached_rate - except Exception as error: # pragma: no cover - защитный блок от циклических импортов - logger.debug("Не удалось получить актуальный курс Stars: %s", error) - return self.TELEGRAM_STARS_RATE_RUB def stars_to_rubles(self, stars: int) -> float: diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 394233d7..34a74bae 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -269,7 +269,6 @@ class BotConfigurationService: "VERSION_CHECK_REPO": "VERSION", "VERSION_CHECK_INTERVAL_HOURS": "VERSION", "TELEGRAM_STARS_RATE_RUB": "TELEGRAM", - "TELEGRAM_STARS_CUSTOM_RATE_ENABLED": "TELEGRAM", "REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE", "REMNAWAVE_USER_USERNAME_TEMPLATE": "REMNAWAVE", "REMNAWAVE_AUTO_SYNC_ENABLED": "REMNAWAVE", diff --git a/app/services/telegram_stars_rate_service.py b/app/services/telegram_stars_rate_service.py deleted file mode 100644 index 1dac8c41..00000000 --- a/app/services/telegram_stars_rate_service.py +++ /dev/null @@ -1,387 +0,0 @@ -from __future__ import annotations - -import asyncio - -import json -import logging -import math -import re -import time -from dataclasses import dataclass -from typing import Any, Optional, Sequence, Tuple - -import aiohttp - -from app.config import settings - - -logger = logging.getLogger(__name__) - - -_RATE_KEYS: Tuple[str, ...] = ( - "rub_per_star", - "rubperstar", - "price_per_star", - "per_star_price", - "perstarprice", - "priceperstar", - "per_star", - "perstar", - "rate", - "exchange_rate", - "exchangerate", -) - -_STAR_KEYS: Tuple[str, ...] = ( - "stars", - "star", - "star_count", - "stars_count", - "starcount", - "starscount", - "starQuantity", - "starquantity", -) - -_PRICE_KEYS: Tuple[str, ...] = ( - "price", - "amount", - "total", - "total_price", - "totalPrice", - "total_amount", - "totalAmount", - "value", -) - - -@dataclass(frozen=True, slots=True) -class _RequestSpec: - method: str - url: str - params: Optional[dict[str, Any]] = None - json_payload: Optional[dict[str, Any]] = None - - -class TelegramStarsRateService: - """Получает и кэширует актуальный курс Telegram Stars.""" - - _REQUESTS: Sequence[_RequestSpec] = ( - _RequestSpec( - method="POST", - url="https://pay.telegram.org/api/index", - json_payload={ - "method": "getStarsExchangeRates", - "params": {"currency": "RUB"}, - }, - ), - _RequestSpec( - method="GET", - url="https://pay.telegram.org/api/index", - params={"act": "pack", "type": "stars"}, - ), - ) - - _REQUEST_HEADERS = { - "User-Agent": "Mozilla/5.0 (compatible; RemnawaveBot/1.0)", - "Accept": "application/json, text/plain, */*", - "Referer": "https://pay.telegram.org/", - "Origin": "https://pay.telegram.org", - } - - _REFRESH_INTERVAL_SECONDS = 15 * 60 - _MIN_RETRY_INTERVAL_SECONDS = 60 - _MIN_REASONABLE_RATE = 0.01 - _MAX_REASONABLE_RATE = 100.0 - - def __init__(self) -> None: - self._rate: Optional[float] = float(settings.TELEGRAM_STARS_RATE_RUB or 0) - self._last_update: float = 0.0 - self._last_attempt: float = 0.0 - self._lock = asyncio.Lock() - self._background_task: Optional[asyncio.Task[Optional[float]]] = None - - def get_cached_rate(self) -> Optional[float]: - """Возвращает закэшированный курс Stars.""" - - if self._rate and self._rate >= self._MIN_REASONABLE_RATE: - return self._rate - return None - - def ensure_refresh(self, force: bool = False) -> None: - """Гарантирует запуск обновления курса в фоне при необходимости.""" - - if settings.TELEGRAM_STARS_CUSTOM_RATE_ENABLED: - return - - if not force and not self._is_refresh_needed(): - return - - try: - loop = asyncio.get_running_loop() - except RuntimeError: - try: - asyncio.run(self.refresh_rate(force=force)) - except RuntimeError: - logger.debug("Не удалось синхронно обновить курс Stars") - return - - if self._background_task and not self._background_task.done(): - return - - self._background_task = loop.create_task( - self.refresh_rate(force=force), - name="telegram-stars-rate-refresh", - ) - - async def refresh_rate(self, force: bool = False) -> Optional[float]: - """Асинхронно обновляет курс Stars.""" - - if settings.TELEGRAM_STARS_CUSTOM_RATE_ENABLED: - return float(settings.TELEGRAM_STARS_RATE_RUB) - - if not force and not self._is_refresh_needed(): - return self.get_cached_rate() - - async with self._lock: - if not force and not self._is_refresh_needed(): - return self.get_cached_rate() - - self._last_attempt = time.monotonic() - - try: - rate = await self._fetch_rate() - except Exception as error: - logger.warning("Не удалось получить курс Telegram Stars: %s", error) - return self.get_cached_rate() - - if rate is None: - logger.debug("API Telegram Stars не вернуло валидный курс") - return self.get_cached_rate() - - self._rate = rate - self._last_update = time.monotonic() - settings.TELEGRAM_STARS_RATE_RUB = rate - logger.info("Актуальный курс Telegram Stars обновлён: %.4f ₽/⭐", rate) - return rate - - def _is_refresh_needed(self) -> bool: - now = time.monotonic() - - if self._rate is None or self._rate < self._MIN_REASONABLE_RATE: - return now - self._last_attempt >= self._MIN_RETRY_INTERVAL_SECONDS - - if now - self._last_update >= self._REFRESH_INTERVAL_SECONDS: - return now - self._last_attempt >= self._MIN_RETRY_INTERVAL_SECONDS - - return False - - async def _fetch_rate(self) -> Optional[float]: - timeout = aiohttp.ClientTimeout(total=10) - - async with aiohttp.ClientSession(timeout=timeout) as session: - for request in self._REQUESTS: - try: - response = await session.request( - method=request.method, - url=request.url, - headers=self._REQUEST_HEADERS, - params=request.params, - json=request.json_payload, - ) - except aiohttp.ClientError as error: - logger.debug( - "Ошибка запроса курса Stars (%s %s): %s", - request.method, - request.url, - error, - ) - continue - - if response.status >= 400: - body = await response.text() - logger.debug( - "API Telegram Stars ответило %s: %s", - response.status, - _truncate(body, 200), - ) - continue - - data = await self._read_json(response) - if data is None: - continue - - rate = self._extract_rate(data) - if rate is not None: - return rate - - return None - - async def _read_json(self, response: aiohttp.ClientResponse) -> Optional[Any]: - try: - return await response.json(content_type=None) - except (aiohttp.ContentTypeError, json.JSONDecodeError, ValueError): - text = await response.text() - - try: - return json.loads(text) - except json.JSONDecodeError: - logger.debug("Не удалось распарсить ответ Telegram Stars как JSON") - return None - - @classmethod - def _extract_rate(cls, data: Any) -> Optional[float]: - candidates: list[Tuple[float, float]] = [] - cls._collect_rate_candidates(data, candidates) - - if not candidates: - return None - - best_rate, _ = max( - candidates, - key=lambda item: (item[1], -item[0]), - ) - return best_rate - - @classmethod - def _collect_rate_candidates( - cls, - payload: Any, - result: list[Tuple[float, float]], - ) -> None: - if isinstance(payload, dict): - direct_rate = cls._parse_direct_rate(payload) - if direct_rate is not None: - result.append((direct_rate, math.inf)) - - pack_rate = cls._parse_pack_rate(payload) - if pack_rate is not None: - result.append(pack_rate) - - for value in payload.values(): - cls._collect_rate_candidates(value, result) - - elif isinstance(payload, list): - for item in payload: - cls._collect_rate_candidates(item, result) - - @classmethod - def _parse_direct_rate(cls, data: dict[str, Any]) -> Optional[float]: - for key, value in data.items(): - normalized_key = key.lower() - if normalized_key in _RATE_KEYS: - if isinstance(value, dict): - price = cls._parse_price_value(value) - normalized_rate = cls._normalize_rate(price) - if normalized_rate is not None: - return normalized_rate - continue - - numeric = cls._coerce_number(value) - normalized_rate = cls._normalize_rate(numeric) - if normalized_rate is not None: - return normalized_rate - - return None - - @classmethod - def _parse_pack_rate(cls, data: dict[str, Any]) -> Optional[Tuple[float, float]]: - stars = cls._parse_stars_value(data) - if stars is None or stars <= 0: - return None - - price = cls._parse_price_value(data) - if price is None or price <= 0: - return None - - rate = cls._normalize_rate(price / stars) - if rate is None: - return None - - return rate, float(stars) - - @classmethod - def _parse_stars_value(cls, data: dict[str, Any]) -> Optional[float]: - for key in data.keys(): - normalized_key = key.lower() - if normalized_key in _STAR_KEYS: - candidate = cls._coerce_number(data[key]) - if candidate and candidate > 0: - return candidate - return None - - @classmethod - def _parse_price_value(cls, value: Any) -> Optional[float]: - if isinstance(value, dict): - currency = value.get("currency") or value.get("code") - if currency and str(currency).upper() not in {"RUB", "RUR"}: - return None - - for key in _PRICE_KEYS: - if key in value: - candidate = cls._parse_price_value(value[key]) - if candidate is not None: - return candidate - - return None - - if isinstance(value, list): - for item in value: - candidate = cls._parse_price_value(item) - if candidate is not None: - return candidate - return None - - return cls._coerce_number(value) - - @classmethod - def _coerce_number(cls, value: Any) -> Optional[float]: - if value is None: - return None - - if isinstance(value, (int, float)): - return float(value) - - if isinstance(value, str): - cleaned = value.strip().replace(" ", "").replace(" ", "") - if not cleaned: - return None - - match = re.search(r"-?\d+[\.,]?\d*", cleaned) - if not match: - return None - - normalized = match.group(0).replace(",", ".") - try: - return float(normalized) - except ValueError: - return None - - return None - - @classmethod - def _normalize_rate(cls, value: Optional[float]) -> Optional[float]: - if value is None or value <= 0: - return None - - rate = float(value) - attempts = 0 - while rate > cls._MAX_REASONABLE_RATE and attempts < 3: - rate /= 100 - attempts += 1 - - if cls._MIN_REASONABLE_RATE <= rate <= cls._MAX_REASONABLE_RATE: - return round(rate, 4) - - return None - - -def _truncate(value: str, max_len: int) -> str: - value = value.strip() - if len(value) <= max_len: - return value - return value[: max_len - 1] + "…" - - -telegram_stars_rate_service = TelegramStarsRateService() - diff --git a/app/webapi/app.py b/app/webapi/app.py index 4ab6c2b4..d5848663 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -1,7 +1,5 @@ from __future__ import annotations -import logging - from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -32,9 +30,6 @@ from .routes import ( ) -logger = logging.getLogger(__name__) - - OPENAPI_TAGS = [ { "name": "health", @@ -158,18 +153,4 @@ def create_web_api_app() -> FastAPI: app.include_router(polls.router, prefix="/polls", tags=["polls"]) app.include_router(logs.router, prefix="/logs", tags=["logs"]) - @app.on_event("startup") - async def refresh_stars_rate() -> None: # pragma: no cover - инфраструктурный хук - if settings.TELEGRAM_STARS_CUSTOM_RATE_ENABLED: - return - - try: - from app.services.telegram_stars_rate_service import ( # pylint: disable=import-outside-toplevel - telegram_stars_rate_service, - ) - - await telegram_stars_rate_service.refresh_rate() - except Exception as error: - logger.debug("Не удалось обновить курс Stars при старте web api: %s", error) - return app diff --git a/main.py b/main.py index 892752cd..d50e4689 100644 --- a/main.py +++ b/main.py @@ -35,7 +35,6 @@ from app.localization.loader import ensure_locale_templates from app.services.system_settings_service import bot_configuration_service from app.services.external_admin_service import ensure_external_admin_token from app.services.broadcast_service import broadcast_service -from app.services.telegram_stars_rate_service import telegram_stars_rate_service from app.utils.startup_timeline import StartupTimeline @@ -142,28 +141,6 @@ async def main(): stage.warning(f"Не удалось загрузить конфигурацию: {error}") logger.error(f"❌ Не удалось загрузить конфигурацию: {error}") - async with timeline.stage( - "Актуализация курса Telegram Stars", - "⭐", - success_message="Курс Stars актуален", - ) as stage: - try: - rate = await telegram_stars_rate_service.refresh_rate(force=True) - if settings.TELEGRAM_STARS_CUSTOM_RATE_ENABLED: - stage.log( - "Используется пользовательский курс Stars: " - f"{settings.TELEGRAM_STARS_RATE_RUB:.2f} ₽/⭐" - ) - elif rate: - stage.log(f"Текущий курс Stars: {rate:.2f} ₽/⭐") - else: - stage.warning( - "Не удалось получить курс из Telegram, используется значение из настроек" - ) - except Exception as error: - stage.warning(f"Ошибка обновления курса Stars: {error}") - logger.warning("Ошибка обновления курса Telegram Stars: %s", error) - bot = None dp = None async with timeline.stage("Настройка бота", "🤖", success_message="Бот настроен") as stage: diff --git a/tests/services/test_telegram_stars_rate_service.py b/tests/services/test_telegram_stars_rate_service.py deleted file mode 100644 index c0bdec5b..00000000 --- a/tests/services/test_telegram_stars_rate_service.py +++ /dev/null @@ -1,100 +0,0 @@ -from pathlib import Path -from unittest.mock import AsyncMock -import sys - -import pytest - -ROOT_DIR = Path(__file__).resolve().parents[2] -if str(ROOT_DIR) not in sys.path: - sys.path.insert(0, str(ROOT_DIR)) - -from app.config import settings -from app.services import telegram_stars_rate_service as rate_module -from app.services.telegram_stars_rate_service import TelegramStarsRateService - - -@pytest.mark.parametrize( - "payload,expected", - [ - ( - {"rate": 1.79}, - pytest.approx(1.79), - ), - ( - {"pricePerStar": "1.85"}, - pytest.approx(1.85), - ), - ( - { - "packs": [ - { - "stars": 250, - "price": {"currency": "RUB", "amount": 44900}, - } - ] - }, - pytest.approx(1.796, rel=1e-3), - ), - ( - { - "data": { - "options": [ - { - "star_count": "500", - "total_price": {"value": "895", "currency": "RUB"}, - } - ] - } - }, - pytest.approx(1.79, rel=1e-3), - ), - ], -) -def test_extract_rate(payload, expected): - assert TelegramStarsRateService._extract_rate(payload) == expected - - -@pytest.mark.asyncio -async def test_refresh_rate_updates_settings(monkeypatch): - service = TelegramStarsRateService() - monkeypatch.setattr(settings, "TELEGRAM_STARS_CUSTOM_RATE_ENABLED", False, raising=False) - monkeypatch.setattr(settings, "TELEGRAM_STARS_RATE_RUB", 1.3, raising=False) - monkeypatch.setattr(service, "_fetch_rate", AsyncMock(return_value=1.91)) - - rate = await service.refresh_rate(force=True) - - assert rate == pytest.approx(1.91) - assert settings.TELEGRAM_STARS_RATE_RUB == pytest.approx(1.91) - - -@pytest.mark.asyncio -async def test_refresh_rate_respects_custom_setting(monkeypatch): - service = TelegramStarsRateService() - monkeypatch.setattr(settings, "TELEGRAM_STARS_CUSTOM_RATE_ENABLED", True, raising=False) - monkeypatch.setattr(settings, "TELEGRAM_STARS_RATE_RUB", 2.05, raising=False) - fetch_mock = AsyncMock(return_value=1.5) - monkeypatch.setattr(service, "_fetch_rate", fetch_mock) - - rate = await service.refresh_rate(force=True) - - assert rate == pytest.approx(2.05) - fetch_mock.assert_not_awaited() - - -def test_settings_get_stars_rate_uses_dynamic(monkeypatch): - custom_service = TelegramStarsRateService() - monkeypatch.setattr(rate_module, "telegram_stars_rate_service", custom_service, raising=False) - monkeypatch.setattr(settings, "TELEGRAM_STARS_CUSTOM_RATE_ENABLED", False, raising=False) - monkeypatch.setattr(settings, "TELEGRAM_STARS_RATE_RUB", 1.3, raising=False) - - custom_service._rate = 2.34 - - assert settings.get_stars_rate() == pytest.approx(2.34) - - -def test_settings_get_stars_rate_uses_custom(monkeypatch): - monkeypatch.setattr(settings, "TELEGRAM_STARS_CUSTOM_RATE_ENABLED", True, raising=False) - monkeypatch.setattr(settings, "TELEGRAM_STARS_RATE_RUB", 3.21, raising=False) - - assert settings.get_stars_rate() == pytest.approx(3.21) - From 54fc1c029cdc92d077f855ae68aa0af2b5cc8558 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 1 Nov 2025 01:19:31 +0300 Subject: [PATCH 5/8] Allow referral commissions on any top-up amount --- app/services/referral_service.py | 113 +++++++++++++++++------- tests/services/test_referral_service.py | 67 ++++++++++++++ 2 files changed, 146 insertions(+), 34 deletions(-) create mode 100644 tests/services/test_referral_service.py diff --git a/app/services/referral_service.py b/app/services/referral_service.py index bbf6e810..2302cf45 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -89,16 +89,64 @@ async def process_referral_topup( logger.info(f"Пользователь {user_id} не является рефералом") return True - if topup_amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS: - logger.info(f"Пополнение {user_id} на {topup_amount_kopeks/100}₽ меньше минимума") - return True - referrer = await get_user_by_id(db, user.referred_by_id) if not referrer: logger.error(f"Реферер {user.referred_by_id} не найден") return False - + + qualifies_for_first_bonus = ( + topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS + ) + commission_amount = 0 + if settings.REFERRAL_COMMISSION_PERCENT > 0: + commission_amount = int( + topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100 + ) + if not user.has_made_first_topup: + if not qualifies_for_first_bonus: + logger.info( + "Пополнение %s на %s₽ меньше минимума для первого бонуса, но комиссия будет начислена", + user_id, + topup_amount_kopeks / 100, + ) + + if commission_amount > 0: + await add_user_balance( + db, + referrer, + commission_amount, + f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}", + bot=bot, + ) + + await create_referral_earning( + db=db, + user_id=referrer.id, + referral_id=user.id, + amount_kopeks=commission_amount, + reason="referral_commission_topup", + ) + + logger.info( + "💰 Комиссия с пополнения: %s получил %s₽ (до первого бонуса)", + referrer.telegram_id, + commission_amount / 100, + ) + + if bot: + commission_notification = ( + f"💰 Реферальная комиссия!\n\n" + f"Ваш реферал {user.full_name} пополнил баланс на " + f"{settings.format_price(topup_amount_kopeks)}\n\n" + f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): " + f"{settings.format_price(commission_amount)}\n\n" + f"💎 Средства зачислены на ваш баланс." + ) + await send_referral_notification(bot, referrer.telegram_id, commission_notification) + + return True + user.has_made_first_topup = True await db.commit() @@ -161,36 +209,33 @@ async def process_referral_topup( await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification) else: - if settings.REFERRAL_COMMISSION_PERCENT > 0: - commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100) - - if commission_amount > 0: - await add_user_balance( - db, referrer, commission_amount, - f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}", - bot=bot + if commission_amount > 0: + await add_user_balance( + db, referrer, commission_amount, + f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}", + bot=bot + ) + + await create_referral_earning( + db=db, + user_id=referrer.id, + referral_id=user.id, + amount_kopeks=commission_amount, + reason="referral_commission_topup" + ) + + logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽") + + if bot: + commission_notification = ( + f"💰 Реферальная комиссия!\n\n" + f"Ваш реферал {user.full_name} пополнил баланс на " + f"{settings.format_price(topup_amount_kopeks)}\n\n" + f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): " + f"{settings.format_price(commission_amount)}\n\n" + f"💎 Средства зачислены на ваш баланс." ) - - await create_referral_earning( - db=db, - user_id=referrer.id, - referral_id=user.id, - amount_kopeks=commission_amount, - reason="referral_commission_topup" - ) - - logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽") - - if bot: - commission_notification = ( - f"💰 Реферальная комиссия!\n\n" - f"Ваш реферал {user.full_name} пополнил баланс на " - f"{settings.format_price(topup_amount_kopeks)}\n\n" - f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): " - f"{settings.format_price(commission_amount)}\n\n" - f"💎 Средства зачислены на ваш баланс." - ) - await send_referral_notification(bot, referrer.telegram_id, commission_notification) + await send_referral_notification(bot, referrer.telegram_id, commission_notification) return True diff --git a/tests/services/test_referral_service.py b/tests/services/test_referral_service.py new file mode 100644 index 00000000..c59c5bd5 --- /dev/null +++ b/tests/services/test_referral_service.py @@ -0,0 +1,67 @@ +from pathlib import Path +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.services import referral_service # noqa: E402 + + +@pytest.mark.asyncio +async def test_commission_accrues_before_minimum_first_topup(monkeypatch): + user = SimpleNamespace( + id=1, + telegram_id=101, + full_name="Test User", + referred_by_id=2, + has_made_first_topup=False, + ) + referrer = SimpleNamespace( + id=2, + telegram_id=202, + full_name="Referrer", + ) + + db = SimpleNamespace( + commit=AsyncMock(), + execute=AsyncMock(), + ) + + get_user_mock = AsyncMock(side_effect=[user, referrer]) + monkeypatch.setattr(referral_service, "get_user_by_id", get_user_mock) + add_user_balance_mock = AsyncMock() + monkeypatch.setattr(referral_service, "add_user_balance", add_user_balance_mock) + create_referral_earning_mock = AsyncMock() + monkeypatch.setattr(referral_service, "create_referral_earning", create_referral_earning_mock) + + monkeypatch.setattr(referral_service.settings, "REFERRAL_MINIMUM_TOPUP_KOPEKS", 20000) + monkeypatch.setattr(referral_service.settings, "REFERRAL_FIRST_TOPUP_BONUS_KOPEKS", 5000) + monkeypatch.setattr(referral_service.settings, "REFERRAL_INVITER_BONUS_KOPEKS", 10000) + monkeypatch.setattr(referral_service.settings, "REFERRAL_COMMISSION_PERCENT", 25) + + topup_amount = 15000 + + result = await referral_service.process_referral_topup(db, user.id, topup_amount) + + assert result is True + assert user.has_made_first_topup is False + + add_user_balance_mock.assert_awaited_once() + add_call = add_user_balance_mock.await_args + assert add_call.args[1] is referrer + assert add_call.args[2] == 3750 + assert "Комиссия" in add_call.args[3] + assert add_call.kwargs.get("bot") is None + + create_referral_earning_mock.assert_awaited_once() + earning_call = create_referral_earning_mock.await_args + assert earning_call.kwargs["amount_kopeks"] == 3750 + assert earning_call.kwargs["reason"] == "referral_commission_topup" + + db.commit.assert_not_awaited() + db.execute.assert_not_awaited() From 060f0ca199428fd29164b725eb6e8472525a6a8c Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 1 Nov 2025 01:35:20 +0300 Subject: [PATCH 6/8] Add admin user referrals management --- app/database/crud/user.py | 28 ++- app/handlers/admin/users.py | 401 ++++++++++++++++++++++++++++++- app/keyboards/admin.py | 6 + app/localization/locales/ru.json | 20 ++ app/services/user_service.py | 68 +++++- app/states.py | 1 + 6 files changed, 519 insertions(+), 5 deletions(-) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 8613ad20..e0295e73 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -62,10 +62,34 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona .where(User.telegram_id == telegram_id) ) user = result.scalar_one_or_none() - + if user and user.subscription: _ = user.subscription.is_active - + + return user + + +async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]: + if not username: + return None + + normalized = username.lower() + + result = await db.execute( + select(User) + .options( + selectinload(User.subscription), + selectinload(User.promo_group), + selectinload(User.referrer), + ) + .where(func.lower(User.username) == normalized) + ) + + user = result.scalar_one_or_none() + + if user and user.subscription: + _ = user.subscription.is_active + return user diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index ed18af5d..38426f73 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1,6 +1,7 @@ import logging +import re from datetime import datetime, timedelta -from typing import Optional +from typing import Optional, List, Tuple from aiogram import Dispatcher, types, F from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton @@ -10,7 +11,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import AdminStates from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType -from app.database.crud.user import get_user_by_id +from app.database.crud.user import ( + get_user_by_id, + get_user_by_telegram_id, + get_user_by_username, + get_referrals, +) from app.database.crud.campaign import ( get_campaign_registration_by_user, get_campaign_statistics, @@ -1489,6 +1495,382 @@ async def show_user_management( await callback.answer() +async def _build_user_referrals_view( + db: AsyncSession, + language: str, + user_id: int, + limit: int = 30, +) -> Optional[Tuple[str, InlineKeyboardMarkup]]: + texts = get_texts(language) + + user = await get_user_by_id(db, user_id) + if not user: + return None + + referrals = await get_referrals(db, user_id) + + header = texts.t( + "ADMIN_USER_REFERRALS_TITLE", + "🤝 Рефералы пользователя", + ) + summary = texts.t( + "ADMIN_USER_REFERRALS_SUMMARY", + "👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}", + ).format( + name=user.full_name, + telegram_id=user.telegram_id, + count=len(referrals), + ) + + lines: List[str] = [header, summary] + + if referrals: + lines.append( + texts.t( + "ADMIN_USER_REFERRALS_LIST_HEADER", + "Список рефералов:", + ) + ) + items = [] + for referral in referrals[:limit]: + username_part = ( + f", @{referral.username}" + if referral.username + else "" + ) + items.append( + texts.t( + "ADMIN_USER_REFERRALS_LIST_ITEM", + "• {name} (ID: {telegram_id}{username_part})", + ).format( + name=referral.full_name, + telegram_id=referral.telegram_id, + username_part=username_part, + ) + ) + + lines.append("\n".join(items)) + + if len(referrals) > limit: + remaining = len(referrals) - limit + lines.append( + texts.t( + "ADMIN_USER_REFERRALS_LIST_TRUNCATED", + "• … и ещё {count} рефералов", + ).format(count=remaining) + ) + else: + lines.append( + texts.t( + "ADMIN_USER_REFERRALS_EMPTY", + "Рефералов пока нет.", + ) + ) + + lines.append( + texts.t( + "ADMIN_USER_REFERRALS_EDIT_HINT", + "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.", + ) + ) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t( + "ADMIN_USER_REFERRALS_EDIT_BUTTON", + "✏️ Редактировать", + ), + callback_data=f"admin_user_referrals_edit_{user_id}", + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_user_manage_{user_id}", + ) + ], + ] + ) + + return "\n\n".join(lines), keyboard + + +@admin_required +@error_handler +async def show_user_referrals( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + user_id = int(callback.data.split('_')[-1]) + + view = await _build_user_referrals_view(db, db_user.language, user_id) + if not view: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + text, keyboard = view + + await callback.message.edit_text( + text, + reply_markup=keyboard, + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_edit_user_referrals( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + user_id = int(callback.data.split('_')[-1]) + + user = await get_user_by_id(db, user_id) + if not user: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + texts = get_texts(db_user.language) + + prompt = texts.t( + "ADMIN_USER_REFERRALS_EDIT_PROMPT", + ( + "✏️ Редактирование рефералов\n\n" + "Отправьте список рефералов для пользователя {name} (ID: {telegram_id}):\n" + "• Используйте TG ID или @username\n" + "• Значения можно указывать через запятую, пробел или с новой строки\n" + "• Чтобы очистить список, отправьте 0 или слово 'нет'\n\n" + "Или нажмите кнопку ниже, чтобы отменить." + ), + ).format( + name=user.full_name, + telegram_id=user.telegram_id, + ) + + await state.update_data( + editing_referrals_user_id=user_id, + referrals_message_id=callback.message.message_id, + ) + + await callback.message.edit_text( + prompt, + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_user_referrals_{user_id}", + ) + ] + ] + ), + ) + + await state.set_state(AdminStates.editing_user_referrals) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_user_referrals( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + data = await state.get_data() + + user_id = data.get("editing_referrals_user_id") + if not user_id: + await message.answer( + texts.t( + "ADMIN_USER_REFERRALS_STATE_LOST", + "❌ Не удалось определить пользователя. Попробуйте начать сначала.", + ) + ) + await state.clear() + return + + raw_text = message.text.strip() + lower_text = raw_text.lower() + clear_keywords = {"0", "нет", "none", "пусто", "clear"} + clear_requested = lower_text in clear_keywords + + tokens: List[str] = [] + if not clear_requested: + parts = re.split(r"[,\n]+", raw_text) + for part in parts: + for token in part.split(): + cleaned = token.strip() + if cleaned and cleaned not in tokens: + tokens.append(cleaned) + + found_users: List[User] = [] + not_found: List[str] = [] + skipped_self: List[str] = [] + duplicate_tokens: List[str] = [] + + seen_ids = set() + + for token in tokens: + normalized = token.strip() + if not normalized: + continue + + if normalized.startswith("@"): + normalized = normalized[1:] + + user = None + if normalized.isdigit(): + try: + user = await get_user_by_telegram_id(db, int(normalized)) + except ValueError: + user = None + else: + user = await get_user_by_username(db, normalized) + + if not user: + not_found.append(token) + continue + + if user.id == user_id: + skipped_self.append(token) + continue + + if user.id in seen_ids: + duplicate_tokens.append(token) + continue + + seen_ids.add(user.id) + found_users.append(user) + + if not found_users and not clear_requested: + error_lines = [ + texts.t( + "ADMIN_USER_REFERRALS_NO_VALID", + "❌ Не удалось найти ни одного пользователя по введённым данным.", + ) + ] + if not_found: + error_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_INVALID_ENTRIES", + "Не найдены: {values}", + ).format(values=", ".join(not_found)) + ) + if skipped_self: + error_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_SELF_SKIPPED", + "Пропущены значения пользователя: {values}", + ).format(values=", ".join(skipped_self)) + ) + await message.answer("\n".join(error_lines)) + return + + user_service = UserService() + + new_referral_ids = [user.id for user in found_users] if not clear_requested else [] + + success, details = await user_service.update_user_referrals( + db, + user_id, + new_referral_ids, + db_user.id, + ) + + if not success: + await message.answer( + texts.t( + "ADMIN_USER_REFERRALS_UPDATE_ERROR", + "❌ Не удалось обновить рефералов. Попробуйте позже.", + ) + ) + return + + response_lines = [ + texts.t( + "ADMIN_USER_REFERRALS_UPDATED", + "✅ Список рефералов обновлён.", + ) + ] + + total_referrals = details.get("total", len(new_referral_ids)) + added = details.get("added", 0) + removed = details.get("removed", 0) + + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_UPDATED_TOTAL", + "• Текущий список: {total}", + ).format(total=total_referrals) + ) + + if added > 0: + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_UPDATED_ADDED", + "• Добавлено: {count}", + ).format(count=added) + ) + + if removed > 0: + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_UPDATED_REMOVED", + "• Удалено: {count}", + ).format(count=removed) + ) + + if not_found: + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_INVALID_ENTRIES", + "Не найдены: {values}", + ).format(values=", ".join(not_found)) + ) + + if skipped_self: + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_SELF_SKIPPED", + "Пропущены значения пользователя: {values}", + ).format(values=", ".join(skipped_self)) + ) + + if duplicate_tokens: + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_DUPLICATES", + "Игнорированы дубли: {values}", + ).format(values=", ".join(duplicate_tokens)) + ) + + view = await _build_user_referrals_view(db, db_user.language, user_id) + message_id = data.get("referrals_message_id") + + if view and message_id: + try: + await message.bot.edit_message_text( + view[0], + chat_id=message.chat.id, + message_id=message_id, + reply_markup=view[1], + ) + except TelegramBadRequest: + await message.answer(view[0], reply_markup=view[1]) + elif view: + await message.answer(view[0], reply_markup=view[1]) + + await message.answer("\n".join(response_lines)) + await state.clear() + async def _render_user_promo_group( message: types.Message, language: str, @@ -4159,6 +4541,21 @@ def register_handlers(dp: Dispatcher): AdminStates.editing_user_balance ) + dp.callback_query.register( + show_user_referrals, + F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit") + ) + + dp.callback_query.register( + start_edit_user_referrals, + F.data.startswith("admin_user_referrals_edit_") + ) + + dp.message.register( + process_edit_user_referrals, + AdminStates.editing_user_referrals + ) + dp.callback_query.register( start_send_user_message, F.data.startswith("admin_user_send_message_") diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 5d993543..fb0b21b1 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -760,6 +760,12 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str = callback_data=f"admin_user_promo_group_{user_id}" ) ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"), + callback_data=f"admin_user_referrals_{user_id}" + ) + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"), diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 67437d35..fc188245 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -713,6 +713,26 @@ "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа", + "ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы", + "ADMIN_USER_REFERRALS_TITLE": "🤝 Рефералы пользователя", + "ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}", + "ADMIN_USER_REFERRALS_LIST_HEADER": "Список рефералов:", + "ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: {telegram_id}{username_part})", + "ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов", + "ADMIN_USER_REFERRALS_EMPTY": "Рефералов пока нет.", + "ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.", + "ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редактировать", + "ADMIN_USER_REFERRALS_EDIT_PROMPT": "✏️ Редактирование рефералов\n\nОтправьте список рефералов для пользователя {name} (ID: {telegram_id}):\n• Используйте TG ID или @username\n• Значения можно указывать через запятую, пробел или с новой строки\n• Чтобы очистить список, отправьте 0 или слово 'нет'\n\nИли нажмите кнопку ниже, чтобы отменить.", + "ADMIN_USER_REFERRALS_STATE_LOST": "❌ Не удалось определить пользователя. Попробуйте начать сначала.", + "ADMIN_USER_REFERRALS_NO_VALID": "❌ Не удалось найти ни одного пользователя по введённым данным.", + "ADMIN_USER_REFERRALS_INVALID_ENTRIES": "Не найдены: {values}", + "ADMIN_USER_REFERRALS_SELF_SKIPPED": "Пропущены значения пользователя: {values}", + "ADMIN_USER_REFERRALS_DUPLICATES": "Игнорированы дубли: {values}", + "ADMIN_USER_REFERRALS_UPDATE_ERROR": "❌ Не удалось обновить рефералов. Попробуйте позже.", + "ADMIN_USER_REFERRALS_UPDATED": "✅ Список рефералов обновлён.", + "ADMIN_USER_REFERRALS_UPDATED_TOTAL": "• Текущий список: {total}", + "ADMIN_USER_REFERRALS_UPDATED_ADDED": "• Добавлено: {count}", + "ADMIN_USER_REFERRALS_UPDATED_REMOVED": "• Удалено: {count}", "ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}", "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", diff --git a/app/services/user_service.py b/app/services/user_service.py index 6c19472b..9d551fec 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -9,7 +9,7 @@ from app.database.crud.user import ( get_user_by_id, get_user_by_telegram_id, get_users_list, get_users_count, get_users_statistics, get_inactive_users, add_user_balance, subtract_user_balance, update_user, delete_user, - get_users_spending_stats + get_users_spending_stats, get_referrals ) from app.database.crud.promo_group import get_promo_group_by_id from app.database.crud.transaction import get_user_transactions_count @@ -411,6 +411,72 @@ class UserService: logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}") return False, None, None, None + async def update_user_referrals( + self, + db: AsyncSession, + user_id: int, + referral_user_ids: List[int], + admin_id: int, + ) -> Tuple[bool, Dict[str, int]]: + try: + user = await get_user_by_id(db, user_id) + if not user: + return False, {"error": "user_not_found"} + + unique_ids: List[int] = [] + for referral_id in referral_user_ids: + if referral_id == user_id: + continue + if referral_id not in unique_ids: + unique_ids.append(referral_id) + + current_referrals = await get_referrals(db, user_id) + current_ids = {ref.id for ref in current_referrals} + + to_assign = unique_ids + to_remove = [rid for rid in current_ids if rid not in unique_ids] + to_add = [rid for rid in unique_ids if rid not in current_ids] + + if to_assign: + await db.execute( + update(User) + .where(User.id.in_(to_assign)) + .values(referred_by_id=user_id) + ) + + if to_remove: + await db.execute( + update(User) + .where(User.id.in_(to_remove)) + .values(referred_by_id=None) + ) + + await db.commit() + + logger.info( + "Админ %s обновил рефералов пользователя %s: добавлено %s, удалено %s, всего %s", + admin_id, + user_id, + len(to_add), + len(to_remove), + len(unique_ids), + ) + + return True, { + "added": len(to_add), + "removed": len(to_remove), + "total": len(unique_ids), + } + + except Exception as e: + await db.rollback() + logger.error( + "Ошибка обновления рефералов пользователя %s: %s", + user_id, + e, + ) + return False, {"error": "update_failed"} + async def block_user( self, db: AsyncSession, diff --git a/app/states.py b/app/states.py index 549e314f..981c314a 100644 --- a/app/states.py +++ b/app/states.py @@ -90,6 +90,7 @@ class AdminStates(StatesGroup): editing_device_price = State() editing_user_devices = State() editing_user_traffic = State() + editing_user_referrals = State() editing_rules_page = State() editing_privacy_policy = State() From 165691412b7542a7fc3dccdfe4ea151cde5da546 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 1 Nov 2025 01:58:08 +0300 Subject: [PATCH 7/8] Clear referral edit state when returning to list --- app/database/crud/user.py | 28 ++- app/handlers/admin/users.py | 414 ++++++++++++++++++++++++++++++- app/keyboards/admin.py | 6 + app/localization/locales/ru.json | 20 ++ app/services/user_service.py | 68 ++++- app/states.py | 1 + 6 files changed, 532 insertions(+), 5 deletions(-) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 8613ad20..e0295e73 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -62,10 +62,34 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona .where(User.telegram_id == telegram_id) ) user = result.scalar_one_or_none() - + if user and user.subscription: _ = user.subscription.is_active - + + return user + + +async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]: + if not username: + return None + + normalized = username.lower() + + result = await db.execute( + select(User) + .options( + selectinload(User.subscription), + selectinload(User.promo_group), + selectinload(User.referrer), + ) + .where(func.lower(User.username) == normalized) + ) + + user = result.scalar_one_or_none() + + if user and user.subscription: + _ = user.subscription.is_active + return user diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index ed18af5d..32d3b02e 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1,6 +1,7 @@ import logging +import re from datetime import datetime, timedelta -from typing import Optional +from typing import Optional, List, Tuple from aiogram import Dispatcher, types, F from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton @@ -10,7 +11,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import AdminStates from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType -from app.database.crud.user import get_user_by_id +from app.database.crud.user import ( + get_user_by_id, + get_user_by_telegram_id, + get_user_by_username, + get_referrals, +) from app.database.crud.campaign import ( get_campaign_registration_by_user, get_campaign_statistics, @@ -1489,6 +1495,395 @@ async def show_user_management( await callback.answer() +async def _build_user_referrals_view( + db: AsyncSession, + language: str, + user_id: int, + limit: int = 30, +) -> Optional[Tuple[str, InlineKeyboardMarkup]]: + texts = get_texts(language) + + user = await get_user_by_id(db, user_id) + if not user: + return None + + referrals = await get_referrals(db, user_id) + + header = texts.t( + "ADMIN_USER_REFERRALS_TITLE", + "🤝 Рефералы пользователя", + ) + summary = texts.t( + "ADMIN_USER_REFERRALS_SUMMARY", + "👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}", + ).format( + name=user.full_name, + telegram_id=user.telegram_id, + count=len(referrals), + ) + + lines: List[str] = [header, summary] + + if referrals: + lines.append( + texts.t( + "ADMIN_USER_REFERRALS_LIST_HEADER", + "Список рефералов:", + ) + ) + items = [] + for referral in referrals[:limit]: + username_part = ( + f", @{referral.username}" + if referral.username + else "" + ) + items.append( + texts.t( + "ADMIN_USER_REFERRALS_LIST_ITEM", + "• {name} (ID: {telegram_id}{username_part})", + ).format( + name=referral.full_name, + telegram_id=referral.telegram_id, + username_part=username_part, + ) + ) + + lines.append("\n".join(items)) + + if len(referrals) > limit: + remaining = len(referrals) - limit + lines.append( + texts.t( + "ADMIN_USER_REFERRALS_LIST_TRUNCATED", + "• … и ещё {count} рефералов", + ).format(count=remaining) + ) + else: + lines.append( + texts.t( + "ADMIN_USER_REFERRALS_EMPTY", + "Рефералов пока нет.", + ) + ) + + lines.append( + texts.t( + "ADMIN_USER_REFERRALS_EDIT_HINT", + "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.", + ) + ) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t( + "ADMIN_USER_REFERRALS_EDIT_BUTTON", + "✏️ Редактировать", + ), + callback_data=f"admin_user_referrals_edit_{user_id}", + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_user_manage_{user_id}", + ) + ], + ] + ) + + return "\n\n".join(lines), keyboard + + +@admin_required +@error_handler +async def show_user_referrals( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + user_id = int(callback.data.split('_')[-1]) + + current_state = await state.get_state() + if current_state == AdminStates.editing_user_referrals: + data = await state.get_data() + preserved_data = { + key: value + for key, value in data.items() + if key not in {"editing_referrals_user_id", "referrals_message_id"} + } + await state.clear() + if preserved_data: + await state.update_data(**preserved_data) + + view = await _build_user_referrals_view(db, db_user.language, user_id) + if not view: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + text, keyboard = view + + await callback.message.edit_text( + text, + reply_markup=keyboard, + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_edit_user_referrals( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + user_id = int(callback.data.split('_')[-1]) + + user = await get_user_by_id(db, user_id) + if not user: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + texts = get_texts(db_user.language) + + prompt = texts.t( + "ADMIN_USER_REFERRALS_EDIT_PROMPT", + ( + "✏️ Редактирование рефералов\n\n" + "Отправьте список рефералов для пользователя {name} (ID: {telegram_id}):\n" + "• Используйте TG ID или @username\n" + "• Значения можно указывать через запятую, пробел или с новой строки\n" + "• Чтобы очистить список, отправьте 0 или слово 'нет'\n\n" + "Или нажмите кнопку ниже, чтобы отменить." + ), + ).format( + name=user.full_name, + telegram_id=user.telegram_id, + ) + + await state.update_data( + editing_referrals_user_id=user_id, + referrals_message_id=callback.message.message_id, + ) + + await callback.message.edit_text( + prompt, + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_user_referrals_{user_id}", + ) + ] + ] + ), + ) + + await state.set_state(AdminStates.editing_user_referrals) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_user_referrals( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + data = await state.get_data() + + user_id = data.get("editing_referrals_user_id") + if not user_id: + await message.answer( + texts.t( + "ADMIN_USER_REFERRALS_STATE_LOST", + "❌ Не удалось определить пользователя. Попробуйте начать сначала.", + ) + ) + await state.clear() + return + + raw_text = message.text.strip() + lower_text = raw_text.lower() + clear_keywords = {"0", "нет", "none", "пусто", "clear"} + clear_requested = lower_text in clear_keywords + + tokens: List[str] = [] + if not clear_requested: + parts = re.split(r"[,\n]+", raw_text) + for part in parts: + for token in part.split(): + cleaned = token.strip() + if cleaned and cleaned not in tokens: + tokens.append(cleaned) + + found_users: List[User] = [] + not_found: List[str] = [] + skipped_self: List[str] = [] + duplicate_tokens: List[str] = [] + + seen_ids = set() + + for token in tokens: + normalized = token.strip() + if not normalized: + continue + + if normalized.startswith("@"): + normalized = normalized[1:] + + user = None + if normalized.isdigit(): + try: + user = await get_user_by_telegram_id(db, int(normalized)) + except ValueError: + user = None + else: + user = await get_user_by_username(db, normalized) + + if not user: + not_found.append(token) + continue + + if user.id == user_id: + skipped_self.append(token) + continue + + if user.id in seen_ids: + duplicate_tokens.append(token) + continue + + seen_ids.add(user.id) + found_users.append(user) + + if not found_users and not clear_requested: + error_lines = [ + texts.t( + "ADMIN_USER_REFERRALS_NO_VALID", + "❌ Не удалось найти ни одного пользователя по введённым данным.", + ) + ] + if not_found: + error_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_INVALID_ENTRIES", + "Не найдены: {values}", + ).format(values=", ".join(not_found)) + ) + if skipped_self: + error_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_SELF_SKIPPED", + "Пропущены значения пользователя: {values}", + ).format(values=", ".join(skipped_self)) + ) + await message.answer("\n".join(error_lines)) + return + + user_service = UserService() + + new_referral_ids = [user.id for user in found_users] if not clear_requested else [] + + success, details = await user_service.update_user_referrals( + db, + user_id, + new_referral_ids, + db_user.id, + ) + + if not success: + await message.answer( + texts.t( + "ADMIN_USER_REFERRALS_UPDATE_ERROR", + "❌ Не удалось обновить рефералов. Попробуйте позже.", + ) + ) + return + + response_lines = [ + texts.t( + "ADMIN_USER_REFERRALS_UPDATED", + "✅ Список рефералов обновлён.", + ) + ] + + total_referrals = details.get("total", len(new_referral_ids)) + added = details.get("added", 0) + removed = details.get("removed", 0) + + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_UPDATED_TOTAL", + "• Текущий список: {total}", + ).format(total=total_referrals) + ) + + if added > 0: + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_UPDATED_ADDED", + "• Добавлено: {count}", + ).format(count=added) + ) + + if removed > 0: + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_UPDATED_REMOVED", + "• Удалено: {count}", + ).format(count=removed) + ) + + if not_found: + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_INVALID_ENTRIES", + "Не найдены: {values}", + ).format(values=", ".join(not_found)) + ) + + if skipped_self: + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_SELF_SKIPPED", + "Пропущены значения пользователя: {values}", + ).format(values=", ".join(skipped_self)) + ) + + if duplicate_tokens: + response_lines.append( + texts.t( + "ADMIN_USER_REFERRALS_DUPLICATES", + "Игнорированы дубли: {values}", + ).format(values=", ".join(duplicate_tokens)) + ) + + view = await _build_user_referrals_view(db, db_user.language, user_id) + message_id = data.get("referrals_message_id") + + if view and message_id: + try: + await message.bot.edit_message_text( + view[0], + chat_id=message.chat.id, + message_id=message_id, + reply_markup=view[1], + ) + except TelegramBadRequest: + await message.answer(view[0], reply_markup=view[1]) + elif view: + await message.answer(view[0], reply_markup=view[1]) + + await message.answer("\n".join(response_lines)) + await state.clear() + async def _render_user_promo_group( message: types.Message, language: str, @@ -4159,6 +4554,21 @@ def register_handlers(dp: Dispatcher): AdminStates.editing_user_balance ) + dp.callback_query.register( + show_user_referrals, + F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit") + ) + + dp.callback_query.register( + start_edit_user_referrals, + F.data.startswith("admin_user_referrals_edit_") + ) + + dp.message.register( + process_edit_user_referrals, + AdminStates.editing_user_referrals + ) + dp.callback_query.register( start_send_user_message, F.data.startswith("admin_user_send_message_") diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 5d993543..fb0b21b1 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -760,6 +760,12 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str = callback_data=f"admin_user_promo_group_{user_id}" ) ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"), + callback_data=f"admin_user_referrals_{user_id}" + ) + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"), diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 67437d35..fc188245 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -713,6 +713,26 @@ "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа", + "ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы", + "ADMIN_USER_REFERRALS_TITLE": "🤝 Рефералы пользователя", + "ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}", + "ADMIN_USER_REFERRALS_LIST_HEADER": "Список рефералов:", + "ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: {telegram_id}{username_part})", + "ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов", + "ADMIN_USER_REFERRALS_EMPTY": "Рефералов пока нет.", + "ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.", + "ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редактировать", + "ADMIN_USER_REFERRALS_EDIT_PROMPT": "✏️ Редактирование рефералов\n\nОтправьте список рефералов для пользователя {name} (ID: {telegram_id}):\n• Используйте TG ID или @username\n• Значения можно указывать через запятую, пробел или с новой строки\n• Чтобы очистить список, отправьте 0 или слово 'нет'\n\nИли нажмите кнопку ниже, чтобы отменить.", + "ADMIN_USER_REFERRALS_STATE_LOST": "❌ Не удалось определить пользователя. Попробуйте начать сначала.", + "ADMIN_USER_REFERRALS_NO_VALID": "❌ Не удалось найти ни одного пользователя по введённым данным.", + "ADMIN_USER_REFERRALS_INVALID_ENTRIES": "Не найдены: {values}", + "ADMIN_USER_REFERRALS_SELF_SKIPPED": "Пропущены значения пользователя: {values}", + "ADMIN_USER_REFERRALS_DUPLICATES": "Игнорированы дубли: {values}", + "ADMIN_USER_REFERRALS_UPDATE_ERROR": "❌ Не удалось обновить рефералов. Попробуйте позже.", + "ADMIN_USER_REFERRALS_UPDATED": "✅ Список рефералов обновлён.", + "ADMIN_USER_REFERRALS_UPDATED_TOTAL": "• Текущий список: {total}", + "ADMIN_USER_REFERRALS_UPDATED_ADDED": "• Добавлено: {count}", + "ADMIN_USER_REFERRALS_UPDATED_REMOVED": "• Удалено: {count}", "ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}", "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", diff --git a/app/services/user_service.py b/app/services/user_service.py index 6c19472b..9d551fec 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -9,7 +9,7 @@ from app.database.crud.user import ( get_user_by_id, get_user_by_telegram_id, get_users_list, get_users_count, get_users_statistics, get_inactive_users, add_user_balance, subtract_user_balance, update_user, delete_user, - get_users_spending_stats + get_users_spending_stats, get_referrals ) from app.database.crud.promo_group import get_promo_group_by_id from app.database.crud.transaction import get_user_transactions_count @@ -411,6 +411,72 @@ class UserService: logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}") return False, None, None, None + async def update_user_referrals( + self, + db: AsyncSession, + user_id: int, + referral_user_ids: List[int], + admin_id: int, + ) -> Tuple[bool, Dict[str, int]]: + try: + user = await get_user_by_id(db, user_id) + if not user: + return False, {"error": "user_not_found"} + + unique_ids: List[int] = [] + for referral_id in referral_user_ids: + if referral_id == user_id: + continue + if referral_id not in unique_ids: + unique_ids.append(referral_id) + + current_referrals = await get_referrals(db, user_id) + current_ids = {ref.id for ref in current_referrals} + + to_assign = unique_ids + to_remove = [rid for rid in current_ids if rid not in unique_ids] + to_add = [rid for rid in unique_ids if rid not in current_ids] + + if to_assign: + await db.execute( + update(User) + .where(User.id.in_(to_assign)) + .values(referred_by_id=user_id) + ) + + if to_remove: + await db.execute( + update(User) + .where(User.id.in_(to_remove)) + .values(referred_by_id=None) + ) + + await db.commit() + + logger.info( + "Админ %s обновил рефералов пользователя %s: добавлено %s, удалено %s, всего %s", + admin_id, + user_id, + len(to_add), + len(to_remove), + len(unique_ids), + ) + + return True, { + "added": len(to_add), + "removed": len(to_remove), + "total": len(unique_ids), + } + + except Exception as e: + await db.rollback() + logger.error( + "Ошибка обновления рефералов пользователя %s: %s", + user_id, + e, + ) + return False, {"error": "update_failed"} + async def block_user( self, db: AsyncSession, diff --git a/app/states.py b/app/states.py index 549e314f..981c314a 100644 --- a/app/states.py +++ b/app/states.py @@ -90,6 +90,7 @@ class AdminStates(StatesGroup): editing_device_price = State() editing_user_devices = State() editing_user_traffic = State() + editing_user_referrals = State() editing_rules_page = State() editing_privacy_policy = State() From dc32f53585e7a8c773690152a168946b94944ec4 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 1 Nov 2025 01:58:29 +0300 Subject: [PATCH 8/8] Revert "Add admin user referrals management" --- app/database/crud/user.py | 28 +-- app/handlers/admin/users.py | 401 +------------------------------ app/keyboards/admin.py | 6 - app/localization/locales/ru.json | 20 -- app/services/user_service.py | 68 +----- app/states.py | 1 - 6 files changed, 5 insertions(+), 519 deletions(-) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index e0295e73..8613ad20 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -62,34 +62,10 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona .where(User.telegram_id == telegram_id) ) user = result.scalar_one_or_none() - + if user and user.subscription: _ = user.subscription.is_active - - return user - - -async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]: - if not username: - return None - - normalized = username.lower() - - result = await db.execute( - select(User) - .options( - selectinload(User.subscription), - selectinload(User.promo_group), - selectinload(User.referrer), - ) - .where(func.lower(User.username) == normalized) - ) - - user = result.scalar_one_or_none() - - if user and user.subscription: - _ = user.subscription.is_active - + return user diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 38426f73..ed18af5d 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1,7 +1,6 @@ import logging -import re from datetime import datetime, timedelta -from typing import Optional, List, Tuple +from typing import Optional from aiogram import Dispatcher, types, F from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton @@ -11,12 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import AdminStates from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType -from app.database.crud.user import ( - get_user_by_id, - get_user_by_telegram_id, - get_user_by_username, - get_referrals, -) +from app.database.crud.user import get_user_by_id from app.database.crud.campaign import ( get_campaign_registration_by_user, get_campaign_statistics, @@ -1495,382 +1489,6 @@ async def show_user_management( await callback.answer() -async def _build_user_referrals_view( - db: AsyncSession, - language: str, - user_id: int, - limit: int = 30, -) -> Optional[Tuple[str, InlineKeyboardMarkup]]: - texts = get_texts(language) - - user = await get_user_by_id(db, user_id) - if not user: - return None - - referrals = await get_referrals(db, user_id) - - header = texts.t( - "ADMIN_USER_REFERRALS_TITLE", - "🤝 Рефералы пользователя", - ) - summary = texts.t( - "ADMIN_USER_REFERRALS_SUMMARY", - "👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}", - ).format( - name=user.full_name, - telegram_id=user.telegram_id, - count=len(referrals), - ) - - lines: List[str] = [header, summary] - - if referrals: - lines.append( - texts.t( - "ADMIN_USER_REFERRALS_LIST_HEADER", - "Список рефералов:", - ) - ) - items = [] - for referral in referrals[:limit]: - username_part = ( - f", @{referral.username}" - if referral.username - else "" - ) - items.append( - texts.t( - "ADMIN_USER_REFERRALS_LIST_ITEM", - "• {name} (ID: {telegram_id}{username_part})", - ).format( - name=referral.full_name, - telegram_id=referral.telegram_id, - username_part=username_part, - ) - ) - - lines.append("\n".join(items)) - - if len(referrals) > limit: - remaining = len(referrals) - limit - lines.append( - texts.t( - "ADMIN_USER_REFERRALS_LIST_TRUNCATED", - "• … и ещё {count} рефералов", - ).format(count=remaining) - ) - else: - lines.append( - texts.t( - "ADMIN_USER_REFERRALS_EMPTY", - "Рефералов пока нет.", - ) - ) - - lines.append( - texts.t( - "ADMIN_USER_REFERRALS_EDIT_HINT", - "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.", - ) - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t( - "ADMIN_USER_REFERRALS_EDIT_BUTTON", - "✏️ Редактировать", - ), - callback_data=f"admin_user_referrals_edit_{user_id}", - ) - ], - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_user_manage_{user_id}", - ) - ], - ] - ) - - return "\n\n".join(lines), keyboard - - -@admin_required -@error_handler -async def show_user_referrals( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - user_id = int(callback.data.split('_')[-1]) - - view = await _build_user_referrals_view(db, db_user.language, user_id) - if not view: - await callback.answer("❌ Пользователь не найден", show_alert=True) - return - - text, keyboard = view - - await callback.message.edit_text( - text, - reply_markup=keyboard, - ) - await callback.answer() - - -@admin_required -@error_handler -async def start_edit_user_referrals( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession, -): - user_id = int(callback.data.split('_')[-1]) - - user = await get_user_by_id(db, user_id) - if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) - return - - texts = get_texts(db_user.language) - - prompt = texts.t( - "ADMIN_USER_REFERRALS_EDIT_PROMPT", - ( - "✏️ Редактирование рефералов\n\n" - "Отправьте список рефералов для пользователя {name} (ID: {telegram_id}):\n" - "• Используйте TG ID или @username\n" - "• Значения можно указывать через запятую, пробел или с новой строки\n" - "• Чтобы очистить список, отправьте 0 или слово 'нет'\n\n" - "Или нажмите кнопку ниже, чтобы отменить." - ), - ).format( - name=user.full_name, - telegram_id=user.telegram_id, - ) - - await state.update_data( - editing_referrals_user_id=user_id, - referrals_message_id=callback.message.message_id, - ) - - await callback.message.edit_text( - prompt, - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_user_referrals_{user_id}", - ) - ] - ] - ), - ) - - await state.set_state(AdminStates.editing_user_referrals) - await callback.answer() - - -@admin_required -@error_handler -async def process_edit_user_referrals( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession, -): - texts = get_texts(db_user.language) - data = await state.get_data() - - user_id = data.get("editing_referrals_user_id") - if not user_id: - await message.answer( - texts.t( - "ADMIN_USER_REFERRALS_STATE_LOST", - "❌ Не удалось определить пользователя. Попробуйте начать сначала.", - ) - ) - await state.clear() - return - - raw_text = message.text.strip() - lower_text = raw_text.lower() - clear_keywords = {"0", "нет", "none", "пусто", "clear"} - clear_requested = lower_text in clear_keywords - - tokens: List[str] = [] - if not clear_requested: - parts = re.split(r"[,\n]+", raw_text) - for part in parts: - for token in part.split(): - cleaned = token.strip() - if cleaned and cleaned not in tokens: - tokens.append(cleaned) - - found_users: List[User] = [] - not_found: List[str] = [] - skipped_self: List[str] = [] - duplicate_tokens: List[str] = [] - - seen_ids = set() - - for token in tokens: - normalized = token.strip() - if not normalized: - continue - - if normalized.startswith("@"): - normalized = normalized[1:] - - user = None - if normalized.isdigit(): - try: - user = await get_user_by_telegram_id(db, int(normalized)) - except ValueError: - user = None - else: - user = await get_user_by_username(db, normalized) - - if not user: - not_found.append(token) - continue - - if user.id == user_id: - skipped_self.append(token) - continue - - if user.id in seen_ids: - duplicate_tokens.append(token) - continue - - seen_ids.add(user.id) - found_users.append(user) - - if not found_users and not clear_requested: - error_lines = [ - texts.t( - "ADMIN_USER_REFERRALS_NO_VALID", - "❌ Не удалось найти ни одного пользователя по введённым данным.", - ) - ] - if not_found: - error_lines.append( - texts.t( - "ADMIN_USER_REFERRALS_INVALID_ENTRIES", - "Не найдены: {values}", - ).format(values=", ".join(not_found)) - ) - if skipped_self: - error_lines.append( - texts.t( - "ADMIN_USER_REFERRALS_SELF_SKIPPED", - "Пропущены значения пользователя: {values}", - ).format(values=", ".join(skipped_self)) - ) - await message.answer("\n".join(error_lines)) - return - - user_service = UserService() - - new_referral_ids = [user.id for user in found_users] if not clear_requested else [] - - success, details = await user_service.update_user_referrals( - db, - user_id, - new_referral_ids, - db_user.id, - ) - - if not success: - await message.answer( - texts.t( - "ADMIN_USER_REFERRALS_UPDATE_ERROR", - "❌ Не удалось обновить рефералов. Попробуйте позже.", - ) - ) - return - - response_lines = [ - texts.t( - "ADMIN_USER_REFERRALS_UPDATED", - "✅ Список рефералов обновлён.", - ) - ] - - total_referrals = details.get("total", len(new_referral_ids)) - added = details.get("added", 0) - removed = details.get("removed", 0) - - response_lines.append( - texts.t( - "ADMIN_USER_REFERRALS_UPDATED_TOTAL", - "• Текущий список: {total}", - ).format(total=total_referrals) - ) - - if added > 0: - response_lines.append( - texts.t( - "ADMIN_USER_REFERRALS_UPDATED_ADDED", - "• Добавлено: {count}", - ).format(count=added) - ) - - if removed > 0: - response_lines.append( - texts.t( - "ADMIN_USER_REFERRALS_UPDATED_REMOVED", - "• Удалено: {count}", - ).format(count=removed) - ) - - if not_found: - response_lines.append( - texts.t( - "ADMIN_USER_REFERRALS_INVALID_ENTRIES", - "Не найдены: {values}", - ).format(values=", ".join(not_found)) - ) - - if skipped_self: - response_lines.append( - texts.t( - "ADMIN_USER_REFERRALS_SELF_SKIPPED", - "Пропущены значения пользователя: {values}", - ).format(values=", ".join(skipped_self)) - ) - - if duplicate_tokens: - response_lines.append( - texts.t( - "ADMIN_USER_REFERRALS_DUPLICATES", - "Игнорированы дубли: {values}", - ).format(values=", ".join(duplicate_tokens)) - ) - - view = await _build_user_referrals_view(db, db_user.language, user_id) - message_id = data.get("referrals_message_id") - - if view and message_id: - try: - await message.bot.edit_message_text( - view[0], - chat_id=message.chat.id, - message_id=message_id, - reply_markup=view[1], - ) - except TelegramBadRequest: - await message.answer(view[0], reply_markup=view[1]) - elif view: - await message.answer(view[0], reply_markup=view[1]) - - await message.answer("\n".join(response_lines)) - await state.clear() - async def _render_user_promo_group( message: types.Message, language: str, @@ -4541,21 +4159,6 @@ def register_handlers(dp: Dispatcher): AdminStates.editing_user_balance ) - dp.callback_query.register( - show_user_referrals, - F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit") - ) - - dp.callback_query.register( - start_edit_user_referrals, - F.data.startswith("admin_user_referrals_edit_") - ) - - dp.message.register( - process_edit_user_referrals, - AdminStates.editing_user_referrals - ) - dp.callback_query.register( start_send_user_message, F.data.startswith("admin_user_send_message_") diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index fb0b21b1..5d993543 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -760,12 +760,6 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str = callback_data=f"admin_user_promo_group_{user_id}" ) ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"), - callback_data=f"admin_user_referrals_{user_id}" - ) - ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"), diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index fc188245..67437d35 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -713,26 +713,6 @@ "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа", - "ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы", - "ADMIN_USER_REFERRALS_TITLE": "🤝 Рефералы пользователя", - "ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}", - "ADMIN_USER_REFERRALS_LIST_HEADER": "Список рефералов:", - "ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: {telegram_id}{username_part})", - "ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов", - "ADMIN_USER_REFERRALS_EMPTY": "Рефералов пока нет.", - "ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.", - "ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редактировать", - "ADMIN_USER_REFERRALS_EDIT_PROMPT": "✏️ Редактирование рефералов\n\nОтправьте список рефералов для пользователя {name} (ID: {telegram_id}):\n• Используйте TG ID или @username\n• Значения можно указывать через запятую, пробел или с новой строки\n• Чтобы очистить список, отправьте 0 или слово 'нет'\n\nИли нажмите кнопку ниже, чтобы отменить.", - "ADMIN_USER_REFERRALS_STATE_LOST": "❌ Не удалось определить пользователя. Попробуйте начать сначала.", - "ADMIN_USER_REFERRALS_NO_VALID": "❌ Не удалось найти ни одного пользователя по введённым данным.", - "ADMIN_USER_REFERRALS_INVALID_ENTRIES": "Не найдены: {values}", - "ADMIN_USER_REFERRALS_SELF_SKIPPED": "Пропущены значения пользователя: {values}", - "ADMIN_USER_REFERRALS_DUPLICATES": "Игнорированы дубли: {values}", - "ADMIN_USER_REFERRALS_UPDATE_ERROR": "❌ Не удалось обновить рефералов. Попробуйте позже.", - "ADMIN_USER_REFERRALS_UPDATED": "✅ Список рефералов обновлён.", - "ADMIN_USER_REFERRALS_UPDATED_TOTAL": "• Текущий список: {total}", - "ADMIN_USER_REFERRALS_UPDATED_ADDED": "• Добавлено: {count}", - "ADMIN_USER_REFERRALS_UPDATED_REMOVED": "• Удалено: {count}", "ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}", "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", diff --git a/app/services/user_service.py b/app/services/user_service.py index 9d551fec..6c19472b 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -9,7 +9,7 @@ from app.database.crud.user import ( get_user_by_id, get_user_by_telegram_id, get_users_list, get_users_count, get_users_statistics, get_inactive_users, add_user_balance, subtract_user_balance, update_user, delete_user, - get_users_spending_stats, get_referrals + get_users_spending_stats ) from app.database.crud.promo_group import get_promo_group_by_id from app.database.crud.transaction import get_user_transactions_count @@ -411,72 +411,6 @@ class UserService: logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}") return False, None, None, None - async def update_user_referrals( - self, - db: AsyncSession, - user_id: int, - referral_user_ids: List[int], - admin_id: int, - ) -> Tuple[bool, Dict[str, int]]: - try: - user = await get_user_by_id(db, user_id) - if not user: - return False, {"error": "user_not_found"} - - unique_ids: List[int] = [] - for referral_id in referral_user_ids: - if referral_id == user_id: - continue - if referral_id not in unique_ids: - unique_ids.append(referral_id) - - current_referrals = await get_referrals(db, user_id) - current_ids = {ref.id for ref in current_referrals} - - to_assign = unique_ids - to_remove = [rid for rid in current_ids if rid not in unique_ids] - to_add = [rid for rid in unique_ids if rid not in current_ids] - - if to_assign: - await db.execute( - update(User) - .where(User.id.in_(to_assign)) - .values(referred_by_id=user_id) - ) - - if to_remove: - await db.execute( - update(User) - .where(User.id.in_(to_remove)) - .values(referred_by_id=None) - ) - - await db.commit() - - logger.info( - "Админ %s обновил рефералов пользователя %s: добавлено %s, удалено %s, всего %s", - admin_id, - user_id, - len(to_add), - len(to_remove), - len(unique_ids), - ) - - return True, { - "added": len(to_add), - "removed": len(to_remove), - "total": len(unique_ids), - } - - except Exception as e: - await db.rollback() - logger.error( - "Ошибка обновления рефералов пользователя %s: %s", - user_id, - e, - ) - return False, {"error": "update_failed"} - async def block_user( self, db: AsyncSession, diff --git a/app/states.py b/app/states.py index 981c314a..549e314f 100644 --- a/app/states.py +++ b/app/states.py @@ -90,7 +90,6 @@ class AdminStates(StatesGroup): editing_device_price = State() editing_user_devices = State() editing_user_traffic = State() - editing_user_referrals = State() editing_rules_page = State() editing_privacy_policy = State()