diff --git a/app/bot.py b/app/bot.py
index ebae217e..ee2eda72 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -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)
diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py
index eaa789ae..0ae6a068 100644
--- a/app/database/crud/discount_offer.py
+++ b/app/database/crud/discount_offer.py
@@ -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)
diff --git a/app/database/crud/promo_offer_template.py b/app/database/crud/promo_offer_template.py
new file mode 100644
index 00000000..cb99770b
--- /dev/null
+++ b/app/database/crud/promo_offer_template.py
@@ -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": (
+ "🔥 Испытайте новые сервера\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": (
+ "💎 Экономия {discount_percent}% при продлении\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": (
+ "🎯 Вернитесь со скидкой {discount_percent}%\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
diff --git a/app/database/crud/user.py b/app/database/crud/user.py
index 69b091b8..44a40173 100644
--- a/app/database/crud/user.py
+++ b/app/database/crud/user.py
@@ -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)
diff --git a/app/database/models.py b/app/database/models.py
index 26c4f860..6de613e4 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -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"
diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py
index f56ded44..4d5dd201 100644
--- a/app/database/universal_migration.py
+++ b/app/database/universal_migration.py
@@ -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')
diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py
new file mode 100644
index 00000000..afb81652
--- /dev/null
+++ b/app/handlers/admin/promo_offers.py
@@ -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} {template.name}", ""]
+ 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", "🎯 Промо-предложения\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)
diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py
index 52800062..e8181f78 100644
--- a/app/handlers/subscription.py
+++ b/app/handlers/subscription.py
@@ -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(
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 60be21e8..2f5200cf 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -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", "👋 Приветственный текст"),
diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py
index 873385a9..584e03fc 100644
--- a/app/services/monitoring_service.py
+++ b/app/services/monitoring_service.py
@@ -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)
diff --git a/app/services/promo_offer_service.py b/app/services/promo_offer_service.py
new file mode 100644
index 00000000..4dd2c18d
--- /dev/null
+++ b/app/services/promo_offer_service.py
@@ -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()
diff --git a/app/states.py b/app/states.py
index 25ceccd1..767d20f6 100644
--- a/app/states.py
+++ b/app/states.py
@@ -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()
diff --git a/locales/en.json b/locales/en.json
index 189c9d9f..f024ad95 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -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": "🎯 Promo offers\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",
diff --git a/locales/ru.json b/locales/ru.json
index 335e65db..f4d63055 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -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": "🎯 Промо-предложения\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": "🛟 Настройки поддержки",