mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-03 20:34:10 +00:00
Merge pull request #739 from Fr1ngg/bedolaga/add-promotional-offers-feature-to-bot-mgwac3
Add admin promo offer management and targeted promotions
This commit is contained in:
@@ -33,6 +33,7 @@ from app.handlers.admin import (
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
promo_offers as admin_promo_offers,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates,
|
||||
backup as admin_backup,
|
||||
@@ -137,6 +138,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
admin_updates.register_handlers(dp)
|
||||
|
||||
@@ -16,6 +16,8 @@ async def upsert_discount_offer(
|
||||
discount_percent: int,
|
||||
bonus_amount_kopeks: int,
|
||||
valid_hours: int,
|
||||
effect_type: str = "balance_bonus",
|
||||
extra_data: Optional[dict] = None,
|
||||
) -> DiscountOffer:
|
||||
"""Create or refresh a discount offer for a user."""
|
||||
|
||||
@@ -37,6 +39,8 @@ async def upsert_discount_offer(
|
||||
offer.bonus_amount_kopeks = bonus_amount_kopeks
|
||||
offer.expires_at = expires_at
|
||||
offer.subscription_id = subscription_id
|
||||
offer.effect_type = effect_type
|
||||
offer.extra_data = extra_data
|
||||
else:
|
||||
offer = DiscountOffer(
|
||||
user_id=user_id,
|
||||
@@ -46,6 +50,8 @@ async def upsert_discount_offer(
|
||||
bonus_amount_kopeks=bonus_amount_kopeks,
|
||||
expires_at=expires_at,
|
||||
is_active=True,
|
||||
effect_type=effect_type,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
|
||||
165
app/database/crud/promo_offer_template.py
Normal file
165
app/database/crud/promo_offer_template.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PromoOfferTemplate
|
||||
|
||||
|
||||
DEFAULT_TEMPLATES: tuple[dict, ...] = (
|
||||
{
|
||||
"offer_type": "test_access",
|
||||
"name": "Тестовые сервера",
|
||||
"message_text": (
|
||||
"🔥 <b>Испытайте новые сервера</b>\n\n"
|
||||
"Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n"
|
||||
"Предложение действительно {valid_hours} ч."
|
||||
),
|
||||
"button_text": "🚀 Попробовать серверы",
|
||||
"valid_hours": 24,
|
||||
"discount_percent": 0,
|
||||
"bonus_amount_kopeks": 0,
|
||||
"test_duration_hours": 24,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
{
|
||||
"offer_type": "extend_discount",
|
||||
"name": "Скидка на продление",
|
||||
"message_text": (
|
||||
"💎 <b>Экономия {discount_percent}% при продлении</b>\n\n"
|
||||
"Мы начислим {bonus_amount} на баланс после активации, чтобы продление обошлось дешевле.\n"
|
||||
"Срок действия предложения — {valid_hours} ч."
|
||||
),
|
||||
"button_text": "🎁 Получить скидку",
|
||||
"valid_hours": 24,
|
||||
"discount_percent": 20,
|
||||
"bonus_amount_kopeks": settings.PRICE_30_DAYS * 20 // 100,
|
||||
"test_duration_hours": None,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
{
|
||||
"offer_type": "purchase_discount",
|
||||
"name": "Скидка на покупку",
|
||||
"message_text": (
|
||||
"🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\n\n"
|
||||
"Начислим {bonus_amount} после активации — используйте бонус при оплате новой подписки.\n"
|
||||
"Предложение действует {valid_hours} ч."
|
||||
),
|
||||
"button_text": "🎁 Забрать скидку",
|
||||
"valid_hours": 48,
|
||||
"discount_percent": 25,
|
||||
"bonus_amount_kopeks": settings.PRICE_30_DAYS * 25 // 100,
|
||||
"test_duration_hours": None,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _format_template_fields(payload: dict) -> dict:
|
||||
data = dict(payload)
|
||||
data.setdefault("valid_hours", 24)
|
||||
data.setdefault("discount_percent", 0)
|
||||
data.setdefault("bonus_amount_kopeks", 0)
|
||||
data.setdefault("test_duration_hours", None)
|
||||
data.setdefault("test_squad_uuids", [])
|
||||
return data
|
||||
|
||||
|
||||
async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int] = None) -> List[PromoOfferTemplate]:
|
||||
templates: List[PromoOfferTemplate] = []
|
||||
|
||||
for template_data in DEFAULT_TEMPLATES:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data["offer_type"])
|
||||
)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
templates.append(existing)
|
||||
continue
|
||||
|
||||
payload = _format_template_fields(template_data)
|
||||
template = PromoOfferTemplate(
|
||||
name=payload["name"],
|
||||
offer_type=payload["offer_type"],
|
||||
message_text=payload["message_text"],
|
||||
button_text=payload["button_text"],
|
||||
valid_hours=payload["valid_hours"],
|
||||
discount_percent=payload["discount_percent"],
|
||||
bonus_amount_kopeks=payload["bonus_amount_kopeks"],
|
||||
test_duration_hours=payload["test_duration_hours"],
|
||||
test_squad_uuids=payload["test_squad_uuids"],
|
||||
is_active=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
templates.append(template)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
async def list_promo_offer_templates(db: AsyncSession) -> List[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> Optional[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> Optional[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_promo_offer_template(
|
||||
db: AsyncSession,
|
||||
template: PromoOfferTemplate,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
message_text: Optional[str] = None,
|
||||
button_text: Optional[str] = None,
|
||||
valid_hours: Optional[int] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
bonus_amount_kopeks: Optional[int] = None,
|
||||
test_duration_hours: Optional[int] = None,
|
||||
test_squad_uuids: Optional[Iterable[str]] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> PromoOfferTemplate:
|
||||
if name is not None:
|
||||
template.name = name
|
||||
if message_text is not None:
|
||||
template.message_text = message_text
|
||||
if button_text is not None:
|
||||
template.button_text = button_text
|
||||
if valid_hours is not None:
|
||||
template.valid_hours = valid_hours
|
||||
if discount_percent is not None:
|
||||
template.discount_percent = discount_percent
|
||||
if bonus_amount_kopeks is not None:
|
||||
template.bonus_amount_kopeks = bonus_amount_kopeks
|
||||
if test_duration_hours is not None or template.offer_type == "test_access":
|
||||
template.test_duration_hours = test_duration_hours
|
||||
if test_squad_uuids is not None:
|
||||
template.test_squad_uuids = list(test_squad_uuids)
|
||||
if is_active is not None:
|
||||
template.is_active = is_active
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
@@ -12,6 +12,7 @@ from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
PaymentMethod,
|
||||
@@ -515,6 +516,56 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
|
||||
now = datetime.utcnow()
|
||||
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
|
||||
if segment == "no_subscription":
|
||||
query = base_query.outerjoin(Subscription).where(Subscription.id.is_(None))
|
||||
else:
|
||||
query = base_query.join(Subscription)
|
||||
|
||||
if segment == "paid_active":
|
||||
query = query.where(
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
elif segment == "paid_expired":
|
||||
query = query.where(
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
or_(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
Subscription.end_date <= now,
|
||||
),
|
||||
)
|
||||
elif segment == "trial_active":
|
||||
query = query.where(
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
elif segment == "trial_expired":
|
||||
query = query.where(
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
or_(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
Subscription.end_date <= now,
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.warning("Неизвестный сегмент для промо: %s", segment)
|
||||
return []
|
||||
|
||||
result = await db.execute(query.order_by(User.id))
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
|
||||
|
||||
|
||||
@@ -453,6 +453,7 @@ class Subscription(Base):
|
||||
|
||||
user = relationship("User", back_populates="subscription")
|
||||
discount_offers = relationship("DiscountOffer", back_populates="subscription")
|
||||
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
@@ -811,12 +812,55 @@ class DiscountOffer(Base):
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
claimed_at = Column(DateTime, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
effect_type = Column(String(50), nullable=False, default="balance_bonus")
|
||||
extra_data = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", back_populates="discount_offers")
|
||||
subscription = relationship("Subscription", back_populates="discount_offers")
|
||||
|
||||
|
||||
class PromoOfferTemplate(Base):
|
||||
__tablename__ = "promo_offer_templates"
|
||||
__table_args__ = (
|
||||
Index("ix_promo_offer_templates_type", "offer_type"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
offer_type = Column(String(50), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
button_text = Column(String(255), nullable=False)
|
||||
valid_hours = Column(Integer, nullable=False, default=24)
|
||||
discount_percent = Column(Integer, nullable=False, default=0)
|
||||
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
test_duration_hours = Column(Integer, nullable=True)
|
||||
test_squad_uuids = Column(JSON, default=list)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User")
|
||||
|
||||
|
||||
class SubscriptionTemporaryAccess(Base):
|
||||
__tablename__ = "subscription_temporary_access"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
|
||||
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="CASCADE"), nullable=False)
|
||||
squad_uuid = Column(String(255), nullable=False)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
deactivated_at = Column(DateTime, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
was_already_connected = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
subscription = relationship("Subscription", back_populates="temporary_accesses")
|
||||
offer = relationship("DiscountOffer")
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
|
||||
@@ -728,6 +728,8 @@ async def create_discount_offers_table():
|
||||
expires_at DATETIME NOT NULL,
|
||||
claimed_at DATETIME NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus',
|
||||
extra_data TEXT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -751,6 +753,8 @@ async def create_discount_offers_table():
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
claimed_at TIMESTAMP NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus',
|
||||
extra_data JSON NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
@@ -772,6 +776,8 @@ async def create_discount_offers_table():
|
||||
expires_at DATETIME NOT NULL,
|
||||
claimed_at DATETIME NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus',
|
||||
extra_data JSON NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -793,6 +799,226 @@ async def create_discount_offers_table():
|
||||
logger.error(f"Ошибка создания таблицы discount_offers: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_discount_offer_columns():
|
||||
try:
|
||||
effect_exists = await check_column_exists('discount_offers', 'effect_type')
|
||||
extra_exists = await check_column_exists('discount_offers', 'extra_data')
|
||||
|
||||
if effect_exists and extra_exists:
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not effect_exists:
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'"
|
||||
))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'"
|
||||
))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'"
|
||||
))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
if not extra_exists:
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN extra_data TEXT NULL"
|
||||
))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN extra_data JSON NULL"
|
||||
))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN extra_data JSON NULL"
|
||||
))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Колонки effect_type и extra_data для discount_offers проверены")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления колонок discount_offers: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_promo_offer_templates_table():
|
||||
table_exists = await check_table_exists('promo_offer_templates')
|
||||
if table_exists:
|
||||
logger.info("Таблица promo_offer_templates уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE promo_offer_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
offer_type VARCHAR(50) NOT NULL,
|
||||
message_text TEXT NOT NULL,
|
||||
button_text VARCHAR(255) NOT NULL,
|
||||
valid_hours INTEGER NOT NULL DEFAULT 24,
|
||||
discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
test_duration_hours INTEGER NULL,
|
||||
test_squad_uuids TEXT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_by INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_promo_offer_templates_type ON promo_offer_templates(offer_type);
|
||||
"""
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS promo_offer_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
offer_type VARCHAR(50) NOT NULL,
|
||||
message_text TEXT NOT NULL,
|
||||
button_text VARCHAR(255) NOT NULL,
|
||||
valid_hours INTEGER NOT NULL DEFAULT 24,
|
||||
discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
test_duration_hours INTEGER NULL,
|
||||
test_squad_uuids JSON NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_promo_offer_templates_type ON promo_offer_templates(offer_type);
|
||||
"""
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS promo_offer_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
offer_type VARCHAR(50) NOT NULL,
|
||||
message_text TEXT NOT NULL,
|
||||
button_text VARCHAR(255) NOT NULL,
|
||||
valid_hours INT NOT NULL DEFAULT 24,
|
||||
discount_percent INT NOT NULL DEFAULT 0,
|
||||
bonus_amount_kopeks INT NOT NULL DEFAULT 0,
|
||||
test_duration_hours INT NULL,
|
||||
test_squad_uuids JSON NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_promo_offer_templates_type ON promo_offer_templates(offer_type);
|
||||
"""
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
|
||||
logger.info("✅ Таблица promo_offer_templates успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы promo_offer_templates: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_subscription_temporary_access_table():
|
||||
table_exists = await check_table_exists('subscription_temporary_access')
|
||||
if table_exists:
|
||||
logger.info("Таблица subscription_temporary_access уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE subscription_temporary_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subscription_id INTEGER NOT NULL,
|
||||
offer_id INTEGER NOT NULL,
|
||||
squad_uuid VARCHAR(255) NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deactivated_at DATETIME NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
was_already_connected BOOLEAN NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(offer_id) REFERENCES discount_offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX ix_temp_access_subscription ON subscription_temporary_access(subscription_id);
|
||||
CREATE INDEX ix_temp_access_offer ON subscription_temporary_access(offer_id);
|
||||
CREATE INDEX ix_temp_access_active ON subscription_temporary_access(is_active, expires_at);
|
||||
"""
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS subscription_temporary_access (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||
offer_id INTEGER NOT NULL REFERENCES discount_offers(id) ON DELETE CASCADE,
|
||||
squad_uuid VARCHAR(255) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
deactivated_at TIMESTAMP NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
was_already_connected BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_temp_access_subscription ON subscription_temporary_access(subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_temp_access_offer ON subscription_temporary_access(offer_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_temp_access_active ON subscription_temporary_access(is_active, expires_at);
|
||||
"""
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS subscription_temporary_access (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
subscription_id INT NOT NULL,
|
||||
offer_id INT NOT NULL,
|
||||
squad_uuid VARCHAR(255) NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deactivated_at DATETIME NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
was_already_connected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(offer_id) REFERENCES discount_offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX ix_temp_access_subscription ON subscription_temporary_access(subscription_id);
|
||||
CREATE INDEX ix_temp_access_offer ON subscription_temporary_access(offer_id);
|
||||
CREATE INDEX ix_temp_access_active ON subscription_temporary_access(is_active, expires_at);
|
||||
"""
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
|
||||
logger.info("✅ Таблица subscription_temporary_access успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы subscription_temporary_access: {e}")
|
||||
return False
|
||||
|
||||
async def create_user_messages_table():
|
||||
table_exists = await check_table_exists('user_messages')
|
||||
if table_exists:
|
||||
@@ -2201,6 +2427,26 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей discount_offers")
|
||||
|
||||
discount_columns_ready = await ensure_discount_offer_columns()
|
||||
if discount_columns_ready:
|
||||
logger.info("✅ Колонки discount_offers в актуальном состоянии")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить колонки discount_offers")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_TEMPLATES ===")
|
||||
promo_templates_created = await create_promo_offer_templates_table()
|
||||
if promo_templates_created:
|
||||
logger.info("✅ Таблица promo_offer_templates готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей promo_offer_templates")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_TEMPORARY_ACCESS ===")
|
||||
temp_access_created = await create_subscription_temporary_access_table()
|
||||
if temp_access_created:
|
||||
logger.info("✅ Таблица subscription_temporary_access готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей subscription_temporary_access")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===")
|
||||
user_messages_created = await create_user_messages_table()
|
||||
if user_messages_created:
|
||||
@@ -2402,6 +2648,11 @@ async def check_migration_status():
|
||||
"users_auto_promo_group_assigned_column": False,
|
||||
"users_auto_promo_group_threshold_column": False,
|
||||
"subscription_crypto_link_column": False,
|
||||
"discount_offers_table": False,
|
||||
"discount_offers_effect_column": False,
|
||||
"discount_offers_extra_column": False,
|
||||
"promo_offer_templates_table": False,
|
||||
"subscription_temporary_access_table": False,
|
||||
}
|
||||
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
@@ -2413,6 +2664,12 @@ async def check_migration_status():
|
||||
status["promo_groups_table"] = await check_table_exists('promo_groups')
|
||||
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
|
||||
|
||||
status["discount_offers_table"] = await check_table_exists('discount_offers')
|
||||
status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type')
|
||||
status["discount_offers_extra_column"] = await check_column_exists('discount_offers', 'extra_data')
|
||||
status["promo_offer_templates_table"] = await check_table_exists('promo_offer_templates')
|
||||
status["subscription_temporary_access_table"] = await check_table_exists('subscription_temporary_access')
|
||||
|
||||
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')
|
||||
|
||||
525
app/handlers/admin/promo_offers.py
Normal file
525
app/handlers/admin/promo_offers.py
Normal file
@@ -0,0 +1,525 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Sequence
|
||||
|
||||
from aiogram import Dispatcher, F
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.discount_offer import upsert_discount_offer
|
||||
from app.database.crud.promo_offer_template import (
|
||||
ensure_default_templates,
|
||||
get_promo_offer_template_by_id,
|
||||
list_promo_offer_templates,
|
||||
update_promo_offer_template,
|
||||
)
|
||||
from app.database.crud.user import get_users_for_promo_segment
|
||||
from app.database.models import PromoOfferTemplate, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
OFFER_TYPE_CONFIG = {
|
||||
"test_access": {
|
||||
"icon": "🧪",
|
||||
"label_key": "ADMIN_PROMO_OFFER_TEST_ACCESS",
|
||||
"default_label": "Тестовые сервера",
|
||||
"allowed_segments": [
|
||||
("paid_active", "🟢 Активные платные"),
|
||||
("trial_active", "🎁 Активные триалы"),
|
||||
],
|
||||
"effect_type": "test_access",
|
||||
},
|
||||
"extend_discount": {
|
||||
"icon": "💎",
|
||||
"label_key": "ADMIN_PROMO_OFFER_EXTEND",
|
||||
"default_label": "Скидка на продление",
|
||||
"allowed_segments": [
|
||||
("paid_active", "🟢 Активные платные"),
|
||||
],
|
||||
"effect_type": "balance_bonus",
|
||||
},
|
||||
"purchase_discount": {
|
||||
"icon": "🎯",
|
||||
"label_key": "ADMIN_PROMO_OFFER_PURCHASE",
|
||||
"default_label": "Скидка на покупку",
|
||||
"allowed_segments": [
|
||||
("paid_expired", "🔴 Истёкшие платные"),
|
||||
("trial_expired", "🥶 Истёкшие триалы"),
|
||||
],
|
||||
"effect_type": "balance_bonus",
|
||||
},
|
||||
}
|
||||
|
||||
def _format_bonus(template: PromoOfferTemplate) -> str:
|
||||
return settings.format_price(template.bonus_amount_kopeks or 0)
|
||||
|
||||
|
||||
def _render_template_text(template: PromoOfferTemplate, language: str) -> str:
|
||||
replacements = {
|
||||
"discount_percent": template.discount_percent,
|
||||
"bonus_amount": _format_bonus(template),
|
||||
"valid_hours": template.valid_hours,
|
||||
"test_duration_hours": template.test_duration_hours or 0,
|
||||
}
|
||||
try:
|
||||
return template.message_text.format(**replacements)
|
||||
except Exception: # pragma: no cover - fallback for invalid placeholders
|
||||
logger.warning("Не удалось форматировать текст промо-предложения %s", template.id)
|
||||
return template.message_text
|
||||
|
||||
|
||||
def _build_templates_keyboard(templates: Sequence[PromoOfferTemplate], language: str) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
rows: List[List[InlineKeyboardButton]] = []
|
||||
for template in templates:
|
||||
config = OFFER_TYPE_CONFIG.get(template.offer_type, {})
|
||||
icon = config.get("icon", "📨")
|
||||
label = texts.t(config.get("label_key", ""), config.get("default_label", template.offer_type))
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{icon} {label}",
|
||||
callback_data=f"promo_offer_{template.id}",
|
||||
)
|
||||
])
|
||||
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def _build_offer_detail_keyboard(template: PromoOfferTemplate, language: str) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
config = OFFER_TYPE_CONFIG.get(template.offer_type, {})
|
||||
rows: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
rows.append([
|
||||
InlineKeyboardButton(text="✏️ Текст", callback_data=f"promo_offer_edit_message_{template.id}"),
|
||||
InlineKeyboardButton(text="🪄 Кнопка", callback_data=f"promo_offer_edit_button_{template.id}"),
|
||||
])
|
||||
rows.append([
|
||||
InlineKeyboardButton(text="⏱️ Срок", callback_data=f"promo_offer_edit_valid_{template.id}"),
|
||||
])
|
||||
|
||||
if template.offer_type != "test_access":
|
||||
rows[-1].append(InlineKeyboardButton(text="📉 %", callback_data=f"promo_offer_edit_discount_{template.id}"))
|
||||
rows.append([
|
||||
InlineKeyboardButton(text="💰 Бонус", callback_data=f"promo_offer_edit_bonus_{template.id}"),
|
||||
])
|
||||
else:
|
||||
rows.append([
|
||||
InlineKeyboardButton(text="⏳ Длительность", callback_data=f"promo_offer_edit_duration_{template.id}"),
|
||||
InlineKeyboardButton(text="🌍 Сквады", callback_data=f"promo_offer_edit_squads_{template.id}"),
|
||||
])
|
||||
|
||||
rows.append([
|
||||
InlineKeyboardButton(text="📬 Отправить", callback_data=f"promo_offer_send_menu_{template.id}"),
|
||||
])
|
||||
rows.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages"),
|
||||
])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def _build_send_keyboard(template: PromoOfferTemplate, language: str) -> InlineKeyboardMarkup:
|
||||
config = OFFER_TYPE_CONFIG.get(template.offer_type, {})
|
||||
segments = config.get("allowed_segments", [])
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=f"promo_offer_send_{template.id}_{segment}",
|
||||
)
|
||||
]
|
||||
for segment, label in segments
|
||||
]
|
||||
texts = get_texts(language)
|
||||
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_offer_{template.id}")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def _describe_offer(template: PromoOfferTemplate, language: str) -> str:
|
||||
texts = get_texts(language)
|
||||
config = OFFER_TYPE_CONFIG.get(template.offer_type, {})
|
||||
label = texts.t(config.get("label_key", ""), config.get("default_label", template.offer_type))
|
||||
icon = config.get("icon", "📨")
|
||||
|
||||
lines = [f"{icon} <b>{template.name}</b>", ""]
|
||||
lines.append(texts.t("ADMIN_PROMO_OFFER_TYPE", "Тип: {label}").format(label=label))
|
||||
lines.append(texts.t("ADMIN_PROMO_OFFER_VALID", "Срок действия: {hours} ч").format(hours=template.valid_hours))
|
||||
|
||||
if template.offer_type != "test_access":
|
||||
lines.append(texts.t("ADMIN_PROMO_OFFER_DISCOUNT", "Скидка: {percent}%").format(percent=template.discount_percent))
|
||||
lines.append(texts.t("ADMIN_PROMO_OFFER_BONUS", "Бонус: {amount}").format(amount=_format_bonus(template)))
|
||||
else:
|
||||
duration = template.test_duration_hours or 0
|
||||
lines.append(texts.t("ADMIN_PROMO_OFFER_TEST_DURATION", "Доступ: {hours} ч").format(hours=duration))
|
||||
squads = template.test_squad_uuids or []
|
||||
if squads:
|
||||
lines.append(texts.t("ADMIN_PROMO_OFFER_TEST_SQUADS", "Сквады: {squads}").format(squads=", ".join(squads)))
|
||||
else:
|
||||
lines.append(texts.t("ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY", "Сквады: не указаны"))
|
||||
|
||||
allowed_segments = config.get("allowed_segments", [])
|
||||
if allowed_segments:
|
||||
segment_labels = [label for _, label in allowed_segments]
|
||||
lines.append("")
|
||||
lines.append(texts.t("ADMIN_PROMO_OFFER_ALLOWED", "Доступные категории:") )
|
||||
lines.extend(segment_labels)
|
||||
|
||||
lines.append("")
|
||||
lines.append(texts.t("ADMIN_PROMO_OFFER_PREVIEW", "Предпросмотр:"))
|
||||
lines.append(_render_template_text(template, language))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_offers_menu(callback: CallbackQuery, db_user: User, db: AsyncSession):
|
||||
await ensure_default_templates(db, created_by=db_user.id)
|
||||
templates = await list_promo_offer_templates(db)
|
||||
texts = get_texts(db_user.language)
|
||||
header = texts.t("ADMIN_PROMO_OFFERS_TITLE", "🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:")
|
||||
await callback.message.edit_text(
|
||||
header,
|
||||
reply_markup=_build_templates_keyboard(templates, db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_offer_details(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
try:
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
except (ValueError, AttributeError):
|
||||
await callback.answer("❌ Неверный идентификатор", show_alert=True)
|
||||
return
|
||||
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template:
|
||||
await callback.answer("❌ Предложение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(selected_promo_offer=template.id)
|
||||
description = _describe_offer(template, db_user.language)
|
||||
await callback.message.edit_text(
|
||||
description,
|
||||
reply_markup=_build_offer_detail_keyboard(template, db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _prompt_edit(callback: CallbackQuery, state: FSMContext, template_id: int, prompt: str, new_state):
|
||||
await state.update_data(
|
||||
selected_promo_offer=template_id,
|
||||
promo_edit_message_id=callback.message.message_id,
|
||||
promo_edit_chat_id=callback.message.chat.id,
|
||||
)
|
||||
await callback.message.edit_text(prompt)
|
||||
await state.set_state(new_state)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_message(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
texts = get_texts(db_user.language)
|
||||
prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_MESSAGE", "Введите новый текст предложения:")
|
||||
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_button(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
texts = get_texts(db_user.language)
|
||||
prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_BUTTON", "Введите новый текст кнопки:")
|
||||
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_button)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_valid(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
texts = get_texts(db_user.language)
|
||||
prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_VALID", "Укажите срок действия (в часах):")
|
||||
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_valid_hours)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_discount(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
texts = get_texts(db_user.language)
|
||||
prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_DISCOUNT", "Введите размер скидки в процентах:")
|
||||
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_discount)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_bonus(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
texts = get_texts(db_user.language)
|
||||
prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_BONUS", "Введите размер бонуса в копейках:")
|
||||
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_bonus)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_duration(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
texts = get_texts(db_user.language)
|
||||
prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_DURATION", "Введите длительность тестового доступа (в часах):")
|
||||
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_test_duration)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_squads(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
texts = get_texts(db_user.language)
|
||||
prompt = texts.t(
|
||||
"ADMIN_PROMO_OFFER_PROMPT_SQUADS",
|
||||
"Перечислите UUID сквадов через запятую или пробел. Для очистки отправьте 'clear':",
|
||||
)
|
||||
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_squads)
|
||||
|
||||
|
||||
async def _handle_edit_field(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
db_user: User,
|
||||
field: str,
|
||||
):
|
||||
data = await state.get_data()
|
||||
template_id = data.get("selected_promo_offer")
|
||||
if not template_id:
|
||||
await message.answer("❌ Не удалось определить предложение. Повторите действие.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
template = await get_promo_offer_template_by_id(db, int(template_id))
|
||||
if not template:
|
||||
await message.answer("❌ Предложение не найдено.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
value = message.text.strip()
|
||||
try:
|
||||
if field == "message_text":
|
||||
await update_promo_offer_template(db, template, message_text=value)
|
||||
elif field == "button_text":
|
||||
await update_promo_offer_template(db, template, button_text=value)
|
||||
elif field == "valid_hours":
|
||||
hours = max(1, int(value))
|
||||
await update_promo_offer_template(db, template, valid_hours=hours)
|
||||
elif field == "discount_percent":
|
||||
percent = max(0, min(100, int(value)))
|
||||
await update_promo_offer_template(db, template, discount_percent=percent)
|
||||
elif field == "bonus_amount_kopeks":
|
||||
bonus = max(0, int(value))
|
||||
await update_promo_offer_template(db, template, bonus_amount_kopeks=bonus)
|
||||
elif field == "test_duration_hours":
|
||||
hours = max(1, int(value))
|
||||
await update_promo_offer_template(db, template, test_duration_hours=hours)
|
||||
elif field == "test_squad_uuids":
|
||||
if value.lower() in {"clear", "очистить"}:
|
||||
squads: List[str] = []
|
||||
else:
|
||||
squads = [item for item in re.split(r"[\s,]+", value) if item]
|
||||
await update_promo_offer_template(db, template, test_squad_uuids=squads)
|
||||
else:
|
||||
raise ValueError("Unsupported field")
|
||||
except ValueError:
|
||||
await message.answer("❌ Некорректное значение. Попробуйте снова.")
|
||||
return
|
||||
|
||||
edit_message_id = data.get("promo_edit_message_id")
|
||||
edit_chat_id = data.get("promo_edit_chat_id", message.chat.id)
|
||||
|
||||
await state.clear()
|
||||
updated_template = await get_promo_offer_template_by_id(db, template.id)
|
||||
if not updated_template:
|
||||
await message.answer("❌ Предложение не найдено после обновления.")
|
||||
return
|
||||
|
||||
description = _describe_offer(updated_template, db_user.language)
|
||||
reply_markup = _build_offer_detail_keyboard(updated_template, db_user.language)
|
||||
|
||||
if edit_message_id:
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
description,
|
||||
chat_id=edit_chat_id,
|
||||
message_id=edit_message_id,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except TelegramBadRequest as exc:
|
||||
logger.warning("Не удалось обновить сообщение редактирования промо: %s", exc)
|
||||
await message.answer(description, reply_markup=reply_markup, parse_mode="HTML")
|
||||
else:
|
||||
await message.answer(description, reply_markup=reply_markup, parse_mode="HTML")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_send_segments(callback: CallbackQuery, db_user: User, db: AsyncSession):
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template:
|
||||
await callback.answer("❌ Предложение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=_build_send_keyboard(template, db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: AsyncSession):
|
||||
try:
|
||||
_, _, template_id, segment = callback.data.split("_", 3)
|
||||
template_id = int(template_id)
|
||||
except (ValueError, AttributeError):
|
||||
await callback.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template:
|
||||
await callback.answer("❌ Предложение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
config = OFFER_TYPE_CONFIG.get(template.offer_type, {})
|
||||
allowed_segments = {seg for seg, _ in config.get("allowed_segments", [])}
|
||||
if segment not in allowed_segments:
|
||||
await callback.answer("⚠️ Нельзя отправить это предложение выбранной категории", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.t("ADMIN_PROMO_OFFER_SENDING", "Начинаем рассылку..."), show_alert=True)
|
||||
|
||||
users = await get_users_for_promo_segment(db, segment)
|
||||
if not users:
|
||||
await callback.message.answer(texts.t("ADMIN_PROMO_OFFER_NO_USERS", "Подходящих пользователей не найдено."))
|
||||
return
|
||||
|
||||
sent = 0
|
||||
failed = 0
|
||||
effect_type = config.get("effect_type", "balance_bonus")
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
offer_record = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=user.subscription.id if user.subscription else None,
|
||||
notification_type=f"promo_template_{template.id}",
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=template.bonus_amount_kopeks,
|
||||
valid_hours=template.valid_hours,
|
||||
effect_type=effect_type,
|
||||
extra_data={
|
||||
"template_id": template.id,
|
||||
"offer_type": template.offer_type,
|
||||
"test_duration_hours": template.test_duration_hours,
|
||||
"test_squad_uuids": template.test_squad_uuids,
|
||||
},
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=template.button_text, callback_data=f"claim_discount_{offer_record.id}")]
|
||||
])
|
||||
|
||||
message_text = _render_template_text(template, user.language or db_user.language)
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
sent += 1
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
|
||||
failed += 1
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
|
||||
failed += 1
|
||||
|
||||
summary = texts.t(
|
||||
"ADMIN_PROMO_OFFER_RESULT",
|
||||
"📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",
|
||||
).format(sent=sent, failed=failed)
|
||||
refreshed = await get_promo_offer_template_by_id(db, template.id)
|
||||
if refreshed:
|
||||
description = _describe_offer(refreshed, db_user.language)
|
||||
await callback.message.edit_text(
|
||||
description,
|
||||
reply_markup=_build_offer_detail_keyboard(refreshed, db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.message.answer(summary)
|
||||
|
||||
|
||||
async def process_edit_message_text(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
|
||||
await _handle_edit_field(message, state, db, db_user, "message_text")
|
||||
|
||||
|
||||
async def process_edit_button_text(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
|
||||
await _handle_edit_field(message, state, db, db_user, "button_text")
|
||||
|
||||
|
||||
async def process_edit_valid_hours(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
|
||||
await _handle_edit_field(message, state, db, db_user, "valid_hours")
|
||||
|
||||
|
||||
async def process_edit_discount_percent(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
|
||||
await _handle_edit_field(message, state, db, db_user, "discount_percent")
|
||||
|
||||
|
||||
async def process_edit_bonus_amount(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
|
||||
await _handle_edit_field(message, state, db, db_user, "bonus_amount_kopeks")
|
||||
|
||||
|
||||
async def process_edit_test_duration(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
|
||||
await _handle_edit_field(message, state, db, db_user, "test_duration_hours")
|
||||
|
||||
|
||||
async def process_edit_test_squads(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
|
||||
await _handle_edit_field(message, state, db, db_user, "test_squad_uuids")
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promo_offers_menu, F.data == "admin_promo_offers")
|
||||
dp.callback_query.register(prompt_edit_message, F.data.startswith("promo_offer_edit_message_"))
|
||||
dp.callback_query.register(prompt_edit_button, F.data.startswith("promo_offer_edit_button_"))
|
||||
dp.callback_query.register(prompt_edit_valid, F.data.startswith("promo_offer_edit_valid_"))
|
||||
dp.callback_query.register(prompt_edit_discount, F.data.startswith("promo_offer_edit_discount_"))
|
||||
dp.callback_query.register(prompt_edit_bonus, F.data.startswith("promo_offer_edit_bonus_"))
|
||||
dp.callback_query.register(prompt_edit_duration, F.data.startswith("promo_offer_edit_duration_"))
|
||||
dp.callback_query.register(prompt_edit_squads, F.data.startswith("promo_offer_edit_squads_"))
|
||||
dp.callback_query.register(show_send_segments, F.data.startswith("promo_offer_send_menu_"))
|
||||
dp.callback_query.register(send_offer_to_segment, F.data.startswith("promo_offer_send_"))
|
||||
dp.callback_query.register(show_promo_offer_details, F.data.startswith("promo_offer_"))
|
||||
|
||||
dp.message.register(process_edit_message_text, AdminStates.editing_promo_offer_message)
|
||||
dp.message.register(process_edit_button_text, AdminStates.editing_promo_offer_button)
|
||||
dp.message.register(process_edit_valid_hours, AdminStates.editing_promo_offer_valid_hours)
|
||||
dp.message.register(process_edit_discount_percent, AdminStates.editing_promo_offer_discount)
|
||||
dp.message.register(process_edit_bonus_amount, AdminStates.editing_promo_offer_bonus)
|
||||
dp.message.register(process_edit_test_duration, AdminStates.editing_promo_offer_test_duration)
|
||||
dp.message.register(process_edit_test_squads, AdminStates.editing_promo_offer_squads)
|
||||
@@ -52,6 +52,7 @@ from app.services.subscription_checkout_service import (
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.states import SubscriptionStates
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
@@ -5052,6 +5053,47 @@ async def claim_discount_offer(
|
||||
)
|
||||
return
|
||||
|
||||
effect_type = (offer.effect_type or "balance_bonus").lower()
|
||||
|
||||
if effect_type == "test_access":
|
||||
success, added_squads, expires_at, error_code = await promo_offer_service.grant_test_access(
|
||||
db,
|
||||
db_user,
|
||||
offer,
|
||||
)
|
||||
|
||||
if not success:
|
||||
if error_code == "subscription_missing":
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_NO_SUBSCRIPTION",
|
||||
"❌ Для активации предложения необходима действующая подписка.",
|
||||
)
|
||||
elif error_code == "squads_missing":
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_NO_SQUADS",
|
||||
"❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
|
||||
)
|
||||
else:
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_UNKNOWN_ERROR",
|
||||
"❌ Не удалось активировать предложение. Попробуйте позже.",
|
||||
)
|
||||
await callback.answer(error_message, show_alert=True)
|
||||
return
|
||||
|
||||
await mark_offer_claimed(db, offer)
|
||||
|
||||
expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else ""
|
||||
success_message = texts.get(
|
||||
"TEST_ACCESS_ACTIVATED_MESSAGE",
|
||||
"🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
|
||||
).format(expires_at=expires_text)
|
||||
|
||||
popup_text = texts.get("TEST_ACCESS_ACTIVATED_POPUP", "✅ Доступ выдан!")
|
||||
await callback.answer(popup_text, show_alert=True)
|
||||
await callback.message.answer(success_message)
|
||||
return
|
||||
|
||||
bonus_amount = offer.bonus_amount_kopeks or 0
|
||||
if bonus_amount > 0:
|
||||
success = await add_user_balance(
|
||||
|
||||
@@ -101,6 +101,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
|
||||
callback_data="admin_promo_offers"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_WELCOME_TEXT", "👋 Приветственный текст"),
|
||||
|
||||
@@ -40,6 +40,7 @@ from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPIError,
|
||||
@@ -179,6 +180,10 @@ class MonitoringService:
|
||||
if expired_offers:
|
||||
logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений")
|
||||
|
||||
cleaned_test_access = await promo_offer_service.cleanup_expired_test_access(db)
|
||||
if cleaned_test_access:
|
||||
logger.info(f"🧹 Отозвано {cleaned_test_access} истекших тестовых доступов к сквадам")
|
||||
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
|
||||
145
app/services/promo_offer_service.py
Normal file
145
app/services/promo_offer_service.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
DiscountOffer,
|
||||
Subscription,
|
||||
SubscriptionTemporaryAccess,
|
||||
User,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PromoOfferService:
|
||||
def __init__(self) -> None:
|
||||
self.subscription_service = SubscriptionService()
|
||||
|
||||
async def grant_test_access(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
offer: DiscountOffer,
|
||||
) -> Tuple[bool, Optional[List[str]], Optional[datetime], str]:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if not subscription:
|
||||
return False, None, None, "subscription_missing"
|
||||
|
||||
payload = offer.extra_data or {}
|
||||
squad_uuids: Sequence[str] = payload.get("test_squad_uuids") or payload.get("squads") or []
|
||||
if not squad_uuids:
|
||||
return False, None, None, "squads_missing"
|
||||
|
||||
try:
|
||||
duration_hours = int(payload.get("test_duration_hours") or payload.get("duration_hours") or 24)
|
||||
except (TypeError, ValueError):
|
||||
duration_hours = 24
|
||||
|
||||
if duration_hours <= 0:
|
||||
duration_hours = 24
|
||||
|
||||
now = datetime.utcnow()
|
||||
expires_at = now + timedelta(hours=duration_hours)
|
||||
|
||||
connected = set(subscription.connected_squads or [])
|
||||
newly_added: List[str] = []
|
||||
|
||||
for squad_uuid in squad_uuids:
|
||||
normalized_uuid = str(squad_uuid)
|
||||
existing_result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
.where(
|
||||
SubscriptionTemporaryAccess.offer_id == offer.id,
|
||||
SubscriptionTemporaryAccess.squad_uuid == normalized_uuid,
|
||||
)
|
||||
.order_by(SubscriptionTemporaryAccess.id.desc())
|
||||
)
|
||||
existing_access = existing_result.scalars().first()
|
||||
if existing_access and existing_access.is_active:
|
||||
existing_access.expires_at = expires_at
|
||||
continue
|
||||
|
||||
was_already_connected = normalized_uuid in connected
|
||||
if not was_already_connected:
|
||||
connected.add(normalized_uuid)
|
||||
newly_added.append(normalized_uuid)
|
||||
|
||||
access_entry = SubscriptionTemporaryAccess(
|
||||
subscription_id=subscription.id,
|
||||
offer_id=offer.id,
|
||||
squad_uuid=normalized_uuid,
|
||||
expires_at=expires_at,
|
||||
is_active=True,
|
||||
was_already_connected=was_already_connected,
|
||||
)
|
||||
db.add(access_entry)
|
||||
|
||||
if newly_added:
|
||||
subscription.connected_squads = list(connected)
|
||||
subscription.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
if newly_added:
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
return True, newly_added, expires_at, "ok"
|
||||
|
||||
async def cleanup_expired_test_access(self, db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
.options(selectinload(SubscriptionTemporaryAccess.subscription))
|
||||
.where(
|
||||
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
|
||||
SubscriptionTemporaryAccess.expires_at <= now,
|
||||
)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
if not entries:
|
||||
return 0
|
||||
|
||||
subscriptions_updates: dict[int, Tuple[Subscription, set[str]]] = {}
|
||||
|
||||
for entry in entries:
|
||||
entry.is_active = False
|
||||
entry.deactivated_at = now
|
||||
subscription = entry.subscription
|
||||
if not subscription:
|
||||
continue
|
||||
|
||||
bucket = subscriptions_updates.setdefault(subscription.id, (subscription, set()))
|
||||
if not entry.was_already_connected:
|
||||
bucket[1].add(entry.squad_uuid)
|
||||
|
||||
for subscription, squads_to_remove in subscriptions_updates.values():
|
||||
if not squads_to_remove:
|
||||
continue
|
||||
current = set(subscription.connected_squads or [])
|
||||
updated = current.difference(squads_to_remove)
|
||||
if updated != current:
|
||||
subscription.connected_squads = list(updated)
|
||||
subscription.updated_at = now
|
||||
try:
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Ошибка обновления Remnawave при отзыве тестового доступа подписки %s: %s",
|
||||
subscription.id,
|
||||
exc,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return len(entries)
|
||||
|
||||
|
||||
promo_offer_service = PromoOfferService()
|
||||
@@ -97,14 +97,22 @@ class AdminStates(StatesGroup):
|
||||
editing_server_limit = State()
|
||||
editing_server_description = State()
|
||||
editing_server_promo_groups = State()
|
||||
|
||||
|
||||
creating_server_uuid = State()
|
||||
creating_server_name = State()
|
||||
creating_server_price = State()
|
||||
creating_server_country = State()
|
||||
|
||||
|
||||
editing_welcome_text = State()
|
||||
waiting_for_message_buttons = "waiting_for_message_buttons"
|
||||
|
||||
editing_promo_offer_message = State()
|
||||
editing_promo_offer_button = State()
|
||||
editing_promo_offer_valid_hours = State()
|
||||
editing_promo_offer_discount = State()
|
||||
editing_promo_offer_bonus = State()
|
||||
editing_promo_offer_test_duration = State()
|
||||
editing_promo_offer_squads = State()
|
||||
|
||||
# Состояния для отслеживания источника перехода
|
||||
viewing_user_from_balance_list = State()
|
||||
|
||||
@@ -534,6 +534,11 @@
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
|
||||
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ You need an active subscription to use this offer.",
|
||||
"TEST_ACCESS_NO_SQUADS": "❌ Unable to determine servers for the test access. Please contact support.",
|
||||
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Failed to activate the offer. Please try again later.",
|
||||
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Test servers are connected! Access is active until {expires_at}.",
|
||||
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Access granted!",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
|
||||
@@ -557,6 +562,30 @@
|
||||
"ADMIN_SUBMENU_SELECT_SECTION": "Choose a section:",
|
||||
"ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Welcome message",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Promo offers",
|
||||
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Promo offers</b>\n\nSelect a template to configure:",
|
||||
"ADMIN_PROMO_OFFER_TEST_ACCESS": "Test servers",
|
||||
"ADMIN_PROMO_OFFER_EXTEND": "Renewal discount",
|
||||
"ADMIN_PROMO_OFFER_PURCHASE": "Purchase discount",
|
||||
"ADMIN_PROMO_OFFER_TYPE": "Type: {label}",
|
||||
"ADMIN_PROMO_OFFER_VALID": "Validity: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_DISCOUNT": "Discount: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_BONUS": "Bonus: {amount}",
|
||||
"ADMIN_PROMO_OFFER_TEST_DURATION": "Access: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Squads: {squads}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Squads: not specified",
|
||||
"ADMIN_PROMO_OFFER_ALLOWED": "Available segments:",
|
||||
"ADMIN_PROMO_OFFER_PREVIEW": "Preview:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_MESSAGE": "Enter the new offer text:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Enter the button label:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Enter validity (hours):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Enter discount percentage:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_BONUS": "Enter bonus amount in kopeks:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Enter test access duration (hours):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_SQUADS": "List squad UUIDs separated by commas or spaces. Send 'clear' to reset:",
|
||||
"ADMIN_PROMO_OFFER_SENDING": "Starting broadcast...",
|
||||
"ADMIN_PROMO_OFFER_NO_USERS": "No matching users found.",
|
||||
"ADMIN_PROMO_OFFER_RESULT": "📬 Broadcast finished\nSent: {sent}\nFailed: {failed}",
|
||||
"ADMIN_SUPPORT_TICKETS": "🎫 Support tickets",
|
||||
"ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit",
|
||||
"ADMIN_SUPPORT_SETTINGS": "🛟 Support settings",
|
||||
|
||||
@@ -534,6 +534,11 @@
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
|
||||
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ Для активации предложения необходима действующая подписка.",
|
||||
"TEST_ACCESS_NO_SQUADS": "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
|
||||
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Не удалось активировать предложение. Попробуйте позже.",
|
||||
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
|
||||
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Доступ выдан!",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
|
||||
@@ -557,6 +562,30 @@
|
||||
"ADMIN_SUBMENU_SELECT_SECTION": "Выберите нужный раздел:",
|
||||
"ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Приветственный текст",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Промо-предложения",
|
||||
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:",
|
||||
"ADMIN_PROMO_OFFER_TEST_ACCESS": "Тестовые сервера",
|
||||
"ADMIN_PROMO_OFFER_EXTEND": "Скидка на продление",
|
||||
"ADMIN_PROMO_OFFER_PURCHASE": "Скидка на покупку",
|
||||
"ADMIN_PROMO_OFFER_TYPE": "Тип: {label}",
|
||||
"ADMIN_PROMO_OFFER_VALID": "Срок действия: {hours} ч",
|
||||
"ADMIN_PROMO_OFFER_DISCOUNT": "Скидка: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_BONUS": "Бонус: {amount}",
|
||||
"ADMIN_PROMO_OFFER_TEST_DURATION": "Доступ: {hours} ч",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Сквады: {squads}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Сквады: не указаны",
|
||||
"ADMIN_PROMO_OFFER_ALLOWED": "Доступные категории:",
|
||||
"ADMIN_PROMO_OFFER_PREVIEW": "Предпросмотр:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_MESSAGE": "Введите новый текст предложения:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Введите новый текст кнопки:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Укажите срок действия (в часах):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Введите размер скидки в процентах:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_BONUS": "Введите размер бонуса в копейках:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Введите длительность тестового доступа (в часах):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_SQUADS": "Перечислите UUID сквадов через запятую или пробел. Для очистки отправьте 'clear':",
|
||||
"ADMIN_PROMO_OFFER_SENDING": "Начинаем рассылку...",
|
||||
"ADMIN_PROMO_OFFER_NO_USERS": "Подходящих пользователей не найдено.",
|
||||
"ADMIN_PROMO_OFFER_RESULT": "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",
|
||||
"ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
|
||||
"ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов",
|
||||
"ADMIN_SUPPORT_SETTINGS": "🛟 Настройки поддержки",
|
||||
|
||||
Reference in New Issue
Block a user