касса и прочее

This commit is contained in:
gy9vin
2026-01-27 23:47:39 +03:00
parent dd423efe08
commit 95b7152c05
11 changed files with 648 additions and 59 deletions

View File

@@ -284,6 +284,19 @@ async def get_payment_methods():
)
)
# KassaAI
if settings.is_kassa_ai_enabled():
methods.append(
PaymentMethodResponse(
id='kassa_ai',
name=settings.get_kassa_ai_display_name(),
description='Pay via KassaAI',
min_amount_kopeks=settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
is_available=True,
)
)
# Tribute
if settings.TRIBUTE_ENABLED and settings.TRIBUTE_DONATE_LINK:
methods.append(
@@ -728,6 +741,31 @@ async def create_topup(
detail='Failed to create FreeKassa payment',
)
elif request.payment_method == 'kassa_ai':
if not settings.is_kassa_ai_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='KassaAI payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_kassa_ai_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create KassaAI payment',
)
elif request.payment_method == 'tribute':
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
raise HTTPException(
@@ -868,6 +906,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.KASSA_AI:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'success': ('', 'Оплачено'),
'paid': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
'failed': ('', 'Ошибка'),
'expired': ('', 'Истёк'),
}
return mapping.get(status, ('', 'Неизвестно'))
return '', 'Неизвестно'
@@ -896,6 +945,8 @@ def _is_checkable(record: PendingPayment) -> bool:
return status in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
return status in {'pending', 'created', 'processing'}
if record.method == PaymentMethod.KASSA_AI:
return status in {'pending', 'created', 'processing'}
return False
@@ -919,7 +970,7 @@ def _get_payment_url(record: PendingPayment) -> str | None:
)
elif record.method == PaymentMethod.PLATEGA:
payment_url = getattr(payment, 'redirect_url', None) or payment_url
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
payment_url = getattr(payment, 'payment_url', None) or payment_url
return payment_url

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import (
ReferralContest,
ReferralContestEvent,
ReferralContestVirtualParticipant,
Transaction,
TransactionType,
User,
@@ -617,9 +618,7 @@ async def debug_contest_transactions(
# Подсчёт общих сумм ПО ТИПАМ (исключаем бонусы без payment_method)
deposit_in_period = sum(
tx.amount_kopeks
for tx in txs_in
if tx.type == TransactionType.DEPOSIT.value and tx.payment_method is not None
tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.DEPOSIT.value and tx.payment_method is not None
)
subscription_in_period = sum(
tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value
@@ -920,3 +919,96 @@ async def cleanup_invalid_contest_events(
'contest_start': contest_start.isoformat(),
'contest_end': contest_end.isoformat(),
}
# ── Виртуальные участники ──────────────────────────────────────────────
async def add_virtual_participant(
db: AsyncSession,
contest_id: int,
display_name: str,
referral_count: int,
total_amount_kopeks: int = 0,
) -> ReferralContestVirtualParticipant:
vp = ReferralContestVirtualParticipant(
contest_id=contest_id,
display_name=display_name,
referral_count=referral_count,
total_amount_kopeks=total_amount_kopeks,
)
db.add(vp)
await db.commit()
await db.refresh(vp)
return vp
async def list_virtual_participants(
db: AsyncSession,
contest_id: int,
) -> Sequence[ReferralContestVirtualParticipant]:
result = await db.execute(
select(ReferralContestVirtualParticipant)
.where(ReferralContestVirtualParticipant.contest_id == contest_id)
.order_by(ReferralContestVirtualParticipant.referral_count.desc())
)
return result.scalars().all()
async def delete_virtual_participant(
db: AsyncSession,
participant_id: int,
) -> bool:
result = await db.execute(
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
)
vp = result.scalar_one_or_none()
if not vp:
return False
await db.delete(vp)
await db.commit()
return True
async def update_virtual_participant_count(
db: AsyncSession,
participant_id: int,
referral_count: int,
) -> ReferralContestVirtualParticipant | None:
result = await db.execute(
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
)
vp = result.scalar_one_or_none()
if not vp:
return None
vp.referral_count = referral_count
await db.commit()
await db.refresh(vp)
return vp
async def get_contest_leaderboard_with_virtual(
db: AsyncSession,
contest_id: int,
*,
limit: int | None = None,
) -> list[tuple[str, int, int, bool]]:
"""Лидерборд с виртуальными участниками.
Возвращает список кортежей (display_name, referral_count, total_amount, is_virtual).
"""
real = await get_contest_leaderboard(db, contest_id)
virtual = await list_virtual_participants(db, contest_id)
merged: list[tuple[str, int, int, bool]] = []
for user, score, amount in real:
merged.append((user.full_name, score, amount, False))
for vp in virtual:
merged.append((vp.display_name, vp.referral_count, vp.total_amount_kopeks, True))
merged.sort(key=lambda x: (-x[1], -x[2]))
if limit:
merged = merged[:limit]
return merged

View File

@@ -1529,6 +1529,24 @@ class ReferralContestEvent(Base):
)
class ReferralContestVirtualParticipant(Base):
__tablename__ = 'referral_contest_virtual_participants'
id = Column(Integer, primary_key=True, index=True)
contest_id = Column(Integer, ForeignKey('referral_contests.id', ondelete='CASCADE'), nullable=False)
display_name = Column(String(255), nullable=False)
referral_count = Column(Integer, nullable=False, default=0)
total_amount_kopeks = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, default=func.now())
contest = relationship('ReferralContest')
def __repr__(self):
return (
f"<ReferralContestVirtualParticipant id={self.id} name='{self.display_name}' count={self.referral_count}>"
)
class ContestTemplate(Base):
__tablename__ = 'contest_templates'

View File

@@ -1731,6 +1731,65 @@ async def create_referral_contest_events_table() -> bool:
return False
async def create_referral_contest_virtual_participants_table() -> bool:
table_exists = await check_table_exists('referral_contest_virtual_participants')
if table_exists:
logger.info('Таблица referral_contest_virtual_participants уже существует')
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(
text("""
CREATE TABLE referral_contest_virtual_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contest_id INTEGER NOT NULL,
display_name VARCHAR(255) NOT NULL,
referral_count INTEGER NOT NULL DEFAULT 0,
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
)
""")
)
elif db_type == 'postgresql':
await conn.execute(
text("""
CREATE TABLE referral_contest_virtual_participants (
id SERIAL PRIMARY KEY,
contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE,
display_name VARCHAR(255) NOT NULL,
referral_count INTEGER NOT NULL DEFAULT 0,
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
)
else:
await conn.execute(
text("""
CREATE TABLE referral_contest_virtual_participants (
id INT AUTO_INCREMENT PRIMARY KEY,
contest_id INT NOT NULL,
display_name VARCHAR(255) NOT NULL,
referral_count INT NOT NULL DEFAULT 0,
total_amount_kopeks INT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
)
""")
)
logger.info('✅ Таблица referral_contest_virtual_participants создана')
return True
except Exception as error:
logger.error(f'Ошибка создания таблицы referral_contest_virtual_participants: {error}')
return False
async def ensure_referral_contest_summary_columns() -> bool:
ok = True
for column in ['daily_summary_times', 'last_daily_summary_at']:
@@ -4223,33 +4282,33 @@ async def fix_button_click_logs_fk() -> bool:
logger.info('🔧 Исправляем FK button_click_logs.user_id: telegram_id -> id')
# Обнуляем невалидные user_id (которые были internal id, а не telegram_id)
await conn.execute(text("""
await conn.execute(
text("""
UPDATE button_click_logs
SET user_id = NULL
WHERE user_id IS NOT NULL
AND user_id NOT IN (SELECT telegram_id FROM users)
"""))
""")
)
# Удаляем старый FK
await conn.execute(text(
'ALTER TABLE button_click_logs DROP CONSTRAINT IF EXISTS button_click_logs_user_id_fkey'
))
await conn.execute(
text('ALTER TABLE button_click_logs DROP CONSTRAINT IF EXISTS button_click_logs_user_id_fkey')
)
# Меняем тип колонки и добавляем правильный FK
await conn.execute(text(
'ALTER TABLE button_click_logs ALTER COLUMN user_id TYPE INTEGER'
))
await conn.execute(text('ALTER TABLE button_click_logs ALTER COLUMN user_id TYPE INTEGER'))
# Обнуляем все значения, т.к. они были записаны неправильно
await conn.execute(text(
'UPDATE button_click_logs SET user_id = NULL'
))
await conn.execute(text('UPDATE button_click_logs SET user_id = NULL'))
await conn.execute(text(
'ALTER TABLE button_click_logs '
'ADD CONSTRAINT button_click_logs_user_id_fkey '
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL'
))
await conn.execute(
text(
'ALTER TABLE button_click_logs '
'ADD CONSTRAINT button_click_logs_user_id_fkey '
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL'
)
)
logger.info('✅ FK button_click_logs.user_id исправлен')
else:
@@ -6395,6 +6454,12 @@ async def run_universal_migration():
else:
logger.warning('⚠️ Проблемы с таблицей referral_contest_events')
virtual_participants_ready = await create_referral_contest_virtual_participants_table()
if virtual_participants_ready:
logger.info('✅ Таблица referral_contest_virtual_participants готова')
else:
logger.warning('⚠️ Проблемы с таблицей referral_contest_virtual_participants')
contest_type_ready = await ensure_referral_contest_type_column()
if contest_type_ready:
logger.info('✅ Колонка contest_type для referral_contests готова')

View File

@@ -9,15 +9,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.referral_contest import (
add_virtual_participant,
create_referral_contest,
delete_referral_contest,
delete_virtual_participant,
get_contest_events_count,
get_contest_leaderboard,
get_contest_leaderboard_with_virtual,
get_referral_contest,
get_referral_contests_count,
list_referral_contests,
list_virtual_participants,
toggle_referral_contest,
update_referral_contest,
update_virtual_participant_count,
)
from app.keyboards.admin import (
get_admin_contests_keyboard,
@@ -240,8 +244,10 @@ async def show_contest_details(
return
tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
leaderboard = await get_contest_leaderboard(db, contest.id, limit=5)
total_events = await get_contest_events_count(db, contest.id)
leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id, limit=5)
virtual_list = await list_virtual_participants(db, contest.id)
virtual_count = sum(vp.referral_count for vp in virtual_list)
total_events = await get_contest_events_count(db, contest.id) + virtual_count
lines = [
f'🏆 <b>{contest.title}</b>',
@@ -256,8 +262,9 @@ async def show_contest_details(
if leaderboard:
lines.append('')
lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'))
for idx, (user, score, _) in enumerate(leaderboard, start=1):
lines.append(f'{idx}. {user.full_name}{score}')
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
await callback.message.edit_text(
'\n'.join(lines),
@@ -427,7 +434,7 @@ async def show_leaderboard(
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
leaderboard = await get_contest_leaderboard_with_virtual(db, contest_id, limit=10)
if not leaderboard:
await callback.answer(texts.t('ADMIN_CONTEST_EMPTY_LEADERBOARD', 'Пока нет участников.'), show_alert=True)
return
@@ -435,9 +442,9 @@ async def show_leaderboard(
lines = [
texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'),
]
for idx, (user, score, _) in enumerate(leaderboard, start=1):
user_id_display = user.telegram_id or user.email or f'#{user.id}'
lines.append(f'{idx}. {user.full_name} ({user_id_display}){score}')
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
await callback.message.edit_text(
'\n'.join(lines),
@@ -676,6 +683,9 @@ async def show_detailed_stats(
from app.services.referral_contest_service import referral_contest_service
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
virtual = await list_virtual_participants(db, contest_id)
virtual_count = len(virtual)
virtual_referrals = sum(vp.referral_count for vp in virtual)
# Общее сообщение с основной статистикой
general_lines = [
@@ -693,6 +703,10 @@ async def show_detailed_stats(
f' 📥 Пополнения баланса: <b>{stats.get("deposit_total", 0) // 100} руб.</b>',
]
if virtual_count > 0:
general_lines.append('')
general_lines.append(f'👻 Виртуальных: <b>{virtual_count}</b> (рефералов: {virtual_referrals})')
await callback.message.edit_text(
'\n'.join(general_lines),
reply_markup=get_referral_contest_manage_keyboard(
@@ -970,6 +984,274 @@ async def debug_contest_transactions(
)
# ── Виртуальные участники ──────────────────────────────────────────────
@admin_required
@error_handler
async def show_virtual_participants(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
contest_id = int(callback.data.split('_')[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer('Конкурс не найден.', show_alert=True)
return
vps = await list_virtual_participants(db, contest_id)
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
if vps:
for vp in vps:
lines.append(f'{vp.display_name}{vp.referral_count} реф.')
else:
lines.append('Пока нет виртуальных участников.')
rows = [
[
types.InlineKeyboardButton(
text=' Добавить',
callback_data=f'admin_contest_vp_add_{contest_id}',
),
],
]
if vps:
for vp in vps:
rows.append(
[
types.InlineKeyboardButton(
text=f'✏️ {vp.display_name}',
callback_data=f'admin_contest_vp_edit_{vp.id}',
),
types.InlineKeyboardButton(
text='🗑',
callback_data=f'admin_contest_vp_del_{vp.id}',
),
]
)
rows.append(
[
types.InlineKeyboardButton(
text='⬅️ Назад',
callback_data=f'admin_contest_view_{contest_id}',
),
]
)
await callback.message.edit_text(
'\n'.join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
)
await callback.answer()
@admin_required
@error_handler
async def start_add_virtual_participant(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
contest_id = int(callback.data.split('_')[-1])
await state.set_state(AdminStates.adding_virtual_participant_name)
await state.update_data(vp_contest_id=contest_id)
await callback.message.edit_text(
'👻 Введите отображаемое имя виртуального участника:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_virtual_participant_name(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
name = message.text.strip()
if not name or len(name) > 200:
await message.answer('Имя должно быть от 1 до 200 символов. Попробуйте ещё раз:')
return
await state.update_data(vp_name=name)
await state.set_state(AdminStates.adding_virtual_participant_count)
await message.answer(f'Имя: <b>{name}</b>\n\nВведите количество рефералов (число):')
@admin_required
@error_handler
async def process_virtual_participant_count(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
try:
count = int(message.text.strip())
if count < 1:
raise ValueError
except (ValueError, TypeError):
await message.answer('Введите положительное целое число:')
return
data = await state.get_data()
contest_id = data['vp_contest_id']
display_name = data['vp_name']
await state.clear()
vp = await add_virtual_participant(db, contest_id, display_name, count)
await message.answer(
f'✅ Виртуальный участник добавлен:\nИмя: <b>{vp.display_name}</b>\nРефералов: <b>{vp.referral_count}</b>',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
[types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
]
),
)
@admin_required
@error_handler
async def delete_virtual_participant_handler(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
vp_id = int(callback.data.split('_')[-1])
# Получим contest_id до удаления
from sqlalchemy import select as sa_select
from app.database.models import ReferralContestVirtualParticipant
result = await db.execute(
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
)
vp = result.scalar_one_or_none()
if not vp:
await callback.answer('Участник не найден.', show_alert=True)
return
contest_id = vp.contest_id
deleted = await delete_virtual_participant(db, vp_id)
if deleted:
await callback.answer('✅ Удалён', show_alert=False)
else:
await callback.answer('Не удалось удалить.', show_alert=True)
# Вернуться к списку
vps = await list_virtual_participants(db, contest_id)
contest = await get_referral_contest(db, contest_id)
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
if vps:
for v in vps:
lines.append(f'{v.display_name}{v.referral_count} реф.')
else:
lines.append('Пока нет виртуальных участников.')
rows = [
[types.InlineKeyboardButton(text=' Добавить', callback_data=f'admin_contest_vp_add_{contest_id}')],
]
if vps:
for v in vps:
rows.append(
[
types.InlineKeyboardButton(
text=f'✏️ {v.display_name}', callback_data=f'admin_contest_vp_edit_{v.id}'
),
types.InlineKeyboardButton(text='🗑', callback_data=f'admin_contest_vp_del_{v.id}'),
]
)
rows.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_contest_view_{contest_id}')])
await callback.message.edit_text(
'\n'.join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
)
@admin_required
@error_handler
async def start_edit_virtual_participant(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
vp_id = int(callback.data.split('_')[-1])
from sqlalchemy import select as sa_select
from app.database.models import ReferralContestVirtualParticipant
result = await db.execute(
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
)
vp = result.scalar_one_or_none()
if not vp:
await callback.answer('Участник не найден.', show_alert=True)
return
await state.set_state(AdminStates.editing_virtual_participant_count)
await state.update_data(vp_edit_id=vp_id, vp_edit_contest_id=vp.contest_id)
await callback.message.edit_text(
f'✏️ <b>{vp.display_name}</b>\n'
f'Текущее кол-во рефералов: <b>{vp.referral_count}</b>\n\n'
f'Введите новое количество:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{vp.contest_id}')],
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_virtual_participant_count(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
try:
count = int(message.text.strip())
if count < 1:
raise ValueError
except (ValueError, TypeError):
await message.answer('Введите положительное целое число:')
return
data = await state.get_data()
vp_id = data['vp_edit_id']
contest_id = data['vp_edit_contest_id']
await state.clear()
vp = await update_virtual_participant_count(db, vp_id, count)
if vp:
await message.answer(
f'✅ Обновлено: <b>{vp.display_name}</b> — {vp.referral_count} реф.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
]
),
)
else:
await message.answer('Участник не найден.')
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_contests_menu, F.data == 'admin_contests')
dp.callback_query.register(show_referral_contests_menu, F.data == 'admin_contests_referral')
@@ -996,3 +1278,11 @@ def register_handlers(dp: Dispatcher):
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)
dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times)
dp.callback_query.register(start_add_virtual_participant, F.data.startswith('admin_contest_vp_add_'))
dp.callback_query.register(delete_virtual_participant_handler, F.data.startswith('admin_contest_vp_del_'))
dp.callback_query.register(start_edit_virtual_participant, F.data.startswith('admin_contest_vp_edit_'))
dp.callback_query.register(show_virtual_participants, F.data.regexp(r'^admin_contest_vp_\d+$'))
dp.message.register(process_virtual_participant_name, AdminStates.adding_virtual_participant_name)
dp.message.register(process_virtual_participant_count, AdminStates.adding_virtual_participant_count)
dp.message.register(process_edit_virtual_participant_count, AdminStates.editing_virtual_participant_count)

View File

@@ -649,6 +649,12 @@ def get_referral_contest_manage_keyboard(
callback_data=f'admin_contest_edit_times_{contest_id}',
),
],
[
InlineKeyboardButton(
text='👻 Виртуальные',
callback_data=f'admin_contest_vp_{contest_id}',
),
],
[
InlineKeyboardButton(
text='🔄 Синхронизация',

View File

@@ -22,6 +22,7 @@ from app.database.models import (
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PaymentMethod,
@@ -109,6 +110,8 @@ def method_display_name(method: PaymentMethod) -> str:
return 'CloudPayments'
if method == PaymentMethod.FREEKASSA:
return 'Freekassa'
if method == PaymentMethod.KASSA_AI:
return settings.get_kassa_ai_display_name()
if method == PaymentMethod.TELEGRAM_STARS:
return 'Telegram Stars'
return method.value
@@ -133,6 +136,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
return settings.is_cloudpayments_enabled()
if method == PaymentMethod.FREEKASSA:
return settings.is_freekassa_enabled()
if method == PaymentMethod.KASSA_AI:
return settings.is_kassa_ai_enabled()
return False
@@ -356,6 +361,13 @@ def _is_freekassa_pending(payment: FreekassaPayment) -> bool:
return status in {'pending', 'created', 'processing'}
def _is_kassa_ai_pending(payment: KassaAiPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'created', 'processing'}
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
payload = payment.payload or ''
match = re.search(r'_(\d+)$', payload)
@@ -648,6 +660,31 @@ async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> list[
return records
async def _fetch_kassa_ai_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(KassaAiPayment)
.options(selectinload(KassaAiPayment.user))
.where(KassaAiPayment.created_at >= cutoff)
.order_by(desc(KassaAiPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_kassa_ai_pending(payment):
continue
record = _build_record(
PaymentMethod.KASSA_AI,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(Transaction)
@@ -694,6 +731,7 @@ async def list_recent_pending_payments(
await _fetch_cryptobot_payments(db, cutoff),
await _fetch_cloudpayments_payments(db, cutoff),
await _fetch_freekassa_payments(db, cutoff),
await _fetch_kassa_ai_payments(db, cutoff),
await _fetch_stars_transactions(db, cutoff),
)
@@ -848,6 +886,20 @@ async def get_payment_record(
is_paid=bool(payment.is_paid),
)
if method == PaymentMethod.KASSA_AI:
payment = await db.get(KassaAiPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if method == PaymentMethod.TELEGRAM_STARS:
transaction = await db.get(Transaction, local_payment_id)
if not transaction:

View File

@@ -12,10 +12,11 @@ from app.config import settings
from app.database.crud.referral_contest import (
add_contest_event,
get_contest_events_count,
get_contest_leaderboard,
get_contest_leaderboard_with_virtual,
get_contests_for_events,
get_contests_for_summaries,
get_referrer_score,
list_virtual_participants,
mark_daily_summary_sent,
mark_final_summary_sent,
)
@@ -173,8 +174,10 @@ class ReferralContestService:
day_start_utc = day_start_local.astimezone(UTC).replace(tzinfo=None)
day_end_utc = day_end_local.astimezone(UTC).replace(tzinfo=None)
leaderboard = list(await get_contest_leaderboard(db, contest.id))
total_events = await get_contest_events_count(db, contest.id)
leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id)
virtual_participants = await list_virtual_participants(db, contest.id)
virtual_count = sum(vp.referral_count for vp in virtual_participants)
total_events = await get_contest_events_count(db, contest.id) + virtual_count
today_events = await get_contest_events_count(
db,
contest.id,
@@ -269,7 +272,7 @@ class ReferralContestService:
self,
*,
contest: ReferralContest,
leaderboard: Sequence[tuple[User, int, int]],
leaderboard: Sequence[tuple[str, int, int, bool]],
total_events: int,
today_events: int,
is_final: bool,
@@ -293,10 +296,9 @@ class ReferralContestService:
]
if leaderboard:
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
name = user.full_name
user_id_display = user.telegram_id or user.email or f'#{user.id}'
lines.append(f'{idx}. {name} ({user_id_display}) — {score}')
for idx, (name, score, _, is_virtual) in enumerate(leaderboard[:5], start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
else:
lines.append('Пока нет участников.')
@@ -318,7 +320,7 @@ class ReferralContestService:
self,
*,
contest: ReferralContest,
leaderboard: Sequence[tuple[User, int, int]],
leaderboard: Sequence[tuple[str, int, int, bool]],
total_events: int,
today_events: int,
is_final: bool,
@@ -346,8 +348,8 @@ class ReferralContestService:
]
if leaderboard:
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
lines.append(f'{idx}. {user.full_name}{score}')
for idx, (name, score, _, _is_virtual) in enumerate(leaderboard[:5], start=1):
lines.append(f'{idx}. {name}{score}')
else:
lines.append('Пока нет участников.')
@@ -537,8 +539,12 @@ class ReferralContestService:
for contest in contests:
try:
# Проверяем что реферал зарегистрировался В ПЕРИОД конкурса
user_created_at = user.created_at if user.created_at.tzinfo is None else user.created_at.replace(tzinfo=None)
contest_start = contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None)
user_created_at = (
user.created_at if user.created_at.tzinfo is None else user.created_at.replace(tzinfo=None)
)
contest_start = (
contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None)
)
contest_end = contest.end_at if contest.end_at.tzinfo is None else contest.end_at.replace(tzinfo=None)
if user_created_at < contest_start or user_created_at > contest_end:
@@ -594,8 +600,12 @@ class ReferralContestService:
for contest in contests:
try:
# Проверяем что реферал зарегистрировался В ПЕРИОД конкурса
user_created_at = user.created_at if user.created_at.tzinfo is None else user.created_at.replace(tzinfo=None)
contest_start = contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None)
user_created_at = (
user.created_at if user.created_at.tzinfo is None else user.created_at.replace(tzinfo=None)
)
contest_start = (
contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None)
)
contest_end = contest.end_at if contest.end_at.tzinfo is None else contest.end_at.replace(tzinfo=None)
if user_created_at < contest_start or user_created_at > contest_end:

View File

@@ -115,6 +115,9 @@ class AdminStates(StatesGroup):
creating_referral_contest_end = State()
creating_referral_contest_time = State()
editing_referral_contest_summary_times = State()
adding_virtual_participant_name = State()
adding_virtual_participant_count = State()
editing_virtual_participant_count = State()
editing_daily_contest_field = State()
editing_daily_contest_value = State()

View File

@@ -256,7 +256,11 @@ async def compute_simple_subscription_price(
)
total_before_discount = (
base_price_original + traffic_price_original + devices_price_original + servers_price_original + modem_price_original
base_price_original
+ traffic_price_original
+ devices_price_original
+ servers_price_original
+ modem_price_original
)
total_discount = base_discount + traffic_discount + devices_discount + servers_discount_total

View File

@@ -2,20 +2,18 @@
Упрощенные тесты для проверки логики уведомлений Kassa AI.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import MagicMock
def test_notification_message_bright_prompt():
"""
Тест: проверяем что формируется ЯРКОЕ сообщение с SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true.
"""
from app.config import settings
# Эмулируем код из kassa_ai.py
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP = True
display_name = "Kassa AI"
amount_formatted = "10₽"
display_name = 'Kassa AI'
amount_formatted = '10₽'
if SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
message = (
@@ -37,7 +35,7 @@ def test_notification_message_bright_prompt():
assert '👇' in message
assert display_name in message
assert amount_formatted in message
print(f"\n✅ ЯРКОЕ сообщение сформировано правильно:\n{message}")
print(f'\n✅ ЯРКОЕ сообщение сформировано правильно:\n{message}')
def test_notification_message_standard():
@@ -46,8 +44,8 @@ def test_notification_message_standard():
"""
# Эмулируем код из kassa_ai.py
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP = False
display_name = "Kassa AI"
amount_formatted = "10₽"
display_name = 'Kassa AI'
amount_formatted = '10₽'
if SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
message = ''
@@ -69,7 +67,7 @@ def test_notification_message_standard():
assert 'Платеж успешно завершен' in message
assert display_name in message
assert amount_formatted in message
print(f"\n✅ Обычное сообщение сформировано правильно:\n{message}")
print(f'\n✅ Обычное сообщение сформировано правильно:\n{message}')
def test_telegram_id_saved_before_commit():
@@ -92,7 +90,7 @@ def test_telegram_id_saved_before_commit():
# Проверяем что локальные переменные сохранились
assert user_telegram_id == 123456789
assert user_language == 'ru'
print(f"\n✅ telegram_id сохранен в локальную переменную: {user_telegram_id}")
print(f'\n✅ telegram_id сохранен в локальную переменную: {user_telegram_id}')
def test_send_message_called_with_correct_params():
@@ -103,7 +101,7 @@ def test_send_message_called_with_correct_params():
bot.send_message = MagicMock()
user_telegram_id = 123456789
message = "Тестовое сообщение"
message = 'Тестовое сообщение'
keyboard = MagicMock()
# Эмулируем вызов
@@ -121,7 +119,7 @@ def test_send_message_called_with_correct_params():
assert call_args[1]['chat_id'] == 123456789
assert call_args[1]['parse_mode'] == 'HTML'
assert call_args[1]['text'] == message
print(f"\n✅ bot.send_message вызван с правильными параметрами")
print('\n✅ bot.send_message вызван с правильными параметрами')
def test_no_send_when_no_telegram_id():
@@ -135,8 +133,8 @@ def test_no_send_when_no_telegram_id():
# Эмулируем проверку
if bot and user_telegram_id:
bot.send_message(chat_id=user_telegram_id, text="test")
bot.send_message(chat_id=user_telegram_id, text='test')
# Проверка
bot.send_message.assert_not_called()
print(f"\n✅ bot.send_message НЕ вызван когда telegram_id=None")
print('\n✅ bot.send_message НЕ вызван когда telegram_id=None')