eb18994b7d
- 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
573 lines
19 KiB
Python
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)
|