Files
remnawave-bedolaga-telegram…/app/handlers/admin/daily_contests.py
T
Fringg eb18994b7d fix: complete datetime.utcnow() → datetime.now(UTC) migration
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC)
- Migrate 30+ datetime.now() without UTC to datetime.now(UTC)
- Convert all 170 DateTime columns to DateTime(timezone=True)
- Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety
- Remove 70+ .replace(tzinfo=None) workarounds
- Fix utcfromtimestamp → fromtimestamp(..., tz=UTC)
- Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics)
- Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo)
- Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics)
- Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring)
- Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC)
- Rename panel_datetime_to_naive_utc → panel_datetime_to_utc
- Remove DTZ003 from ruff ignore list
2026-02-17 04:45:40 +03:00

573 lines
19 KiB
Python

import json
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.contest import (
clear_attempts,
create_round,
get_template_by_id,
list_templates,
update_template_fields,
)
from app.database.models import ContestTemplate
from app.keyboards.admin import (
get_daily_contest_manage_keyboard,
)
from app.localization.texts import get_texts
from app.services.contest_rotation_service import contest_rotation_service
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
logger = structlog.get_logger(__name__)
EDITABLE_FIELDS: dict[str, dict] = {
'prize_type': {'type': str, 'label': 'тип приза (days/balance/custom)'},
'prize_value': {'type': str, 'label': 'значение приза'},
'max_winners': {'type': int, 'min': 1, 'label': 'макс. победителей'},
'attempts_per_user': {'type': int, 'min': 1, 'label': 'попыток на пользователя'},
'times_per_day': {'type': int, 'min': 1, 'label': 'раундов в день'},
'schedule_times': {'type': str, 'label': 'расписание HH:MM через запятую'},
'cooldown_hours': {'type': int, 'min': 1, 'label': 'длительность раунда (часы)'},
}
async def _get_template(db: AsyncSession, template_id: int) -> ContestTemplate | None:
return await get_template_by_id(db, template_id)
@admin_required
@error_handler
async def show_daily_contests(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
templates = await list_templates(db, enabled_only=False)
lines = [texts.t('ADMIN_DAILY_CONTESTS_TITLE', '📆 Ежедневные конкурсы')]
if not templates:
lines.append(texts.t('ADMIN_CONTESTS_EMPTY', 'Пока нет созданных конкурсов.'))
else:
for tpl in templates:
status = '🟢' if tpl.is_enabled else '⚪️'
prize_info = f'{tpl.prize_value} ({tpl.prize_type})' if tpl.prize_type else tpl.prize_value
lines.append(f'{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {prize_info}, макс {tpl.max_winners}')
keyboard_rows = []
if templates:
keyboard_rows.append(
[types.InlineKeyboardButton(text='❌ Закрыть все активные раунды', callback_data='admin_daily_close_all')]
)
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=' Сбросить попытки во всех активных раундах', callback_data='admin_daily_reset_all_attempts'
)
]
)
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=' Запустить все активные конкурсы', callback_data='admin_daily_start_all'
)
]
)
for tpl in templates:
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=f'⚙️ {tpl.name}',
callback_data=f'admin_daily_contest_{tpl.id}',
)
]
)
keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_contests')])
await callback.message.edit_text(
'\n'.join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
@admin_required
@error_handler
async def show_daily_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
try:
template_id = int(callback.data.split('_')[-1])
except Exception:
await callback.answer('Некорректный id', show_alert=True)
return
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
lines = [
f'🏷 <b>{tpl.name}</b> (slug: {tpl.slug})',
f'{texts.t("ADMIN_CONTEST_STATUS_ACTIVE", "🟢 Активен") if tpl.is_enabled else texts.t("ADMIN_CONTEST_STATUS_INACTIVE", "⚪️ Выключен")}',
f'Тип приза: {tpl.prize_type or "days"} | Значение: {tpl.prize_value or "1"}',
f'Макс победителей: {tpl.max_winners}',
f'Попыток/польз: {tpl.attempts_per_user}',
f'Раундов в день: {tpl.times_per_day}',
f'Расписание: {tpl.schedule_times or "-"}',
f'Длительность раунда: {tpl.cooldown_hours} ч.',
]
await callback.message.edit_text(
'\n'.join(lines),
reply_markup=get_daily_contest_manage_keyboard(tpl.id, tpl.is_enabled, db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def toggle_daily_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split('_')[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
tpl.is_enabled = not tpl.is_enabled
await db.commit()
await callback.answer(texts.t('ADMIN_UPDATED', 'Обновлено'))
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def start_round_now(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split('_')[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
if not tpl.is_enabled:
tpl.is_enabled = True
await db.commit()
await db.refresh(tpl)
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.now(UTC)
ends = now + timedelta(hours=tpl.cooldown_hours)
await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now,
ends,
)
await callback.answer(texts.t('ADMIN_ROUND_STARTED', 'Раунд запущен'), show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def manual_start_round(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split('_')[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
# Проверяем, есть ли уже активный раунд для этого шаблона
from app.database.crud.contest import get_active_round_by_template
exists = await get_active_round_by_template(db, tpl.id)
if exists:
await callback.answer(texts.t('ADMIN_ROUND_ALREADY_ACTIVE', 'Раунд уже активен.'), show_alert=True)
await show_daily_contest(callback, db_user, db)
return
# Для ручного старта не включаем конкурс, если он выключен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.now(UTC)
ends = now + timedelta(hours=tpl.cooldown_hours)
await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
# Анонсируем всем пользователям (как тест)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now,
ends,
)
await callback.answer(texts.t('ADMIN_ROUND_STARTED', 'Тестовый раунд запущен'), show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def prompt_edit_field(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
parts = callback.data.split('_')
template_id = int(parts[3])
field = '_'.join(parts[4:]) # поле может содержать подчеркивания
tpl = await _get_template(db, template_id)
if not tpl or field not in EDITABLE_FIELDS:
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
meta = EDITABLE_FIELDS[field]
await state.set_state(AdminStates.editing_daily_contest_field)
await state.update_data(template_id=template_id, field=field)
kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f'admin_daily_contest_{template_id}',
)
]
]
)
await callback.message.edit_text(
texts.t(
'ADMIN_CONTEST_FIELD_PROMPT',
'Введите новое значение для {label}:',
).format(label=meta.get('label', field)),
reply_markup=kb,
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_field(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
template_id = data.get('template_id')
field = data.get('field')
if not template_id or not field or field not in EDITABLE_FIELDS:
await message.answer(texts.ERROR)
await state.clear()
return
tpl = await _get_template(db, template_id)
if not tpl:
await message.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'))
await state.clear()
return
meta = EDITABLE_FIELDS[field]
raw = message.text or ''
try:
if meta['type'] is int:
value = int(raw)
if meta.get('min') is not None and value < meta['min']:
raise ValueError('min')
else:
value = raw.strip()
except Exception:
await message.answer(texts.t('ADMIN_INVALID_NUMBER', 'Некорректное число'))
await state.clear()
return
await update_template_fields(db, tpl, **{field: value})
back_kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f'admin_daily_contest_{template_id}',
)
]
]
)
await message.answer(texts.t('ADMIN_UPDATED', 'Обновлено'), reply_markup=back_kb)
await state.clear()
@admin_required
@error_handler
async def edit_payload(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split('_')[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
await state.set_state(AdminStates.editing_daily_contest_value)
await state.update_data(template_id=template_id, field='payload')
payload_json = json.dumps(tpl.payload or {}, ensure_ascii=False, indent=2)
kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f'admin_daily_contest_{template_id}',
)
]
]
)
await callback.message.edit_text(
texts.t('ADMIN_CONTEST_PAYLOAD_PROMPT', 'Отправьте JSON payload для игры (словарь настроек):\n')
+ f'<code>{payload_json}</code>',
reply_markup=kb,
)
await callback.answer()
@admin_required
@error_handler
async def process_payload(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
template_id = data.get('template_id')
if not template_id:
await message.answer(texts.ERROR)
await state.clear()
return
try:
payload = json.loads(message.text or '{}')
if not isinstance(payload, dict):
raise ValueError
except Exception:
await message.answer(texts.t('ADMIN_INVALID_JSON', 'Некорректный JSON'))
await state.clear()
return
tpl = await _get_template(db, template_id)
if not tpl:
await message.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'))
await state.clear()
return
await update_template_fields(db, tpl, payload=payload)
back_kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f'admin_daily_contest_{template_id}',
)
]
]
)
await message.answer(texts.t('ADMIN_UPDATED', 'Обновлено'), reply_markup=back_kb)
await state.clear()
@admin_required
@error_handler
async def start_all_contests(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
templates = await list_templates(db, enabled_only=True)
if not templates:
await callback.answer(texts.t('ADMIN_CONTESTS_EMPTY', 'Нет активных конкурсов.'), show_alert=True)
return
started_count = 0
for tpl in templates:
from app.database.crud.contest import get_active_round_by_template
exists = await get_active_round_by_template(db, tpl.id)
if exists:
continue # уже запущен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.now(UTC)
ends = now + timedelta(hours=tpl.cooldown_hours)
await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now,
ends,
)
started_count += 1
message = f'Запущено конкурсов: {started_count}'
await callback.answer(message, show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def close_all_rounds(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
get_texts(db_user.language)
from app.database.crud.contest import get_active_rounds
active_rounds = await get_active_rounds(db)
if not active_rounds:
await callback.answer('Нет активных раундов', show_alert=True)
return
for rnd in active_rounds:
rnd.status = 'finished'
await db.commit()
await callback.answer(f'Закрыто раундов: {len(active_rounds)}', show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def reset_all_attempts(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
get_texts(db_user.language)
from app.database.crud.contest import get_active_rounds
active_rounds = await get_active_rounds(db)
if not active_rounds:
await callback.answer('Нет активных раундов', show_alert=True)
return
total_deleted = 0
for rnd in active_rounds:
deleted = await clear_attempts(db, rnd.id)
total_deleted += deleted
await callback.answer(f'Попытки сброшены: {total_deleted}', show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def reset_attempts(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split('_')[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
from app.database.crud.contest import clear_attempts, get_active_round_by_template
round_obj = await get_active_round_by_template(db, tpl.id)
if not round_obj:
await callback.answer('Нет активного раунда', show_alert=True)
return
deleted_count = await clear_attempts(db, round_obj.id)
await callback.answer(f'Попытки сброшены: {deleted_count}', show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def close_round(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split('_')[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
from app.database.crud.contest import get_active_round_by_template
round_obj = await get_active_round_by_template(db, tpl.id)
if not round_obj:
await callback.answer('Нет активного раунда', show_alert=True)
return
round_obj.status = 'finished'
await db.commit()
await db.refresh(round_obj)
await callback.answer('Раунд закрыт', show_alert=True)
await show_daily_contest(callback, db_user, db)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_daily_contests, F.data == 'admin_contests_daily')
dp.callback_query.register(show_daily_contest, F.data.startswith('admin_daily_contest_'))
dp.callback_query.register(toggle_daily_contest, F.data.startswith('admin_daily_toggle_'))
dp.callback_query.register(start_all_contests, F.data == 'admin_daily_start_all')
dp.callback_query.register(start_round_now, F.data.startswith('admin_daily_start_'))
dp.callback_query.register(manual_start_round, F.data.startswith('admin_daily_manual_'))
dp.callback_query.register(close_all_rounds, F.data == 'admin_daily_close_all')
dp.callback_query.register(reset_all_attempts, F.data == 'admin_daily_reset_all_attempts')
dp.callback_query.register(reset_attempts, F.data.startswith('admin_daily_reset_attempts_'))
dp.callback_query.register(close_round, F.data.startswith('admin_daily_close_'))
dp.callback_query.register(prompt_edit_field, F.data.startswith('admin_daily_edit_'))
dp.callback_query.register(edit_payload, F.data.startswith('admin_daily_payload_'))
dp.message.register(process_edit_field, AdminStates.editing_daily_contest_field)
dp.message.register(process_payload, AdminStates.editing_daily_contest_value)