diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py
index d63c3107..3bc093f2 100644
--- a/app/database/crud/promo_group.py
+++ b/app/database/crud/promo_group.py
@@ -59,15 +59,23 @@ async def create_promo_group(
traffic_discount_percent: int,
device_discount_percent: int,
period_discounts: Optional[Dict[int, int]] = None,
+ auto_assign_total_spent_kopeks: Optional[int] = None,
) -> PromoGroup:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
+ auto_assign_total_spent_kopeks = (
+ max(0, auto_assign_total_spent_kopeks)
+ if auto_assign_total_spent_kopeks is not None
+ else None
+ )
+
promo_group = PromoGroup(
name=name.strip(),
server_discount_percent=max(0, min(100, server_discount_percent)),
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_total_spent_kopeks=auto_assign_total_spent_kopeks,
is_default=False,
)
@@ -76,12 +84,13 @@ async def create_promo_group(
await db.refresh(promo_group)
logger.info(
- "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s)",
+ "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽",
promo_group.name,
promo_group.server_discount_percent,
promo_group.traffic_discount_percent,
promo_group.device_discount_percent,
normalized_period_discounts,
+ (auto_assign_total_spent_kopeks or 0) / 100,
)
return promo_group
@@ -96,6 +105,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_total_spent_kopeks: Optional[int] = None,
) -> PromoGroup:
if name is not None:
group.name = name.strip()
@@ -108,6 +118,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_total_spent_kopeks is not None:
+ group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
await db.commit()
await db.refresh(group)
diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py
index b258f1b2..11a2827e 100644
--- a/app/database/crud/transaction.py
+++ b/app/database/crud/transaction.py
@@ -37,6 +37,20 @@ async def create_transaction(
await db.refresh(transaction)
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
+
+ try:
+ from app.services.promo_group_assignment import (
+ maybe_assign_promo_group_by_total_spent,
+ )
+
+ await maybe_assign_promo_group_by_total_spent(db, user_id)
+ except Exception as exc:
+ logger.debug(
+ "Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
+ user_id,
+ exc,
+ )
+
return transaction
@@ -98,8 +112,26 @@ async def get_user_transactions_count(
return result.scalar()
+async def get_user_total_spent_kopeks(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.is_completed.is_(True),
+ Transaction.type.in_(
+ [
+ TransactionType.DEPOSIT.value,
+ TransactionType.SUBSCRIPTION_PAYMENT.value,
+ ]
+ ),
+ )
+ )
+ )
+ return int(result.scalar_one())
+
+
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
-
+
transaction.is_completed = True
transaction.completed_at = datetime.utcnow()
diff --git a/app/database/models.py b/app/database/models.py
index 9cdeaa86..f9b6d8ab 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -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_total_spent_kopeks = Column(Integer, nullable=True, default=None)
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)
@@ -358,6 +359,7 @@ class User(Base):
transactions = relationship("Transaction", back_populates="user")
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
+ auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
last_remnawave_sync = Column(DateTime, nullable=True)
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py
index ce270f13..40273ff4 100644
--- a/app/database/universal_migration.py
+++ b/app/database/universal_migration.py
@@ -721,6 +721,39 @@ async def ensure_promo_groups_setup():
logger.info("Добавлена колонка promo_groups.period_discounts")
+ auto_assign_column_exists = await check_column_exists(
+ "promo_groups", "auto_assign_total_spent_kopeks"
+ )
+
+ if not auto_assign_column_exists:
+ if db_type == "sqlite":
+ await conn.execute(
+ text(
+ "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0"
+ )
+ )
+ elif db_type == "postgresql":
+ await conn.execute(
+ text(
+ "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0"
+ )
+ )
+ elif db_type == "mysql":
+ await conn.execute(
+ text(
+ "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INT DEFAULT 0"
+ )
+ )
+ else:
+ logger.error(
+ f"Неподдерживаемый тип БД для promo_groups.auto_assign_total_spent_kopeks: {db_type}"
+ )
+ return False
+
+ logger.info(
+ "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks"
+ )
+
column_exists = await check_column_exists("users", "promo_group_id")
if not column_exists:
@@ -736,6 +769,37 @@ async def ensure_promo_groups_setup():
logger.info("Добавлена колонка users.promo_group_id")
+ auto_promo_flag_exists = await check_column_exists(
+ "users", "auto_promo_group_assigned"
+ )
+
+ if not auto_promo_flag_exists:
+ if db_type == "sqlite":
+ await conn.execute(
+ text(
+ "ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT 0"
+ )
+ )
+ elif db_type == "postgresql":
+ await conn.execute(
+ text(
+ "ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT FALSE"
+ )
+ )
+ elif db_type == "mysql":
+ await conn.execute(
+ text(
+ "ALTER TABLE users ADD COLUMN auto_promo_group_assigned TINYINT(1) DEFAULT 0"
+ )
+ )
+ else:
+ logger.error(
+ f"Неподдерживаемый тип БД для users.auto_promo_group_assigned: {db_type}"
+ )
+ return False
+
+ logger.info("Добавлена колонка users.auto_promo_group_assigned")
+
index_exists = await check_index_exists("users", "ix_users_promo_group_id")
if not index_exists:
@@ -1585,6 +1649,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_auto_promo_group_assigned_column": False,
}
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
@@ -1598,6 +1664,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_total_spent_kopeks')
+ status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned')
media_fields_exist = (
await check_column_exists('broadcast_history', 'has_media') and
@@ -1631,6 +1699,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_total_spent_kopeks у промо-групп",
+ "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей",
}
for check_key, check_status in status.items():
diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py
index 0546550e..917f673f 100644
--- a/app/handlers/admin/promo_groups.py
+++ b/app/handlers/admin/promo_groups.py
@@ -1,6 +1,6 @@
import logging
-import logging
-from typing import Dict, Optional
+from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
+from typing import Dict, Optional, Tuple
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
@@ -161,6 +161,214 @@ async def _prompt_for_period_discounts(
await message.answer(prompt_text)
+def _format_rubles(amount_kopeks: int) -> str:
+ if amount_kopeks <= 0:
+ return "0"
+
+ rubles = Decimal(amount_kopeks) / Decimal(100)
+ if rubles == rubles.to_integral_value():
+ formatted = f"{rubles:,.0f}"
+ else:
+ formatted = f"{rubles:,.2f}"
+
+ return formatted.replace(",", " ")
+
+
+def _format_auto_assign_line(texts, group: PromoGroup) -> str:
+ threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0
+
+ if threshold <= 0:
+ return texts.t(
+ "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED",
+ "Автовыдача по суммарным тратам: отключена",
+ )
+
+ amount = _format_rubles(threshold)
+ return texts.t(
+ "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE",
+ "Автовыдача по суммарным тратам: от {amount} ₽",
+ ).format(amount=amount)
+
+
+def _format_auto_assign_value(value_kopeks: Optional[int]) -> str:
+ if not value_kopeks or value_kopeks <= 0:
+ return "0"
+
+ rubles = Decimal(value_kopeks) / Decimal(100)
+ quantized = (
+ rubles.quantize(Decimal("1"))
+ if rubles == rubles.to_integral_value()
+ else rubles.quantize(Decimal("0.01"))
+ )
+ return str(quantized)
+
+
+def _parse_auto_assign_threshold_input(value: str) -> int:
+ cleaned = (value or "").strip()
+
+ if not cleaned or cleaned in {"0", "-", "off", "нет"}:
+ return 0
+
+ normalized = cleaned.replace(" ", "").replace(",", ".")
+
+ try:
+ amount = Decimal(normalized)
+ except InvalidOperation:
+ raise ValueError
+
+ if amount < 0:
+ raise ValueError
+
+ kopeks = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
+ return max(0, kopeks)
+
+
+async def _prompt_for_auto_assign_threshold(
+ 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)
+
+
+def _build_edit_menu_content(
+ texts,
+ group: PromoGroup,
+ language: str,
+) -> Tuple[str, types.InlineKeyboardMarkup]:
+ header = texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE",
+ "✏️ Настройки промогруппы «{name}»",
+ ).format(name=group.name)
+
+ lines = [
+ header,
+ _format_discount_line(texts, group),
+ _format_auto_assign_line(texts, group),
+ ]
+
+ period_lines = _format_period_discounts_lines(texts, group, language)
+ lines.extend(period_lines)
+
+ lines.append(
+ texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_MENU_HINT",
+ "Выберите параметр для изменения:",
+ )
+ )
+
+ text = "\n".join(line for line in lines if line)
+
+ keyboard_rows = [
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME",
+ "✏️ Изменить название",
+ ),
+ callback_data=f"promo_group_edit_field_{group.id}_name",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC",
+ "🌐 Скидка на трафик",
+ ),
+ callback_data=f"promo_group_edit_field_{group.id}_traffic",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS",
+ "🖥 Скидка на серверы",
+ ),
+ callback_data=f"promo_group_edit_field_{group.id}_servers",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES",
+ "📱 Скидка на устройства",
+ ),
+ callback_data=f"promo_group_edit_field_{group.id}_devices",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS",
+ "⏳ Скидки по периодам",
+ ),
+ callback_data=f"promo_group_edit_field_{group.id}_periods",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN",
+ "🤖 Автовыдача по тратам",
+ ),
+ callback_data=f"promo_group_edit_field_{group.id}_auto",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data=f"promo_group_manage_{group.id}",
+ )
+ ],
+ ]
+
+ keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
+ return text, keyboard
+
+
+def _get_edit_prompt_keyboard(group_id: int, texts) -> types.InlineKeyboardMarkup:
+ return types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data=f"promo_group_edit_{group_id}",
+ )
+ ]
+ ]
+ )
+
+
+async def _send_edit_menu_after_update(
+ message: types.Message,
+ texts,
+ group: PromoGroup,
+ language: str,
+ success_message: Optional[str] = None,
+):
+ menu_text, keyboard = _build_edit_menu_content(texts, group, language)
+ parts = [part for part in [success_message, menu_text] if part]
+
+ await message.answer(
+ "\n\n".join(parts),
+ reply_markup=keyboard,
+ parse_mode="HTML",
+ )
+
+
@admin_required
@error_handler
async def show_promo_groups_menu(
@@ -191,6 +399,7 @@ async def show_promo_groups_menu(
group_lines = [
f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}",
_format_discount_line(texts, group),
+ _format_auto_assign_line(texts, group),
texts.t(
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
"Участников: {count}",
@@ -265,6 +474,7 @@ async def show_promo_group_details(
"💳 Промогруппа: {name}",
).format(name=group.name),
_format_discount_line(texts, group),
+ _format_auto_assign_line(texts, group),
texts.t(
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
"Участников: {count}",
@@ -464,6 +674,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_assign)
+
+ await _prompt_for_auto_assign_threshold(
+ message,
+ state,
+ "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT",
+ "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.",
+ )
+
+
+@admin_required
+@error_handler
+async def process_create_group_auto_assign(
+ 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_assign_kopeks = _parse_auto_assign_threshold_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 +714,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_total_spent_kopeks=auto_assign_kopeks,
)
except Exception as e:
logger.error(f"Не удалось создать промогруппу: {e}")
@@ -513,73 +757,190 @@ async def start_edit_promo_group(
return
texts = get_texts(db_user.language)
- await state.set_state(AdminStates.editing_promo_group_name)
await state.update_data(edit_group_id=group.id, language=db_user.language)
+ await state.set_state(AdminStates.editing_promo_group_menu)
+ text, keyboard = _build_edit_menu_content(texts, group, db_user.language)
await callback.message.edit_text(
- texts.t(
- "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
- "Введите новое название промогруппы (текущее: {name}):",
- ).format(name=group.name),
- reply_markup=types.InlineKeyboardMarkup(
- inline_keyboard=[
- [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group.id}")]
- ]
- ),
+ text,
+ reply_markup=keyboard,
+ parse_mode="HTML",
)
await callback.answer()
-async def process_edit_group_name(message: types.Message, state: FSMContext):
+@admin_required
+@error_handler
+async def prompt_edit_promo_group_field(
+ callback: types.CallbackQuery,
+ db_user,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ parts = callback.data.split("_")
+ if len(parts) < 6:
+ await callback.answer("❌ Неверная команда", show_alert=True)
+ return
+
+ group_id = int(parts[4])
+ field = parts[5]
+
+ group = await get_promo_group_by_id(db, group_id)
+ if not group:
+ await callback.answer("❌ Промогруппа не найдена", show_alert=True)
+ return
+
+ await state.update_data(edit_group_id=group.id, language=db_user.language)
+
+ texts = get_texts(db_user.language)
+ reply_markup = _get_edit_prompt_keyboard(group.id, texts)
+
+ if field == "name":
+ await state.set_state(AdminStates.editing_promo_group_name)
+ prompt = texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
+ "Введите новое название промогруппы (текущее: {name}):",
+ ).format(name=group.name)
+ elif field == "traffic":
+ await state.set_state(AdminStates.editing_promo_group_traffic_discount)
+ prompt = texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT",
+ "Введите новую скидку на трафик (текущее значение: {current}%):",
+ ).format(current=group.traffic_discount_percent)
+ elif field == "servers":
+ await state.set_state(AdminStates.editing_promo_group_server_discount)
+ prompt = texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT",
+ "Введите новую скидку на серверы (текущее значение: {current}%):",
+ ).format(current=group.server_discount_percent)
+ elif field == "devices":
+ await state.set_state(AdminStates.editing_promo_group_device_discount)
+ prompt = texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT",
+ "Введите новую скидку на устройства (текущее значение: {current}%):",
+ ).format(current=group.device_discount_percent)
+ elif field == "periods":
+ await state.set_state(AdminStates.editing_promo_group_period_discount)
+ current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None))
+ prompt = texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT",
+ "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
+ ).format(current=_format_period_discounts_value(current_discounts))
+ elif field == "auto":
+ await state.set_state(AdminStates.editing_promo_group_auto_assign)
+ prompt = texts.t(
+ "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT",
+ "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.",
+ ).format(current=_format_auto_assign_value(group.auto_assign_total_spent_kopeks))
+ else:
+ await callback.answer("❌ Неизвестный параметр", show_alert=True)
+ return
+
+ await callback.message.edit_text(prompt, reply_markup=reply_markup)
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def process_edit_group_name(
+ message: types.Message,
+ state: FSMContext,
+ db_user,
+ db: AsyncSession,
+):
+ data = await state.get_data()
+ texts = get_texts(data.get("language", db_user.language))
+
name = message.text.strip()
if not name:
- texts = get_texts((await state.get_data()).get("language", "ru"))
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
return
- await state.update_data(edit_group_name=name)
- await state.set_state(AdminStates.editing_promo_group_traffic_discount)
- await _prompt_for_discount(
+ group = await get_promo_group_by_id(db, data.get("edit_group_id"))
+ if not group:
+ await message.answer("❌ Промогруппа не найдена")
+ await state.clear()
+ return
+
+ group = await update_promo_group(db, group, name=name)
+ await state.set_state(AdminStates.editing_promo_group_menu)
+
+ await _send_edit_menu_after_update(
message,
- state,
- "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT",
- "Введите новую скидку на трафик (0-100):",
+ texts,
+ group,
+ data.get("language", db_user.language),
+ texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
-async def process_edit_group_traffic(message: types.Message, state: FSMContext):
- texts = get_texts((await state.get_data()).get("language", "ru"))
+@admin_required
+@error_handler
+async def process_edit_group_traffic(
+ message: types.Message,
+ state: FSMContext,
+ db_user,
+ db: AsyncSession,
+):
+ data = await state.get_data()
+ texts = get_texts(data.get("language", db_user.language))
+
try:
value = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
- await state.update_data(edit_group_traffic=value)
- await state.set_state(AdminStates.editing_promo_group_server_discount)
- await _prompt_for_discount(
+ group = await get_promo_group_by_id(db, data.get("edit_group_id"))
+ if not group:
+ await message.answer("❌ Промогруппа не найдена")
+ await state.clear()
+ return
+
+ group = await update_promo_group(db, group, traffic_discount_percent=value)
+ await state.set_state(AdminStates.editing_promo_group_menu)
+
+ await _send_edit_menu_after_update(
message,
- state,
- "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT",
- "Введите новую скидку на серверы (0-100):",
+ texts,
+ group,
+ data.get("language", db_user.language),
+ texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
-async def process_edit_group_servers(message: types.Message, state: FSMContext):
- texts = get_texts((await state.get_data()).get("language", "ru"))
+@admin_required
+@error_handler
+async def process_edit_group_servers(
+ message: types.Message,
+ state: FSMContext,
+ db_user,
+ db: AsyncSession,
+):
+ data = await state.get_data()
+ texts = get_texts(data.get("language", db_user.language))
+
try:
value = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
- await state.update_data(edit_group_servers=value)
- await state.set_state(AdminStates.editing_promo_group_device_discount)
- await _prompt_for_discount(
+ group = await get_promo_group_by_id(db, data.get("edit_group_id"))
+ if not group:
+ await message.answer("❌ Промогруппа не найдена")
+ await state.clear()
+ return
+
+ group = await update_promo_group(db, group, server_discount_percent=value)
+ await state.set_state(AdminStates.editing_promo_group_menu)
+
+ await _send_edit_menu_after_update(
message,
- state,
- "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT",
- "Введите новую скидку на устройства (0-100):",
+ texts,
+ group,
+ data.get("language", db_user.language),
+ texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@@ -600,22 +961,21 @@ async def process_edit_group_devices(
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
- group = await get_promo_group_by_id(db, data["edit_group_id"])
+ group = await get_promo_group_by_id(db, data.get("edit_group_id"))
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
- await state.update_data(edit_group_devices=devices_discount)
- await state.set_state(AdminStates.editing_promo_group_period_discount)
+ group = await update_promo_group(db, group, device_discount_percent=devices_discount)
+ await state.set_state(AdminStates.editing_promo_group_menu)
- current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None))
- await _prompt_for_period_discounts(
+ await _send_edit_menu_after_update(
message,
- state,
- "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT",
- "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
- current_value=_format_period_discounts_value(current_discounts),
+ texts,
+ group,
+ data.get("language", db_user.language),
+ texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@@ -641,25 +1001,65 @@ async def process_edit_group_period_discounts(
)
return
- group = await get_promo_group_by_id(db, data["edit_group_id"])
+ 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 = await update_promo_group(db, group, period_discounts=period_discounts)
+ await state.set_state(AdminStates.editing_promo_group_menu)
+
+ await _send_edit_menu_after_update(
+ message,
+ texts,
group,
- name=data["edit_group_name"],
- 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,
+ data.get("language", db_user.language),
+ texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
- await state.clear()
- await message.answer(
- texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name)
+
+@admin_required
+@error_handler
+async def process_edit_group_auto_assign(
+ 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_assign_kopeks = _parse_auto_assign_threshold_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
+
+ group = await update_promo_group(
+ db,
+ group,
+ auto_assign_total_spent_kopeks=auto_assign_kopeks,
+ )
+ await state.set_state(AdminStates.editing_promo_group_menu)
+
+ await _send_edit_menu_after_update(
+ message,
+ texts,
+ group,
+ data.get("language", db_user.language),
+ texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@@ -796,7 +1196,14 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups")
dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_"))
dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create")
- dp.callback_query.register(start_edit_promo_group, F.data.startswith("promo_group_edit_"))
+ dp.callback_query.register(
+ prompt_edit_promo_group_field,
+ F.data.startswith("promo_group_edit_field_"),
+ )
+ dp.callback_query.register(
+ start_edit_promo_group,
+ F.data.regexp(r"^promo_group_edit_\d+$"),
+ )
dp.callback_query.register(
request_delete_promo_group,
F.data.startswith("promo_group_delete_")
@@ -828,6 +1235,10 @@ def register_handlers(dp: Dispatcher):
process_create_group_period_discounts,
AdminStates.creating_promo_group_period_discount,
)
+ dp.message.register(
+ process_create_group_auto_assign,
+ AdminStates.creating_promo_group_auto_assign,
+ )
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
dp.message.register(
@@ -846,3 +1257,7 @@ def register_handlers(dp: Dispatcher):
process_edit_group_period_discounts,
AdminStates.editing_promo_group_period_discount,
)
+ dp.message.register(
+ process_edit_group_auto_assign,
+ AdminStates.editing_promo_group_auto_assign,
+ )
diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py
new file mode 100644
index 00000000..a7437c3c
--- /dev/null
+++ b/app/services/promo_group_assignment.py
@@ -0,0 +1,92 @@
+import logging
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database.crud.transaction import get_user_total_spent_kopeks
+from app.database.models import PromoGroup, User
+
+logger = logging.getLogger(__name__)
+
+
+async def _get_best_group_for_spending(
+ db: AsyncSession,
+ total_spent_kopeks: int,
+) -> Optional[PromoGroup]:
+ if total_spent_kopeks <= 0:
+ return None
+
+ result = await db.execute(
+ select(PromoGroup)
+ .where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
+ .where(PromoGroup.auto_assign_total_spent_kopeks > 0)
+ .order_by(PromoGroup.auto_assign_total_spent_kopeks.desc(), PromoGroup.id.desc())
+ )
+ groups = result.scalars().all()
+
+ for group in groups:
+ threshold = group.auto_assign_total_spent_kopeks or 0
+ if threshold and total_spent_kopeks >= threshold:
+ return group
+
+ return None
+
+
+async def maybe_assign_promo_group_by_total_spent(
+ db: AsyncSession,
+ user_id: int,
+) -> Optional[PromoGroup]:
+ user = await db.get(User, user_id)
+ if not user:
+ logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
+ return None
+
+ if user.auto_promo_group_assigned:
+ logger.debug(
+ "Пользователь %s уже получал промогруппу автоматически, пропускаем", user.telegram_id
+ )
+ return None
+
+ total_spent = await get_user_total_spent_kopeks(db, user_id)
+ if total_spent <= 0:
+ return None
+
+ target_group = await _get_best_group_for_spending(db, total_spent)
+ if not target_group:
+ return None
+
+ try:
+ previous_group_id = user.promo_group_id
+ user.auto_promo_group_assigned = True
+ user.updated_at = datetime.utcnow()
+
+ if target_group.id != previous_group_id:
+ user.promo_group_id = target_group.id
+ user.promo_group = target_group
+ logger.info(
+ "🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s ₽",
+ user.telegram_id,
+ target_group.name,
+ total_spent / 100,
+ )
+ else:
+ logger.info(
+ "🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение",
+ user.telegram_id,
+ target_group.name,
+ )
+
+ await db.commit()
+ await db.refresh(user)
+
+ return target_group
+ except Exception as exc:
+ logger.error(
+ "Ошибка при автоматическом назначении промогруппы пользователю %s: %s",
+ user_id,
+ exc,
+ )
+ await db.rollback()
+ return None
diff --git a/app/states.py b/app/states.py
index 0073a4d4..45e87e21 100644
--- a/app/states.py
+++ b/app/states.py
@@ -69,12 +69,15 @@ 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_assign = State()
+ editing_promo_group_menu = 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_assign = State()
editing_squad_price = State()
editing_traffic_price = State()
diff --git a/locales/en.json b/locales/en.json
index 419dbe95..318249be 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -231,11 +231,24 @@
"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}):",
- "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
- "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_TRAFFIC_PROMPT": "Enter new traffic discount (0-100). Current value: {current}.",
+ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100). Current value: {current}.",
+ "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100). Current value: {current}.",
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.",
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
+ "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled",
+ "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽",
+ "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”",
+ "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending",
+ "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.",
+ "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.",
+ "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.",
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.",
diff --git a/locales/ru.json b/locales/ru.json
index e69b9d92..8a71c225 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -109,11 +109,24 @@
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
- "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
- "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
- "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
+ "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100). Текущее значение: {current}.",
+ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100). Текущее значение: {current}.",
+ "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100). Текущее значение: {current}.",
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
+ "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена",
+ "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽",
+ "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»",
+ "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам",
+ "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам",
+ "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.",
+ "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.",
+ "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.",
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.",