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:
Egor
2025-10-04 10:05:54 +03:00
committed by GitHub
14 changed files with 1316 additions and 2 deletions

View File

@@ -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)

View File

@@ -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)

View 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

View File

@@ -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)

View File

@@ -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"

View File

@@ -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')

View 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)

View File

@@ -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(

View File

@@ -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", "👋 Приветственный текст"),

View File

@@ -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)

View 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()

View File

@@ -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()

View File

@@ -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",

View File

@@ -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": "🛟 Настройки поддержки",