mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-22 04:12:09 +00:00
Refresh user state after campaign bonuses
This commit is contained in:
23
app/bot.py
23
app/bot.py
@@ -19,15 +19,23 @@ from app.handlers import (
|
||||
referral, support, common
|
||||
)
|
||||
from app.handlers.admin import (
|
||||
main as admin_main, users as admin_users, subscriptions as admin_subscriptions,
|
||||
promocodes as admin_promocodes, messages as admin_messages,
|
||||
monitoring as admin_monitoring, referrals as admin_referrals,
|
||||
rules as admin_rules, remnawave as admin_remnawave,
|
||||
statistics as admin_statistics, servers as admin_servers,
|
||||
main as admin_main,
|
||||
users as admin_users,
|
||||
subscriptions as admin_subscriptions,
|
||||
promocodes as admin_promocodes,
|
||||
messages as admin_messages,
|
||||
monitoring as admin_monitoring,
|
||||
referrals as admin_referrals,
|
||||
rules as admin_rules,
|
||||
remnawave as admin_remnawave,
|
||||
statistics as admin_statistics,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
campaigns as admin_campaigns,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates, backup as admin_backup,
|
||||
welcome_text as admin_welcome_text
|
||||
updates as admin_updates,
|
||||
backup as admin_backup,
|
||||
welcome_text as admin_welcome_text,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -119,6 +127,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
admin_updates.register_handlers(dp)
|
||||
|
||||
258
app/database/crud/campaign.py
Normal file
258
app/database/crud/campaign.py
Normal file
@@ -0,0 +1,258 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, func, select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_campaign(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
start_parameter: str,
|
||||
bonus_type: str,
|
||||
created_by: Optional[int] = None,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_duration_days: Optional[int] = None,
|
||||
subscription_traffic_gb: Optional[int] = None,
|
||||
subscription_device_limit: Optional[int] = None,
|
||||
subscription_squads: Optional[List[str]] = None,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
name=name,
|
||||
start_parameter=start_parameter,
|
||||
bonus_type=bonus_type,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=subscription_duration_days,
|
||||
subscription_traffic_gb=subscription_traffic_gb,
|
||||
subscription_device_limit=subscription_device_limit,
|
||||
subscription_squads=subscription_squads or [],
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(campaign)
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info(
|
||||
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
|
||||
campaign.name,
|
||||
campaign.start_parameter,
|
||||
campaign.bonus_type,
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
async def get_campaign_by_id(
|
||||
db: AsyncSession, campaign_id: int
|
||||
) -> Optional[AdvertisingCampaign]:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaign)
|
||||
.options(selectinload(AdvertisingCampaign.registrations))
|
||||
.where(AdvertisingCampaign.id == campaign_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_campaign_by_start_parameter(
|
||||
db: AsyncSession,
|
||||
start_parameter: str,
|
||||
*,
|
||||
only_active: bool = False,
|
||||
) -> Optional[AdvertisingCampaign]:
|
||||
stmt = select(AdvertisingCampaign).where(
|
||||
AdvertisingCampaign.start_parameter == start_parameter
|
||||
)
|
||||
if only_active:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_campaigns_list(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
include_inactive: bool = True,
|
||||
) -> List[AdvertisingCampaign]:
|
||||
stmt = (
|
||||
select(AdvertisingCampaign)
|
||||
.options(selectinload(AdvertisingCampaign.registrations))
|
||||
.order_by(AdvertisingCampaign.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_campaigns_count(
|
||||
db: AsyncSession, *, is_active: Optional[bool] = None
|
||||
) -> int:
|
||||
stmt = select(func.count(AdvertisingCampaign.id))
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one() or 0
|
||||
|
||||
|
||||
async def update_campaign(
|
||||
db: AsyncSession,
|
||||
campaign: AdvertisingCampaign,
|
||||
**kwargs,
|
||||
) -> AdvertisingCampaign:
|
||||
allowed_fields = {
|
||||
"name",
|
||||
"start_parameter",
|
||||
"bonus_type",
|
||||
"balance_bonus_kopeks",
|
||||
"subscription_duration_days",
|
||||
"subscription_traffic_gb",
|
||||
"subscription_device_limit",
|
||||
"subscription_squads",
|
||||
"is_active",
|
||||
}
|
||||
|
||||
update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
|
||||
|
||||
if not update_data:
|
||||
return campaign
|
||||
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
|
||||
await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(AdvertisingCampaign.id == campaign.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data)
|
||||
return campaign
|
||||
|
||||
|
||||
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
|
||||
await db.execute(
|
||||
delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
|
||||
return True
|
||||
|
||||
|
||||
async def record_campaign_registration(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
campaign_id: int,
|
||||
user_id: int,
|
||||
bonus_type: str,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_duration_days: Optional[int] = None,
|
||||
) -> AdvertisingCampaignRegistration:
|
||||
existing = await db.execute(
|
||||
select(AdvertisingCampaignRegistration).where(
|
||||
and_(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
AdvertisingCampaignRegistration.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
registration = existing.scalar_one_or_none()
|
||||
if registration:
|
||||
return registration
|
||||
|
||||
registration = AdvertisingCampaignRegistration(
|
||||
campaign_id=campaign_id,
|
||||
user_id=user_id,
|
||||
bonus_type=bonus_type,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=subscription_duration_days,
|
||||
)
|
||||
db.add(registration)
|
||||
await db.commit()
|
||||
await db.refresh(registration)
|
||||
|
||||
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
|
||||
return registration
|
||||
|
||||
|
||||
async def get_campaign_statistics(
|
||||
db: AsyncSession,
|
||||
campaign_id: int,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(AdvertisingCampaignRegistration.id),
|
||||
func.coalesce(
|
||||
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
|
||||
),
|
||||
func.max(AdvertisingCampaignRegistration.created_at),
|
||||
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
|
||||
)
|
||||
count, total_balance, last_registration = result.one()
|
||||
|
||||
subscription_count_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
and_(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
AdvertisingCampaignRegistration.bonus_type == "subscription",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"registrations": count or 0,
|
||||
"balance_issued": total_balance or 0,
|
||||
"subscription_issued": subscription_count_result.scalar() or 0,
|
||||
"last_registration": last_registration,
|
||||
}
|
||||
|
||||
|
||||
async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]:
|
||||
total = await get_campaigns_count(db)
|
||||
active = await get_campaigns_count(db, is_active=True)
|
||||
inactive = await get_campaigns_count(db, is_active=False)
|
||||
|
||||
registrations_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id))
|
||||
)
|
||||
|
||||
balance_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(
|
||||
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
subscription_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.bonus_type == "subscription"
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"active": active,
|
||||
"inactive": inactive,
|
||||
"registrations": registrations_result.scalar() or 0,
|
||||
"balance_total": balance_result.scalar() or 0,
|
||||
"subscription_total": subscription_result.scalar() or 0,
|
||||
}
|
||||
@@ -3,8 +3,17 @@ from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, DateTime, Boolean, Text,
|
||||
ForeignKey, Float, JSON, BigInteger
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
Boolean,
|
||||
Text,
|
||||
ForeignKey,
|
||||
Float,
|
||||
JSON,
|
||||
BigInteger,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
@@ -666,7 +675,7 @@ class UserMessage(Base):
|
||||
|
||||
class WelcomeText(Base):
|
||||
__tablename__ = "welcome_texts"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
text_content = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
@@ -674,5 +683,61 @@ class WelcomeText(Base):
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
creator = relationship("User", backref="created_welcome_texts")
|
||||
|
||||
|
||||
class AdvertisingCampaign(Base):
|
||||
__tablename__ = "advertising_campaigns"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
start_parameter = Column(String(64), nullable=False, unique=True, index=True)
|
||||
bonus_type = Column(String(20), nullable=False)
|
||||
|
||||
balance_bonus_kopeks = Column(Integer, default=0)
|
||||
|
||||
subscription_duration_days = Column(Integer, nullable=True)
|
||||
subscription_traffic_gb = Column(Integer, nullable=True)
|
||||
subscription_device_limit = Column(Integer, nullable=True)
|
||||
subscription_squads = Column(JSON, default=list)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
registrations = relationship("AdvertisingCampaignRegistration", back_populates="campaign")
|
||||
|
||||
@property
|
||||
def is_balance_bonus(self) -> bool:
|
||||
return self.bonus_type == "balance"
|
||||
|
||||
@property
|
||||
def is_subscription_bonus(self) -> bool:
|
||||
return self.bonus_type == "subscription"
|
||||
|
||||
|
||||
class AdvertisingCampaignRegistration(Base):
|
||||
__tablename__ = "advertising_campaign_registrations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
campaign_id = Column(Integer, ForeignKey("advertising_campaigns.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
bonus_type = Column(String(20), nullable=False)
|
||||
balance_bonus_kopeks = Column(Integer, default=0)
|
||||
subscription_duration_days = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
campaign = relationship("AdvertisingCampaign", back_populates="registrations")
|
||||
user = relationship("User")
|
||||
|
||||
@property
|
||||
def balance_bonus_rubles(self) -> float:
|
||||
return (self.balance_bonus_kopeks or 0) / 100
|
||||
|
||||
805
app/handlers/admin/campaigns.py
Normal file
805
app/handlers/admin/campaigns.py
Normal file
@@ -0,0 +1,805 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import (
|
||||
create_campaign,
|
||||
delete_campaign,
|
||||
get_campaign_by_id,
|
||||
get_campaign_by_start_parameter,
|
||||
get_campaign_statistics,
|
||||
get_campaigns_count,
|
||||
get_campaigns_list,
|
||||
get_campaigns_overview,
|
||||
update_campaign,
|
||||
)
|
||||
from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_id
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import (
|
||||
get_admin_campaigns_keyboard,
|
||||
get_admin_pagination_keyboard,
|
||||
get_campaign_bonus_type_keyboard,
|
||||
get_campaign_management_keyboard,
|
||||
get_confirmation_keyboard,
|
||||
)
|
||||
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__)
|
||||
|
||||
_CAMPAIGN_PARAM_REGEX = re.compile(r"^[A-Za-z0-9_-]{3,32}$")
|
||||
_CAMPAIGNS_PAGE_SIZE = 5
|
||||
|
||||
|
||||
def _format_campaign_summary(campaign, texts) -> str:
|
||||
status = "🟢 Активна" if campaign.is_active else "⚪️ Выключена"
|
||||
|
||||
if campaign.is_balance_bonus:
|
||||
bonus_text = texts.format_price(campaign.balance_bonus_kopeks)
|
||||
bonus_info = f"💰 Бонус на баланс: <b>{bonus_text}</b>"
|
||||
else:
|
||||
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
|
||||
bonus_info = (
|
||||
"📱 Подписка: <b>{days} д.</b>\n"
|
||||
"🌐 Трафик: <b>{traffic}</b>\n"
|
||||
"📱 Устройства: <b>{devices}</b>"
|
||||
).format(
|
||||
days=campaign.subscription_duration_days or 0,
|
||||
traffic=traffic_text,
|
||||
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
)
|
||||
|
||||
return (
|
||||
f"<b>{campaign.name}</b>\n"
|
||||
f"Стартовый параметр: <code>{campaign.start_parameter}</code>\n"
|
||||
f"Статус: {status}\n"
|
||||
f"{bonus_info}\n"
|
||||
)
|
||||
|
||||
|
||||
async def _get_bot_deep_link(
|
||||
callback: types.CallbackQuery, start_parameter: str
|
||||
) -> str:
|
||||
bot = await callback.bot.get_me()
|
||||
return f"https://t.me/{bot.username}?start={start_parameter}"
|
||||
|
||||
|
||||
async def _get_bot_deep_link_from_message(
|
||||
message: types.Message, start_parameter: str
|
||||
) -> str:
|
||||
bot = await message.bot.get_me()
|
||||
return f"https://t.me/{bot.username}?start={start_parameter}"
|
||||
|
||||
|
||||
def _build_campaign_servers_keyboard(
|
||||
servers, selected_uuids: List[str]
|
||||
) -> types.InlineKeyboardMarkup:
|
||||
keyboard: List[List[types.InlineKeyboardButton]] = []
|
||||
|
||||
for server in servers[:20]:
|
||||
is_selected = server.squad_uuid in selected_uuids
|
||||
emoji = "✅" if is_selected else ("⚪" if server.is_available else "🔒")
|
||||
text = f"{emoji} {server.display_name}"
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=text, callback_data=f"campaign_toggle_server_{server.id}"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Сохранить", callback_data="campaign_servers_save"
|
||||
),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_campaigns"),
|
||||
]
|
||||
)
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_campaigns_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
overview = await get_campaigns_overview(db)
|
||||
|
||||
text = (
|
||||
"📣 <b>Рекламные кампании</b>\n\n"
|
||||
f"Всего кампаний: <b>{overview['total']}</b>\n"
|
||||
f"Активных: <b>{overview['active']}</b> | Выключены: <b>{overview['inactive']}</b>\n"
|
||||
f"Регистраций: <b>{overview['registrations']}</b>\n"
|
||||
f"Выдано баланса: <b>{texts.format_price(overview['balance_total'])}</b>\n"
|
||||
f"Выдано подписок: <b>{overview['subscription_total']}</b>"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_campaigns_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_campaigns_overall_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
overview = await get_campaigns_overview(db)
|
||||
|
||||
text = ["📊 <b>Общая статистика кампаний</b>\n"]
|
||||
text.append(f"Всего кампаний: <b>{overview['total']}</b>")
|
||||
text.append(
|
||||
f"Активны: <b>{overview['active']}</b>, выключены: <b>{overview['inactive']}</b>"
|
||||
)
|
||||
text.append(f"Всего регистраций: <b>{overview['registrations']}</b>")
|
||||
text.append(
|
||||
f"Суммарно выдано баланса: <b>{texts.format_price(overview['balance_total'])}</b>"
|
||||
)
|
||||
text.append(f"Выдано подписок: <b>{overview['subscription_total']}</b>")
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data="admin_campaigns"
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_campaigns_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
page = 1
|
||||
if callback.data.startswith("admin_campaigns_list_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
offset = (page - 1) * _CAMPAIGNS_PAGE_SIZE
|
||||
campaigns = await get_campaigns_list(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=_CAMPAIGNS_PAGE_SIZE,
|
||||
)
|
||||
total = await get_campaigns_count(db)
|
||||
total_pages = max(1, (total + _CAMPAIGNS_PAGE_SIZE - 1) // _CAMPAIGNS_PAGE_SIZE)
|
||||
|
||||
if not campaigns:
|
||||
await callback.message.edit_text(
|
||||
"❌ Рекламные кампании не найдены.",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="➕ Создать", callback_data="admin_campaigns_create"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data="admin_campaigns"
|
||||
)
|
||||
],
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text_lines = ["📋 <b>Список кампаний</b>\n"]
|
||||
|
||||
for campaign in campaigns:
|
||||
registrations = len(campaign.registrations or [])
|
||||
total_balance = sum(
|
||||
r.balance_bonus_kopeks or 0 for r in campaign.registrations or []
|
||||
)
|
||||
status = "🟢" if campaign.is_active else "⚪"
|
||||
line = (
|
||||
f"{status} <b>{campaign.name}</b> — <code>{campaign.start_parameter}</code>\n"
|
||||
f" Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}"
|
||||
)
|
||||
if campaign.is_subscription_bonus:
|
||||
line += f", подписка: {campaign.subscription_duration_days or 0} д."
|
||||
else:
|
||||
line += ", бонус: баланс"
|
||||
text_lines.append(line)
|
||||
|
||||
keyboard_rows = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"🔍 {campaign.name}",
|
||||
callback_data=f"admin_campaign_manage_{campaign.id}",
|
||||
)
|
||||
]
|
||||
for campaign in campaigns
|
||||
]
|
||||
|
||||
pagination = get_admin_pagination_keyboard(
|
||||
current_page=page,
|
||||
total_pages=total_pages,
|
||||
callback_prefix="admin_campaigns_list",
|
||||
back_callback="admin_campaigns",
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
keyboard_rows.extend(pagination.inline_keyboard)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_campaign_detail(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
campaign_id = int(callback.data.split("_")[-1])
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
|
||||
if not campaign:
|
||||
await callback.answer("❌ Кампания не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
stats = await get_campaign_statistics(db, campaign_id)
|
||||
deep_link = await _get_bot_deep_link(callback, campaign.start_parameter)
|
||||
|
||||
text = ["📣 <b>Управление кампанией</b>\n"]
|
||||
text.append(_format_campaign_summary(campaign, texts))
|
||||
text.append(f"🔗 Ссылка: <code>{deep_link}</code>")
|
||||
text.append("\n📊 <b>Статистика</b>")
|
||||
text.append(f"• Регистраций: <b>{stats['registrations']}</b>")
|
||||
text.append(
|
||||
f"• Выдано баланса: <b>{texts.format_price(stats['balance_issued'])}</b>"
|
||||
)
|
||||
text.append(f"• Выдано подписок: <b>{stats['subscription_issued']}</b>")
|
||||
if stats["last_registration"]:
|
||||
text.append(
|
||||
f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text),
|
||||
reply_markup=get_campaign_management_keyboard(
|
||||
campaign.id, campaign.is_active, db_user.language
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_campaign_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
campaign_id = int(callback.data.split("_")[-1])
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
await callback.answer("❌ Кампания не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not campaign.is_active
|
||||
await update_campaign(db, campaign, is_active=new_status)
|
||||
status_text = "включена" if new_status else "выключена"
|
||||
logger.info("🔄 Кампания %s переключена: %s", campaign_id, status_text)
|
||||
|
||||
await show_campaign_detail(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_campaign_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
campaign_id = int(callback.data.split("_")[-1])
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
await callback.answer("❌ Кампания не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
stats = await get_campaign_statistics(db, campaign_id)
|
||||
|
||||
text = ["📊 <b>Статистика кампании</b>\n"]
|
||||
text.append(_format_campaign_summary(campaign, texts))
|
||||
text.append(f"Регистраций: <b>{stats['registrations']}</b>")
|
||||
text.append(f"Выдано баланса: <b>{texts.format_price(stats['balance_issued'])}</b>")
|
||||
text.append(f"Выдано подписок: <b>{stats['subscription_issued']}</b>")
|
||||
if stats["last_registration"]:
|
||||
text.append(
|
||||
f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=f"admin_campaign_manage_{campaign_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_delete_campaign(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
campaign_id = int(callback.data.split("_")[-1])
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
await callback.answer("❌ Кампания не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
text = (
|
||||
"🗑️ <b>Удаление кампании</b>\n\n"
|
||||
f"Название: <b>{campaign.name}</b>\n"
|
||||
f"Параметр: <code>{campaign.start_parameter}</code>\n\n"
|
||||
"Вы уверены, что хотите удалить кампанию?"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_confirmation_keyboard(
|
||||
confirm_callback=f"admin_campaign_delete_confirm_{campaign_id}",
|
||||
cancel_callback=f"admin_campaign_manage_{campaign_id}",
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_campaign_confirmed(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
campaign_id = int(callback.data.split("_")[-1])
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
await callback.answer("❌ Кампания не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
await delete_campaign(db, campaign)
|
||||
await callback.message.edit_text(
|
||||
"✅ Кампания удалена.",
|
||||
reply_markup=get_admin_campaigns_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer("Удалено")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_campaign_creation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
await state.clear()
|
||||
await callback.message.edit_text(
|
||||
"🆕 <b>Создание рекламной кампании</b>\n\nВведите название кампании:",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data="admin_campaigns"
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
await state.set_state(AdminStates.creating_campaign_name)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_campaign_name(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
name = message.text.strip()
|
||||
if len(name) < 3 or len(name) > 100:
|
||||
await message.answer(
|
||||
"❌ Название должно содержать от 3 до 100 символов. Попробуйте снова."
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(campaign_name=name)
|
||||
await state.set_state(AdminStates.creating_campaign_start)
|
||||
await message.answer(
|
||||
"🔗 Теперь введите параметр старта (латинские буквы, цифры, - или _):",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_campaign_start_parameter(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
start_param = message.text.strip()
|
||||
if not _CAMPAIGN_PARAM_REGEX.match(start_param):
|
||||
await message.answer(
|
||||
"❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа."
|
||||
)
|
||||
return
|
||||
|
||||
existing = await get_campaign_by_start_parameter(db, start_param)
|
||||
if existing:
|
||||
await message.answer(
|
||||
"❌ Кампания с таким параметром уже существует. Введите другой параметр."
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(campaign_start_parameter=start_param)
|
||||
await state.set_state(AdminStates.creating_campaign_bonus)
|
||||
await message.answer(
|
||||
"🎯 Выберите тип бонуса для кампании:",
|
||||
reply_markup=get_campaign_bonus_type_keyboard(db_user.language),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_campaign_bonus_type(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
bonus_type = "balance" if callback.data.endswith("balance") else "subscription"
|
||||
await state.update_data(campaign_bonus_type=bonus_type)
|
||||
|
||||
if bonus_type == "balance":
|
||||
await state.set_state(AdminStates.creating_campaign_balance)
|
||||
await callback.message.edit_text(
|
||||
"💰 Введите сумму бонуса на баланс (в рублях):",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data="admin_campaigns"
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
else:
|
||||
await state.set_state(AdminStates.creating_campaign_subscription_days)
|
||||
await callback.message.edit_text(
|
||||
"📅 Введите длительность подписки в днях (1-730):",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data="admin_campaigns"
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_campaign_balance_value(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
amount_rubles = float(message.text.replace(",", "."))
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)")
|
||||
return
|
||||
|
||||
if amount_rubles <= 0:
|
||||
await message.answer("❌ Сумма должна быть больше нуля")
|
||||
return
|
||||
|
||||
amount_kopeks = int(round(amount_rubles * 100))
|
||||
data = await state.get_data()
|
||||
|
||||
campaign = await create_campaign(
|
||||
db,
|
||||
name=data["campaign_name"],
|
||||
start_parameter=data["campaign_start_parameter"],
|
||||
bonus_type="balance",
|
||||
balance_bonus_kopeks=amount_kopeks,
|
||||
created_by=db_user.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
deep_link = await _get_bot_deep_link_from_message(message, campaign.start_parameter)
|
||||
texts = get_texts(db_user.language)
|
||||
summary = _format_campaign_summary(campaign, texts)
|
||||
text = (
|
||||
"✅ <b>Кампания создана!</b>\n\n"
|
||||
f"{summary}\n"
|
||||
f"🔗 Ссылка: <code>{deep_link}</code>"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=get_campaign_management_keyboard(
|
||||
campaign.id, campaign.is_active, db_user.language
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_campaign_subscription_days(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
days = int(message.text.strip())
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите число дней (1-730)")
|
||||
return
|
||||
|
||||
if days <= 0 or days > 730:
|
||||
await message.answer("❌ Длительность должна быть от 1 до 730 дней")
|
||||
return
|
||||
|
||||
await state.update_data(campaign_subscription_days=days)
|
||||
await state.set_state(AdminStates.creating_campaign_subscription_traffic)
|
||||
await message.answer("🌐 Введите лимит трафика в ГБ (0 = безлимит):")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_campaign_subscription_traffic(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
traffic = int(message.text.strip())
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите целое число (0 или больше)")
|
||||
return
|
||||
|
||||
if traffic < 0 or traffic > 10000:
|
||||
await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ")
|
||||
return
|
||||
|
||||
await state.update_data(campaign_subscription_traffic=traffic)
|
||||
await state.set_state(AdminStates.creating_campaign_subscription_devices)
|
||||
await message.answer(
|
||||
f"📱 Введите количество устройств (1-{settings.MAX_DEVICES_LIMIT}):"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_campaign_subscription_devices(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
devices = int(message.text.strip())
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите целое число устройств")
|
||||
return
|
||||
|
||||
if devices < 1 or devices > settings.MAX_DEVICES_LIMIT:
|
||||
await message.answer(
|
||||
f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}"
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(campaign_subscription_devices=devices)
|
||||
await state.update_data(campaign_subscription_squads=[])
|
||||
await state.set_state(AdminStates.creating_campaign_subscription_servers)
|
||||
|
||||
servers, _ = await get_all_server_squads(db, available_only=False)
|
||||
if not servers:
|
||||
await message.answer(
|
||||
"❌ Не найдены доступные серверы. Добавьте сервера перед созданием кампании.",
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = _build_campaign_servers_keyboard(servers, [])
|
||||
await message.answer(
|
||||
"🌍 Выберите серверы, которые будут доступны по подписке (максимум 20 отображаются).",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_campaign_server(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
server_id = int(callback.data.split("_")[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден", show_alert=True)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
selected = list(data.get("campaign_subscription_squads", []))
|
||||
|
||||
if server.squad_uuid in selected:
|
||||
selected.remove(server.squad_uuid)
|
||||
else:
|
||||
selected.append(server.squad_uuid)
|
||||
|
||||
await state.update_data(campaign_subscription_squads=selected)
|
||||
|
||||
servers, _ = await get_all_server_squads(db, available_only=False)
|
||||
keyboard = _build_campaign_servers_keyboard(servers, selected)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def finalize_campaign_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
selected = data.get("campaign_subscription_squads", [])
|
||||
|
||||
if not selected:
|
||||
await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True)
|
||||
return
|
||||
|
||||
campaign = await create_campaign(
|
||||
db,
|
||||
name=data["campaign_name"],
|
||||
start_parameter=data["campaign_start_parameter"],
|
||||
bonus_type="subscription",
|
||||
subscription_duration_days=data.get("campaign_subscription_days"),
|
||||
subscription_traffic_gb=data.get("campaign_subscription_traffic"),
|
||||
subscription_device_limit=data.get("campaign_subscription_devices"),
|
||||
subscription_squads=selected,
|
||||
created_by=db_user.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
deep_link = await _get_bot_deep_link(callback, campaign.start_parameter)
|
||||
texts = get_texts(db_user.language)
|
||||
summary = _format_campaign_summary(campaign, texts)
|
||||
text = (
|
||||
"✅ <b>Кампания создана!</b>\n\n"
|
||||
f"{summary}\n"
|
||||
f"🔗 Ссылка: <code>{deep_link}</code>"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_campaign_management_keyboard(
|
||||
campaign.id, campaign.is_active, db_user.language
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_campaigns_menu, F.data == "admin_campaigns")
|
||||
dp.callback_query.register(
|
||||
show_campaigns_overall_stats, F.data == "admin_campaigns_stats"
|
||||
)
|
||||
dp.callback_query.register(show_campaigns_list, F.data == "admin_campaigns_list")
|
||||
dp.callback_query.register(
|
||||
show_campaigns_list, F.data.startswith("admin_campaigns_list_page_")
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_campaign_creation, F.data == "admin_campaigns_create"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_campaign_stats, F.data.startswith("admin_campaign_stats_")
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_campaign_detail, F.data.startswith("admin_campaign_manage_")
|
||||
)
|
||||
dp.callback_query.register(
|
||||
delete_campaign_confirmed, F.data.startswith("admin_campaign_delete_confirm_")
|
||||
)
|
||||
dp.callback_query.register(
|
||||
confirm_delete_campaign, F.data.startswith("admin_campaign_delete_")
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_campaign_status, F.data.startswith("admin_campaign_toggle_")
|
||||
)
|
||||
dp.callback_query.register(
|
||||
finalize_campaign_subscription, F.data == "campaign_servers_save"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_campaign_server, F.data.startswith("campaign_toggle_server_")
|
||||
)
|
||||
dp.callback_query.register(
|
||||
select_campaign_bonus_type, F.data.startswith("campaign_bonus_")
|
||||
)
|
||||
|
||||
dp.message.register(process_campaign_name, AdminStates.creating_campaign_name)
|
||||
dp.message.register(
|
||||
process_campaign_start_parameter, AdminStates.creating_campaign_start
|
||||
)
|
||||
dp.message.register(
|
||||
process_campaign_balance_value, AdminStates.creating_campaign_balance
|
||||
)
|
||||
dp.message.register(
|
||||
process_campaign_subscription_days,
|
||||
AdminStates.creating_campaign_subscription_days,
|
||||
)
|
||||
dp.message.register(
|
||||
process_campaign_subscription_traffic,
|
||||
AdminStates.creating_campaign_subscription_traffic,
|
||||
)
|
||||
dp.message.register(
|
||||
process_campaign_subscription_devices,
|
||||
AdminStates.creating_campaign_subscription_devices,
|
||||
)
|
||||
@@ -9,7 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.states import RegistrationStates
|
||||
from app.database.crud.user import (
|
||||
get_user_by_telegram_id, create_user, get_user_by_referral_code
|
||||
get_user_by_telegram_id,
|
||||
create_user,
|
||||
get_user_by_referral_code,
|
||||
)
|
||||
from app.database.crud.campaign import (
|
||||
get_campaign_by_start_parameter,
|
||||
get_campaign_by_id,
|
||||
)
|
||||
from app.database.models import UserStatus
|
||||
from app.keyboards.inline import (
|
||||
@@ -17,15 +23,52 @@ from app.keyboards.inline import (
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _apply_campaign_bonus_if_needed(
|
||||
db: AsyncSession,
|
||||
user,
|
||||
state_data: dict,
|
||||
texts,
|
||||
):
|
||||
campaign_id = state_data.get("campaign_id") if state_data else None
|
||||
if not campaign_id:
|
||||
return None
|
||||
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign or not campaign.is_active:
|
||||
return None
|
||||
|
||||
service = AdvertisingCampaignService()
|
||||
result = await service.apply_campaign_bonus(db, user, campaign)
|
||||
if not result.success:
|
||||
return None
|
||||
|
||||
if result.bonus_type == "balance":
|
||||
amount_text = texts.format_price(result.balance_kopeks)
|
||||
return texts.CAMPAIGN_BONUS_BALANCE.format(
|
||||
amount=amount_text,
|
||||
name=campaign.name,
|
||||
)
|
||||
|
||||
if result.bonus_type == "subscription":
|
||||
traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0)
|
||||
return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format(
|
||||
name=campaign.name,
|
||||
days=result.subscription_days,
|
||||
traffic=traffic_text,
|
||||
devices=result.subscription_device_limit,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def handle_potential_referral_code(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
@@ -86,13 +129,29 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
|
||||
|
||||
referral_code = None
|
||||
if len(message.text.split()) > 1:
|
||||
potential_code = message.text.split()[1]
|
||||
referral_code = potential_code
|
||||
logger.info(f"🔎 Найден реферальный код: {referral_code}")
|
||||
|
||||
campaign = None
|
||||
start_args = message.text.split()
|
||||
if len(start_args) > 1:
|
||||
start_parameter = start_args[1]
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
start_parameter,
|
||||
only_active=True,
|
||||
)
|
||||
|
||||
if campaign:
|
||||
logger.info(
|
||||
"📣 Найдена рекламная кампания %s (start=%s)",
|
||||
campaign.id,
|
||||
campaign.start_parameter,
|
||||
)
|
||||
await state.update_data(campaign_id=campaign.id)
|
||||
else:
|
||||
referral_code = start_parameter
|
||||
logger.info(f"🔎 Найден реферальный код: {referral_code}")
|
||||
|
||||
if referral_code:
|
||||
await state.set_data({'referral_code': referral_code})
|
||||
await state.update_data(referral_code=referral_code)
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
@@ -130,9 +189,19 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
await db.commit()
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
|
||||
if referral_code and not user.referred_by_id:
|
||||
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
await message.answer(
|
||||
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена."
|
||||
)
|
||||
|
||||
if campaign:
|
||||
try:
|
||||
await message.answer(texts.CAMPAIGN_EXISTING_USER)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления о рекламной кампании: {e}"
|
||||
)
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
@@ -533,9 +602,35 @@ async def complete_registration_from_callback(
|
||||
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
|
||||
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
|
||||
|
||||
try:
|
||||
await db.refresh(user)
|
||||
except Exception as refresh_error:
|
||||
logger.error(
|
||||
"Ошибка обновления данных пользователя %s после бонуса кампании: %s",
|
||||
user.telegram_id,
|
||||
refresh_error,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.refresh(user, ["subscription"])
|
||||
except Exception as refresh_subscription_error:
|
||||
logger.error(
|
||||
"Ошибка обновления подписки пользователя %s после бонуса кампании: %s",
|
||||
user.telegram_id,
|
||||
refresh_subscription_error,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
if campaign_message:
|
||||
try:
|
||||
await callback.message.answer(campaign_message)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}")
|
||||
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, callback.from_user)
|
||||
|
||||
@@ -551,10 +646,10 @@ async def complete_registration_from_callback(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
@@ -698,9 +793,35 @@ async def complete_registration(
|
||||
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
|
||||
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
|
||||
|
||||
try:
|
||||
await db.refresh(user)
|
||||
except Exception as refresh_error:
|
||||
logger.error(
|
||||
"Ошибка обновления данных пользователя %s после бонуса кампании: %s",
|
||||
user.telegram_id,
|
||||
refresh_error,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.refresh(user, ["subscription"])
|
||||
except Exception as refresh_subscription_error:
|
||||
logger.error(
|
||||
"Ошибка обновления подписки пользователя %s после бонуса кампании: %s",
|
||||
user.telegram_id,
|
||||
refresh_subscription_error,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
if campaign_message:
|
||||
try:
|
||||
await message.answer(campaign_message)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}")
|
||||
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, message.from_user)
|
||||
|
||||
@@ -716,10 +837,10 @@ async def complete_registration(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -36,12 +36,15 @@ def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
|
||||
def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
@@ -147,6 +150,54 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Список кампаний", callback_data="admin_campaigns_list"),
|
||||
InlineKeyboardButton(text="➕ Создать", callback_data="admin_campaigns_create")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_campaigns_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_campaign_management_keyboard(campaign_id: int, is_active: bool, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
status_text = "🔴 Выключить" if is_active else "🟢 Включить"
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_campaign_stats_{campaign_id}"),
|
||||
InlineKeyboardButton(text=status_text, callback_data=f"admin_campaign_toggle_{campaign_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_campaign_delete_{campaign_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_campaigns_list")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Бонус на баланс", callback_data="campaign_bonus_balance"),
|
||||
InlineKeyboardButton(text="📱 Подписка", callback_data="campaign_bonus_subscription")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
|
||||
@@ -312,8 +312,15 @@ class RussianTexts(Texts):
|
||||
|
||||
💪 Быстро, надежно, недорого!
|
||||
"""
|
||||
|
||||
|
||||
CREATE_INVITE = "📝 Создать приглашение"
|
||||
CAMPAIGN_EXISTING_USER = (
|
||||
"ℹ️ Эта рекламная ссылка доступна только новым пользователям."
|
||||
)
|
||||
CAMPAIGN_BONUS_BALANCE = (
|
||||
"🎉 Вы получили {amount} за регистрацию по кампании «{name}»!"
|
||||
)
|
||||
CAMPAIGN_BONUS_SUBSCRIPTION = "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!"
|
||||
|
||||
TRIAL_ENDING_SOON = """
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
@@ -421,6 +428,7 @@ class RussianTexts(Texts):
|
||||
ADMIN_USERS = "👥 Пользователи"
|
||||
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
|
||||
ADMIN_PROMOCODES = "🎫 Промокоды"
|
||||
ADMIN_CAMPAIGNS = "📣 Рекламные кампании"
|
||||
ADMIN_MESSAGES = "📨 Рассылки"
|
||||
ADMIN_MONITORING = "🔍 Мониторинг"
|
||||
ADMIN_REFERRALS = "🤝 Партнерка"
|
||||
@@ -535,6 +543,7 @@ To get started, select interface language:
|
||||
CONTINUE = "➡️ Continue"
|
||||
YES = "✅ Yes"
|
||||
NO = "❌ No"
|
||||
ADMIN_CAMPAIGNS = "📣 Campaigns"
|
||||
|
||||
MENU_BALANCE = "💰 Balance"
|
||||
MENU_SUBSCRIPTION = "📱 Subscription"
|
||||
@@ -545,6 +554,9 @@ To get started, select interface language:
|
||||
GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up"
|
||||
RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Back to checkout"
|
||||
NO_SAVED_SUBSCRIPTION_ORDER = "❌ Saved subscription order not found. Please configure it again."
|
||||
CAMPAIGN_EXISTING_USER = "ℹ️ This campaign link is available for new users only."
|
||||
CAMPAIGN_BONUS_BALANCE = "🎉 You received {amount} for joining via campaign “{name}”!"
|
||||
CAMPAIGN_BONUS_SUBSCRIPTION = "🎉 You received a {days}-day subscription (traffic: {traffic}, devices: {devices}) from campaign “{name}”!"
|
||||
|
||||
|
||||
LANGUAGES = {
|
||||
|
||||
171
app/services/campaign_service.py
Normal file
171
app/services/campaign_service.py
Normal file
@@ -0,0 +1,171 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import record_campaign_registration
|
||||
from app.database.crud.subscription import (
|
||||
create_paid_subscription,
|
||||
get_subscription_by_user_id,
|
||||
)
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.models import AdvertisingCampaign, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CampaignBonusResult:
|
||||
success: bool
|
||||
bonus_type: Optional[str] = None
|
||||
balance_kopeks: int = 0
|
||||
subscription_days: Optional[int] = None
|
||||
subscription_traffic_gb: Optional[int] = None
|
||||
subscription_device_limit: Optional[int] = None
|
||||
subscription_squads: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AdvertisingCampaignService:
|
||||
def __init__(self) -> None:
|
||||
self.subscription_service = SubscriptionService()
|
||||
|
||||
async def apply_campaign_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign: AdvertisingCampaign,
|
||||
) -> CampaignBonusResult:
|
||||
if not campaign.is_active:
|
||||
logger.warning(
|
||||
"⚠️ Попытка выдать бонус по неактивной кампании %s", campaign.id
|
||||
)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
if campaign.is_balance_bonus:
|
||||
return await self._apply_balance_bonus(db, user, campaign)
|
||||
|
||||
if campaign.is_subscription_bonus:
|
||||
return await self._apply_subscription_bonus(db, user, campaign)
|
||||
|
||||
logger.error("❌ Неизвестный тип бонуса кампании: %s", campaign.bonus_type)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
async def _apply_balance_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign: AdvertisingCampaign,
|
||||
) -> CampaignBonusResult:
|
||||
amount = campaign.balance_bonus_kopeks or 0
|
||||
if amount <= 0:
|
||||
logger.info("ℹ️ Кампания %s не имеет бонуса на баланс", campaign.id)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
description = f"Бонус за регистрацию по кампании '{campaign.name}'"
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount,
|
||||
description=description,
|
||||
)
|
||||
|
||||
if not success:
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
await record_campaign_registration(
|
||||
db,
|
||||
campaign_id=campaign.id,
|
||||
user_id=user.id,
|
||||
bonus_type="balance",
|
||||
balance_bonus_kopeks=amount,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"💰 Пользователю %s начислен бонус %s₽ по кампании %s",
|
||||
user.telegram_id,
|
||||
amount / 100,
|
||||
campaign.id,
|
||||
)
|
||||
|
||||
return CampaignBonusResult(
|
||||
success=True,
|
||||
bonus_type="balance",
|
||||
balance_kopeks=amount,
|
||||
)
|
||||
|
||||
async def _apply_subscription_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign: AdvertisingCampaign,
|
||||
) -> CampaignBonusResult:
|
||||
existing_subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if existing_subscription:
|
||||
logger.warning(
|
||||
"⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен",
|
||||
user.telegram_id,
|
||||
campaign.id,
|
||||
)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
duration_days = campaign.subscription_duration_days or 0
|
||||
if duration_days <= 0:
|
||||
logger.info(
|
||||
"ℹ️ Кампания %s не содержит корректной длительности подписки",
|
||||
campaign.id,
|
||||
)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
traffic_limit = campaign.subscription_traffic_gb
|
||||
device_limit = (
|
||||
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
)
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=duration_days,
|
||||
traffic_limit_gb=traffic_limit or 0,
|
||||
device_limit=device_limit,
|
||||
connected_squads=squads,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.subscription_service.create_remnawave_user(db, new_subscription)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"❌ Ошибка синхронизации RemnaWave для кампании %s: %s",
|
||||
campaign.id,
|
||||
error,
|
||||
)
|
||||
|
||||
await record_campaign_registration(
|
||||
db,
|
||||
campaign_id=campaign.id,
|
||||
user_id=user.id,
|
||||
bonus_type="subscription",
|
||||
subscription_duration_days=duration_days,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"🎁 Пользователю %s выдана подписка по кампании %s на %s дней",
|
||||
user.telegram_id,
|
||||
campaign.id,
|
||||
duration_days,
|
||||
)
|
||||
|
||||
return CampaignBonusResult(
|
||||
success=True,
|
||||
bonus_type="subscription",
|
||||
subscription_days=duration_days,
|
||||
subscription_traffic_gb=traffic_limit or 0,
|
||||
subscription_device_limit=device_limit,
|
||||
subscription_squads=squads,
|
||||
)
|
||||
@@ -41,6 +41,15 @@ class AdminStates(StatesGroup):
|
||||
setting_promocode_value = State()
|
||||
setting_promocode_uses = State()
|
||||
setting_promocode_expiry = State()
|
||||
|
||||
creating_campaign_name = State()
|
||||
creating_campaign_start = State()
|
||||
creating_campaign_bonus = State()
|
||||
creating_campaign_balance = State()
|
||||
creating_campaign_subscription_days = State()
|
||||
creating_campaign_subscription_traffic = State()
|
||||
creating_campaign_subscription_devices = State()
|
||||
creating_campaign_subscription_servers = State()
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
waiting_for_broadcast_media = State()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""add advertising campaigns tables"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "5d1f1f8b2e9a"
|
||||
down_revision: Union[str, None] = "cbd1be472f3d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"advertising_campaigns",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("start_parameter", sa.String(length=64), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_device_limit", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_squads", sa.JSON(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_advertising_campaigns_start_parameter",
|
||||
"advertising_campaigns",
|
||||
["start_parameter"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_advertising_campaigns_id",
|
||||
"advertising_campaigns",
|
||||
["id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"advertising_campaign_registrations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("campaign_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["advertising_campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_advertising_campaign_registrations_id",
|
||||
"advertising_campaign_registrations",
|
||||
["id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_advertising_campaign_registrations_id", table_name="advertising_campaign_registrations")
|
||||
op.drop_table("advertising_campaign_registrations")
|
||||
op.drop_index("ix_advertising_campaigns_id", table_name="advertising_campaigns")
|
||||
op.drop_index("ix_advertising_campaigns_start_parameter", table_name="advertising_campaigns")
|
||||
op.drop_table("advertising_campaigns")
|
||||
Reference in New Issue
Block a user