mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-03 20:34:10 +00:00
Merge pull request #262 from Fr1ngg/nus2xf-bedolaga/add-auto-assignment-feature-for-promo-groups
Add promo group auto assignment after top-ups
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import desc, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -27,6 +27,23 @@ def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> D
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _normalize_auto_assign_amount(amount_kopeks: Optional[int]) -> Optional[int]:
|
||||
if amount_kopeks is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
normalized = int(amount_kopeks)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if normalized <= 0:
|
||||
return None
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
async def get_promo_groups_with_counts(
|
||||
db: AsyncSession,
|
||||
@@ -59,8 +76,10 @@ async def create_promo_group(
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_amount_kopeks: Optional[int] = None,
|
||||
) -> PromoGroup:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
normalized_auto_amount = _normalize_auto_assign_amount(auto_assign_amount_kopeks)
|
||||
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
@@ -68,6 +87,7 @@ async def create_promo_group(
|
||||
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
period_discounts=normalized_period_discounts or None,
|
||||
auto_assign_amount_kopeks=normalized_auto_amount,
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
@@ -96,6 +116,7 @@ async def update_promo_group(
|
||||
traffic_discount_percent: Optional[int] = None,
|
||||
device_discount_percent: Optional[int] = None,
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_amount_kopeks: Any = _UNSET,
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
@@ -108,6 +129,8 @@ async def update_promo_group(
|
||||
if period_discounts is not None:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
group.period_discounts = normalized_period_discounts or None
|
||||
if auto_assign_amount_kopeks is not _UNSET:
|
||||
group.auto_assign_amount_kopeks = _normalize_auto_assign_amount(auto_assign_amount_kopeks)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
@@ -170,3 +193,23 @@ async def count_promo_group_members(db: AsyncSession, group_id: int) -> int:
|
||||
select(func.count(User.id)).where(User.promo_group_id == group_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_auto_assign_promo_group(
|
||||
db: AsyncSession,
|
||||
total_amount_kopeks: int,
|
||||
) -> Optional[PromoGroup]:
|
||||
if total_amount_kopeks <= 0:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(
|
||||
PromoGroup.auto_assign_amount_kopeks.is_not(None),
|
||||
PromoGroup.auto_assign_amount_kopeks > 0,
|
||||
PromoGroup.auto_assign_amount_kopeks <= total_amount_kopeks,
|
||||
)
|
||||
.order_by(desc(PromoGroup.auto_assign_amount_kopeks), PromoGroup.id)
|
||||
)
|
||||
|
||||
return result.scalars().first()
|
||||
|
||||
@@ -98,6 +98,21 @@ async def get_user_transactions_count(
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_user_total_completed_deposits(db: AsyncSession, user_id: int) -> int:
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
|
||||
|
||||
transaction.is_completed = True
|
||||
|
||||
@@ -116,6 +116,7 @@ async def create_user(
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
promo_group_auto_assigned=False,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
|
||||
@@ -271,6 +271,7 @@ class PromoGroup(Base):
|
||||
traffic_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
device_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
period_discounts = Column(JSON, nullable=True, default=dict)
|
||||
auto_assign_amount_kopeks = Column(Integer, nullable=True)
|
||||
is_default = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
@@ -335,7 +336,7 @@ class PromoGroup(Base):
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
telegram_id = Column(BigInteger, unique=True, index=True, nullable=False)
|
||||
username = Column(String(255), nullable=True)
|
||||
@@ -365,6 +366,7 @@ class User(Base):
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
promo_group_auto_assigned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
|
||||
@@ -902,6 +902,81 @@ async def ensure_promo_groups_setup():
|
||||
logger.error(f"Ошибка настройки промо групп: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_promo_group_auto_assign_column():
|
||||
logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ АВТОВЫДАЧИ ДЛЯ ПРОМОГРУПП ===")
|
||||
|
||||
try:
|
||||
if await check_column_exists("promo_groups", "auto_assign_amount_kopeks"):
|
||||
logger.info("Колонка auto_assign_amount_kopeks уже существует в promo_groups")
|
||||
return True
|
||||
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
column_definition = "INTEGER"
|
||||
elif db_type == "postgresql":
|
||||
column_definition = "INTEGER"
|
||||
elif db_type == "mysql":
|
||||
column_definition = "INT"
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для auto_assign_amount_kopeks: {db_type}")
|
||||
return False
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
f"ALTER TABLE promo_groups ADD COLUMN auto_assign_amount_kopeks {column_definition}"
|
||||
)
|
||||
)
|
||||
|
||||
logger.info("Добавлена колонка promo_groups.auto_assign_amount_kopeks")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления колонки auto_assign_amount_kopeks: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_user_promo_group_auto_flag_column():
|
||||
logger.info("=== ДОБАВЛЕНИЕ ФЛАГА АВТО-ПРОМОГРУППЫ ДЛЯ ПОЛЬЗОВАТЕЛЕЙ ===")
|
||||
|
||||
try:
|
||||
if await check_column_exists("users", "promo_group_auto_assigned"):
|
||||
logger.info("Колонка promo_group_auto_assigned уже существует в users")
|
||||
return True
|
||||
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
column_definition = "BOOLEAN NOT NULL DEFAULT 0"
|
||||
reset_sql = "UPDATE users SET promo_group_auto_assigned = 0 WHERE promo_group_auto_assigned IS NULL"
|
||||
elif db_type == "postgresql":
|
||||
column_definition = "BOOLEAN NOT NULL DEFAULT FALSE"
|
||||
reset_sql = "UPDATE users SET promo_group_auto_assigned = FALSE WHERE promo_group_auto_assigned IS NULL"
|
||||
elif db_type == "mysql":
|
||||
column_definition = "TINYINT(1) NOT NULL DEFAULT 0"
|
||||
reset_sql = "UPDATE users SET promo_group_auto_assigned = 0 WHERE promo_group_auto_assigned IS NULL"
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для promo_group_auto_assigned: {db_type}")
|
||||
return False
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
f"ALTER TABLE users ADD COLUMN promo_group_auto_assigned {column_definition}"
|
||||
)
|
||||
)
|
||||
await conn.execute(text(reset_sql))
|
||||
|
||||
logger.info("Добавлена колонка users.promo_group_auto_assigned")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления колонки promo_group_auto_assigned: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_welcome_text_is_enabled_column():
|
||||
column_exists = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
if column_exists:
|
||||
@@ -1511,6 +1586,18 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с настройкой промо групп")
|
||||
|
||||
promo_auto_column_added = await add_promo_group_auto_assign_column()
|
||||
if promo_auto_column_added:
|
||||
logger.info("✅ Добавлено поле авто-выдачи в промо группах")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось добавить поле авто-выдачи в промо группах")
|
||||
|
||||
user_auto_flag_added = await add_user_promo_group_auto_flag_column()
|
||||
if user_auto_flag_added:
|
||||
logger.info("✅ Добавлен флаг auto_assigned у пользователей")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось добавить флаг auto_assigned у пользователей")
|
||||
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
@@ -1585,6 +1672,8 @@ async def check_migration_status():
|
||||
"promo_groups_table": False,
|
||||
"users_promo_group_column": False,
|
||||
"promo_groups_period_discounts_column": False,
|
||||
"promo_groups_auto_assign_column": False,
|
||||
"users_promo_group_auto_flag_column": False,
|
||||
}
|
||||
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
@@ -1598,6 +1687,8 @@ async def check_migration_status():
|
||||
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id')
|
||||
status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts')
|
||||
status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_amount_kopeks')
|
||||
status["users_promo_group_auto_flag_column"] = await check_column_exists('users', 'promo_group_auto_assigned')
|
||||
|
||||
media_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'has_media') and
|
||||
@@ -1631,6 +1722,8 @@ async def check_migration_status():
|
||||
"promo_groups_table": "Таблица промо-групп",
|
||||
"users_promo_group_column": "Колонка promo_group_id у пользователей",
|
||||
"promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп",
|
||||
"promo_groups_auto_assign_column": "Колонка auto_assign_amount_kopeks у промо-групп",
|
||||
"users_promo_group_auto_flag_column": "Флаг auto_assigned у пользователей",
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
import logging
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from typing import Dict, Optional
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
@@ -109,6 +109,23 @@ def _format_period_discounts_value(discounts: Dict[int, int]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _format_auto_assign_line(texts, group: PromoGroup) -> Optional[str]:
|
||||
amount = getattr(group, "auto_assign_amount_kopeks", None)
|
||||
if not amount:
|
||||
return None
|
||||
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE",
|
||||
"🎯 Автовыдача с суммы: {amount}",
|
||||
).format(amount=settings.format_price(amount))
|
||||
|
||||
|
||||
def _format_auto_assign_value(amount_kopeks: Optional[int]) -> str:
|
||||
if not amount_kopeks:
|
||||
return settings.format_price(0)
|
||||
return settings.format_price(amount_kopeks)
|
||||
|
||||
|
||||
def _parse_period_discounts_input(value: str) -> Dict[int, int]:
|
||||
cleaned = (value or "").strip()
|
||||
|
||||
@@ -140,6 +157,29 @@ def _parse_period_discounts_input(value: str) -> Dict[int, int]:
|
||||
return discounts
|
||||
|
||||
|
||||
def _parse_auto_assign_amount_input(value: str) -> Optional[int]:
|
||||
cleaned = (value or "").strip()
|
||||
|
||||
if not cleaned:
|
||||
raise ValueError
|
||||
|
||||
normalized = cleaned.replace(" ", "").replace(",", ".")
|
||||
|
||||
if normalized in {"0", "-", "нет", "off", "disable"}:
|
||||
return None
|
||||
|
||||
try:
|
||||
decimal_value = Decimal(normalized)
|
||||
except (InvalidOperation, ValueError):
|
||||
raise ValueError
|
||||
|
||||
if decimal_value <= 0:
|
||||
return None
|
||||
|
||||
kopeks = (decimal_value * Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||
return int(kopeks)
|
||||
|
||||
|
||||
async def _prompt_for_period_discounts(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
@@ -161,6 +201,27 @@ async def _prompt_for_period_discounts(
|
||||
await message.answer(prompt_text)
|
||||
|
||||
|
||||
async def _prompt_for_auto_assign_amount(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
prompt_key: str,
|
||||
default_text: str,
|
||||
*,
|
||||
current_value: Optional[str] = None,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", "ru"))
|
||||
prompt_text = texts.t(prompt_key, default_text)
|
||||
|
||||
if current_value is not None:
|
||||
try:
|
||||
prompt_text = prompt_text.format(current=current_value)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
await message.answer(prompt_text)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_groups_menu(
|
||||
@@ -191,11 +252,18 @@ async def show_promo_groups_menu(
|
||||
group_lines = [
|
||||
f"{'⭐' if group.is_default else '🎯'} <b>{group.name}</b>{default_suffix}",
|
||||
_format_discount_line(texts, group),
|
||||
]
|
||||
|
||||
auto_line = _format_auto_assign_line(texts, group)
|
||||
if auto_line:
|
||||
group_lines.append(auto_line)
|
||||
|
||||
group_lines.extend([
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
]
|
||||
])
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
|
||||
group_lines.extend(period_lines)
|
||||
@@ -265,11 +333,18 @@ async def show_promo_group_details(
|
||||
"💳 <b>Промогруппа:</b> {name}",
|
||||
).format(name=group.name),
|
||||
_format_discount_line(texts, group),
|
||||
]
|
||||
|
||||
auto_line = _format_auto_assign_line(texts, group)
|
||||
if auto_line:
|
||||
lines.append(auto_line)
|
||||
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
]
|
||||
)
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
|
||||
lines.extend(period_lines)
|
||||
@@ -464,6 +539,39 @@ async def process_create_group_period_discounts(
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(new_group_period_discounts=period_discounts)
|
||||
await state.set_state(AdminStates.creating_promo_group_auto_amount)
|
||||
|
||||
await _prompt_for_auto_assign_amount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT",
|
||||
"Введите сумму пополнений (в рублях) для автоматической выдачи. Отправьте 0, если не нужно.",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_create_group_auto_amount(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
auto_amount = _parse_auto_assign_amount_input(message.text)
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN",
|
||||
"Введите корректную сумму или 0 для отключения.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
@@ -471,7 +579,8 @@ async def process_create_group_period_discounts(
|
||||
traffic_discount_percent=data["new_group_traffic"],
|
||||
server_discount_percent=data["new_group_servers"],
|
||||
device_discount_percent=data["new_group_devices"],
|
||||
period_discounts=period_discounts,
|
||||
period_discounts=data.get("new_group_period_discounts"),
|
||||
auto_assign_amount_kopeks=auto_amount,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось создать промогруппу: {e}")
|
||||
@@ -647,6 +756,46 @@ async def process_edit_group_period_discounts(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_period_discounts=period_discounts)
|
||||
await state.set_state(AdminStates.editing_promo_group_auto_amount)
|
||||
|
||||
await _prompt_for_auto_assign_amount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT",
|
||||
"Введите новую сумму (текущая: {current}). Отправьте 0, если без автовыдачи.",
|
||||
current_value=_format_auto_assign_value(getattr(group, "auto_assign_amount_kopeks", None)),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_auto_amount(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
auto_amount = _parse_auto_assign_amount_input(message.text)
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN",
|
||||
"Введите корректную сумму или 0 для отключения.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_promo_group(
|
||||
db,
|
||||
group,
|
||||
@@ -654,7 +803,8 @@ async def process_edit_group_period_discounts(
|
||||
traffic_discount_percent=data["edit_group_traffic"],
|
||||
server_discount_percent=data["edit_group_servers"],
|
||||
device_discount_percent=data["edit_group_devices"],
|
||||
period_discounts=period_discounts,
|
||||
period_discounts=data.get("edit_group_period_discounts"),
|
||||
auto_assign_amount_kopeks=auto_amount,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
@@ -828,6 +978,10 @@ def register_handlers(dp: Dispatcher):
|
||||
process_create_group_period_discounts,
|
||||
AdminStates.creating_promo_group_period_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_auto_amount,
|
||||
AdminStates.creating_promo_group_auto_amount,
|
||||
)
|
||||
|
||||
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
|
||||
dp.message.register(
|
||||
@@ -846,3 +1000,7 @@ def register_handlers(dp: Dispatcher):
|
||||
process_edit_group_period_discounts,
|
||||
AdminStates.editing_promo_group_period_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_auto_amount,
|
||||
AdminStates.editing_promo_group_auto_amount,
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.services.subscription_checkout_service import (
|
||||
)
|
||||
from app.services.mulenpay_service import MulenPayService
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.services.promo_group_service import maybe_assign_auto_promo_group
|
||||
from app.database.crud.mulenpay import (
|
||||
create_mulenpay_payment,
|
||||
get_mulenpay_payment_by_local_id,
|
||||
@@ -179,7 +180,9 @@ class PaymentService:
|
||||
logger.error(f"Ошибка обработки реферального пополнения: {e}")
|
||||
else:
|
||||
logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики")
|
||||
|
||||
|
||||
await maybe_assign_auto_promo_group(db, user, self.bot)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
@@ -461,7 +464,9 @@ class PaymentService:
|
||||
await process_referral_topup(db, user.id, updated_payment.amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения YooKassa: {e}")
|
||||
|
||||
|
||||
await maybe_assign_auto_promo_group(db, user, self.bot)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
@@ -528,7 +533,8 @@ class PaymentService:
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
await add_user_balance(db, user, payment.amount_kopeks, f"Пополнение YooKassa: {payment.amount_kopeks//100}₽")
|
||||
|
||||
await maybe_assign_auto_promo_group(db, user, self.bot)
|
||||
|
||||
logger.info(f"Успешно обработан платеж YooKassa {payment.yookassa_payment_id}: "
|
||||
f"пользователь {payment.user_id} получил {payment.amount_kopeks/100}₽")
|
||||
|
||||
@@ -993,6 +999,8 @@ class PaymentService:
|
||||
referral_error,
|
||||
)
|
||||
|
||||
await maybe_assign_auto_promo_group(db, user, self.bot)
|
||||
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
@@ -1182,6 +1190,8 @@ class PaymentService:
|
||||
except Exception as referral_error:
|
||||
logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error)
|
||||
|
||||
await maybe_assign_auto_promo_group(db, user, self.bot)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
@@ -1438,7 +1448,9 @@ class PaymentService:
|
||||
await process_referral_topup(db, user.id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения CryptoBot: {e}")
|
||||
|
||||
|
||||
await maybe_assign_auto_promo_group(db, user, self.bot)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
77
app/services/promo_group_service.py
Normal file
77
app/services/promo_group_service.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_auto_assign_promo_group
|
||||
from app.database.crud.transaction import get_user_total_completed_deposits
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_total_amount(total_amount_kopeks: int) -> str:
|
||||
return settings.format_price(total_amount_kopeks)
|
||||
|
||||
|
||||
async def maybe_assign_auto_promo_group(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> Optional[PromoGroup]:
|
||||
"""Назначает промогруппу автоматически при достижении нужной суммы пополнений."""
|
||||
try:
|
||||
if getattr(user, "promo_group_auto_assigned", False):
|
||||
return None
|
||||
|
||||
total_amount_kopeks = await get_user_total_completed_deposits(db, user.id)
|
||||
target_group = await get_auto_assign_promo_group(db, total_amount_kopeks)
|
||||
|
||||
if not target_group or target_group.id == user.promo_group_id:
|
||||
return None
|
||||
|
||||
user.promo_group_id = target_group.id
|
||||
user.promo_group = target_group
|
||||
user.promo_group_auto_assigned = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
"Автоматически назначена промогруппа '%s' пользователю %s (сумма пополнений: %s)",
|
||||
target_group.name,
|
||||
user.telegram_id,
|
||||
_format_total_amount(total_amount_kopeks),
|
||||
)
|
||||
|
||||
if bot:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
message = texts.t(
|
||||
"PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION",
|
||||
"🎉 Вы автоматически переведены в промогруппу «{name}» за пополнения на {amount}.",
|
||||
).format(name=target_group.name, amount=_format_total_amount(total_amount_kopeks))
|
||||
await bot.send_message(user.telegram_id, message, parse_mode="HTML")
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления об автоназначении промогруппы пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
return target_group
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка автоматического назначения промогруппы пользователю %s: %s",
|
||||
getattr(user, "telegram_id", "unknown"),
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
await db.rollback()
|
||||
return None
|
||||
@@ -14,6 +14,7 @@ from app.database.crud.transaction import (
|
||||
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.promo_group_service import maybe_assign_auto_promo_group
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -139,8 +140,10 @@ class TributeService:
|
||||
if not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}")
|
||||
|
||||
|
||||
|
||||
await maybe_assign_auto_promo_group(session, user, self.bot)
|
||||
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
@@ -333,11 +336,13 @@ class TributeService:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
await maybe_assign_auto_promo_group(session, user, self.bot)
|
||||
|
||||
logger.info(f"💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп")
|
||||
|
||||
|
||||
await self._send_success_notification(user_id, amount_kopeks)
|
||||
|
||||
logger.info(f"✅ Принудительно обработан платеж {payment_id}")
|
||||
|
||||
@@ -69,12 +69,14 @@ class AdminStates(StatesGroup):
|
||||
creating_promo_group_server_discount = State()
|
||||
creating_promo_group_device_discount = State()
|
||||
creating_promo_group_period_discount = State()
|
||||
creating_promo_group_auto_amount = State()
|
||||
|
||||
editing_promo_group_name = State()
|
||||
editing_promo_group_traffic_discount = State()
|
||||
editing_promo_group_server_discount = State()
|
||||
editing_promo_group_device_discount = State()
|
||||
editing_promo_group_period_discount = State()
|
||||
editing_promo_group_auto_amount = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "🎯 Auto assignment from: {amount}",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
|
||||
"MY_TICKETS_BUTTON": "📋 My tickets",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Contact support",
|
||||
@@ -226,8 +227,10 @@
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter the top-up amount (in rubles) for automatic assignment. Send 0 to disable.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a valid amount or 0 to disable.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
|
||||
@@ -235,6 +238,7 @@
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter a new amount (current: {current}). Send 0 to disable.",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
|
||||
@@ -263,6 +267,7 @@
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION": "🎉 You have been automatically moved to the promo group “{name}” for topping up {amount}.",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "🎯 Автовыдача с суммы: {amount}",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
|
||||
"MY_TICKETS_BUTTON": "📋 Мои тикеты",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой",
|
||||
@@ -104,8 +105,10 @@
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму пополнений (в рублях) для автоматической выдачи. Отправьте 0, если без автовыдачи.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите корректную сумму или 0 для отключения.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
|
||||
@@ -113,6 +116,7 @@
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите новую сумму (текущая: {current}). Отправьте 0, если без автовыдачи.",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
|
||||
@@ -146,6 +150,7 @@
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION": "🎉 Вы автоматически переведены в промогруппу «{name}» за пополнения на {amount}.",
|
||||
"CANCEL": "❌ Отмена",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
|
||||
|
||||
Reference in New Issue
Block a user