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