Stabilize staff concurrency and premium emoji; enable verified Drone deployment
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
"""
|
||||
Расширенная админ-панель для управления розыгрышами
|
||||
"""
|
||||
from src.utils.errors import public_error
|
||||
from src.core.access import is_admin
|
||||
from src.core.access import is_admin as check_admin_access
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import timezone
|
||||
import logging
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import (
|
||||
@@ -112,9 +117,6 @@ class AdminStates(StatesGroup):
|
||||
admin_router = Router()
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка прав администратора (быстрая проверка только .env)"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
def is_super_admin(user_id: int) -> bool:
|
||||
@@ -122,23 +124,6 @@ def is_super_admin(user_id: int) -> bool:
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
async def check_admin_access(user_id: int) -> bool:
|
||||
"""
|
||||
Асинхронная проверка доступа администратора.
|
||||
Проверяет как главных администраторов (.env), так и назначенных (БД)
|
||||
"""
|
||||
# Сначала проверяем главных администраторов
|
||||
if user_id in ADMIN_IDS:
|
||||
return True
|
||||
|
||||
# Затем проверяем назначенных администраторов в БД
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == user_id, User.is_admin == True)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
return user is not None
|
||||
|
||||
|
||||
def get_admin_main_keyboard() -> InlineKeyboardMarkup:
|
||||
@@ -233,7 +218,7 @@ async def show_admin_panel(callback: CallbackQuery):
|
||||
# УПРАВЛЕНИЕ РОЗЫГРЫШАМИ
|
||||
# ======================
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_lotteries")
|
||||
@admin_router.callback_query(F.data.in_({"admin_lotteries", "lottery_management"}))
|
||||
async def show_lottery_management(callback: CallbackQuery):
|
||||
"""Управление розыгрышами"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -277,7 +262,7 @@ async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
logging.info(f"✅ Состояние установлено: AdminStates.lottery_title")
|
||||
except Exception as e:
|
||||
logging.error(f"❌ Ошибка при создании розыгрыша: {e}")
|
||||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||||
await callback.message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.lottery_title))
|
||||
@@ -784,7 +769,7 @@ async def choose_user_to_add(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminStates.add_participant_user)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.add_participant_user))
|
||||
@admin_router.message(StateFilter(AdminStates.add_participant_user), F.text)
|
||||
async def process_add_participant(message: Message, state: FSMContext):
|
||||
"""Обработка добавления участника"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
@@ -905,7 +890,7 @@ async def remove_participant_select_lottery(callback: CallbackQuery, state: FSMC
|
||||
)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.remove_participant_user))
|
||||
@admin_router.message(StateFilter(AdminStates.remove_participant_user), F.text)
|
||||
async def process_remove_participant(message: Message, state: FSMContext):
|
||||
"""Обработка удаления участника"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
@@ -1180,7 +1165,7 @@ async def start_search_participants(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminStates.participant_search)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.participant_search))
|
||||
@admin_router.message(StateFilter(AdminStates.participant_search), F.text)
|
||||
async def process_search_participants(message: Message, state: FSMContext):
|
||||
"""Обработка поиска участников"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
@@ -1490,7 +1475,7 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
|
||||
@admin_router.callback_query(F.data.startswith("admin_add_to_"))
|
||||
async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавление участника в конкретный розыгрыш"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1520,10 +1505,10 @@ async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext)
|
||||
await state.set_state(AdminStates.add_to_lottery_user)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user))
|
||||
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user), F.text)
|
||||
async def process_add_to_lottery(message: Message, state: FSMContext):
|
||||
"""Обработка добавления участника в конкретный розыгрыш"""
|
||||
if not is_admin(message.from_user.id):
|
||||
if not await is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
@@ -1624,7 +1609,7 @@ async def process_add_to_lottery(message: Message, state: FSMContext):
|
||||
@admin_router.callback_query(F.data.startswith("admin_remove_from_"))
|
||||
async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Удаление участника из конкретного розыгрыша"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1656,10 +1641,10 @@ async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMCon
|
||||
await state.set_state(AdminStates.remove_from_lottery_user)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user))
|
||||
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user), F.text)
|
||||
async def process_remove_from_lottery(message: Message, state: FSMContext):
|
||||
"""Обработка удаления участника из конкретного розыгрыша"""
|
||||
if not is_admin(message.from_user.id):
|
||||
if not await is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
@@ -1741,7 +1726,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext):
|
||||
@admin_router.callback_query(F.data.startswith("admin_check_winners_"))
|
||||
async def check_winners(callback: CallbackQuery):
|
||||
"""Проверка подтверждения победителей"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1810,7 +1795,7 @@ async def check_winners(callback: CallbackQuery):
|
||||
@admin_router.callback_query(F.data.startswith("admin_redraw_"))
|
||||
async def redraw_lottery(callback: CallbackQuery):
|
||||
"""Повторный розыгрыш для неподтверждённых призов"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1860,123 +1845,23 @@ async def redraw_lottery(callback: CallbackQuery):
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_redraw_confirm_"))
|
||||
async def confirm_redraw(callback: CallbackQuery):
|
||||
"""Подтверждение и выполнение повторного розыгрыша"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
from src.core.redraw_services import redraw_unclaimed
|
||||
from src.utils.notifications import notify_winners_async
|
||||
lottery_id = int(callback.data.split("_")[-1])
|
||||
|
||||
await callback.answer("⏳ Проводится повторный розыгрыш...", show_alert=True)
|
||||
|
||||
await callback.answer("Проверяю неподтверждённые призы")
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select, delete
|
||||
from ..core.models import Winner, Participation
|
||||
import random
|
||||
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await callback.message.edit_text("❌ Розыгрыш не найден")
|
||||
return
|
||||
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
|
||||
# Собираем подтверждённые и неподтверждённые
|
||||
confirmed_winners = [w for w in winners if w.is_claimed]
|
||||
unconfirmed_winners = [w for w in winners if not w.is_claimed]
|
||||
|
||||
if not unconfirmed_winners:
|
||||
await callback.message.edit_text(
|
||||
"✅ Все победители уже подтверждены!",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
# Собираем исключённые счета (подтверждённые победители + бывшие неподтверждённые)
|
||||
excluded_accounts = set()
|
||||
for w in winners:
|
||||
if w.account_number:
|
||||
excluded_accounts.add(w.account_number)
|
||||
|
||||
# Получаем всех участников, исключая уже выигравших
|
||||
result = await session.execute(
|
||||
select(Participation)
|
||||
.where(Participation.lottery_id == lottery_id)
|
||||
)
|
||||
all_participations = result.scalars().all()
|
||||
|
||||
# Фильтруем участников
|
||||
available_participations = [
|
||||
p for p in all_participations
|
||||
if p.account_number not in excluded_accounts
|
||||
]
|
||||
|
||||
if not available_participations:
|
||||
await callback.message.edit_text(
|
||||
"❌ Нет доступных участников для переигровки.\n"
|
||||
"Все участники уже выиграли или были исключены.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
# Удаляем неподтверждённых победителей
|
||||
for winner in unconfirmed_winners:
|
||||
await session.delete(winner)
|
||||
|
||||
# Проводим розыгрыш для неподтверждённых мест
|
||||
new_winners_text = ""
|
||||
random.shuffle(available_participations)
|
||||
|
||||
for i, old_winner in enumerate(unconfirmed_winners):
|
||||
if i >= len(available_participations):
|
||||
break
|
||||
|
||||
new_participation = available_participations[i]
|
||||
|
||||
# Создаём нового победителя
|
||||
new_winner = Winner(
|
||||
lottery_id=lottery_id,
|
||||
user_id=new_participation.user_id,
|
||||
account_number=new_participation.account_number,
|
||||
place=old_winner.place,
|
||||
is_manual=False,
|
||||
is_claimed=False
|
||||
)
|
||||
session.add(new_winner)
|
||||
|
||||
# Исключаем из следующих итераций
|
||||
if new_participation.account_number:
|
||||
excluded_accounts.add(new_participation.account_number)
|
||||
|
||||
prize = lottery.prizes[old_winner.place - 1] if lottery.prizes and len(lottery.prizes) >= old_winner.place else "Приз"
|
||||
name = new_participation.account_number or f"ID: {new_participation.user_id}"
|
||||
new_winners_text += f"🏆 {old_winner.place} место: {name} → {prize}\n"
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Отправляем уведомления новым победителям
|
||||
from ..utils.notifications import notify_winners_async
|
||||
try:
|
||||
winners = await redraw_unclaimed(session, lottery_id)
|
||||
if winners:
|
||||
await notify_winners_async(callback.bot, session, lottery_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомлений: {e}")
|
||||
text = "Повторный розыгрыш завершён.\n" if winners else "Нет просроченных неподтверждённых призов или новых участников."
|
||||
for winner in winners:
|
||||
text += f"{winner.place} место: {winner.account_number or winner.user_id} — {winner.prize}\n"
|
||||
await callback.message.answer(text)
|
||||
|
||||
text = f"🎉 Повторный розыгрыш завершён!\n\n"
|
||||
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
|
||||
text += f"Новые победители:\n{new_winners_text}\n"
|
||||
text += "✅ Уведомления отправлены новым победителям"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="✅ Проверить победителей", callback_data=f"admin_check_winners_{lottery_id}")],
|
||||
[InlineKeyboardButton(text="🔙 К розыгрышу", callback_data=f"admin_lottery_detail_{lottery_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
# ======================
|
||||
@@ -1986,7 +1871,7 @@ async def confirm_redraw(callback: CallbackQuery):
|
||||
@admin_router.callback_query(F.data.startswith("admin_del_lottery_"))
|
||||
async def delete_lottery_confirm(callback: CallbackQuery):
|
||||
"""Подтверждение удаления розыгрыша"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -2020,7 +1905,7 @@ async def delete_lottery_confirm(callback: CallbackQuery):
|
||||
@admin_router.callback_query(F.data.startswith("admin_del_lottery_yes_"))
|
||||
async def delete_lottery_execute(callback: CallbackQuery):
|
||||
"""Выполнение удаления розыгрыша"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -2037,15 +1922,7 @@ async def delete_lottery_execute(callback: CallbackQuery):
|
||||
|
||||
lottery_title = lottery.title
|
||||
|
||||
# Удаляем победителей
|
||||
await session.execute(sql_delete(Winner).where(Winner.lottery_id == lottery_id))
|
||||
|
||||
# Удаляем участников
|
||||
await session.execute(sql_delete(Participation).where(Participation.lottery_id == lottery_id))
|
||||
|
||||
# Удаляем розыгрыш
|
||||
await session.delete(lottery)
|
||||
await session.commit()
|
||||
await LotteryService.delete_lottery(session, lottery_id)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Розыгрыш удалён\n\n"
|
||||
@@ -2384,7 +2261,7 @@ async def show_participants_report(callback: CallbackQuery):
|
||||
select(
|
||||
User.first_name,
|
||||
User.username,
|
||||
User.account_number,
|
||||
func.min(Participation.account_number),
|
||||
func.count(Participation.id).label('participations')
|
||||
)
|
||||
.join(Participation)
|
||||
@@ -2396,11 +2273,11 @@ async def show_participants_report(callback: CallbackQuery):
|
||||
|
||||
# Участники с аккаунтами vs без
|
||||
users_with_accounts = await session.scalar(
|
||||
select(func.count(User.id)).where(User.account_number.isnot(None))
|
||||
select(func.count(User.id)).where(User.accounts.any())
|
||||
)
|
||||
|
||||
users_without_accounts = await session.scalar(
|
||||
select(func.count(User.id)).where(User.account_number.is_(None))
|
||||
select(func.count(User.id)).where(~User.accounts.any())
|
||||
)
|
||||
|
||||
text = "📈 Отчет по участникам\n\n"
|
||||
@@ -2951,7 +2828,7 @@ async def process_winner_place(message: Message, state: FSMContext):
|
||||
await state.set_state(AdminStates.set_winner_user)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.set_winner_user))
|
||||
@admin_router.message(StateFilter(AdminStates.set_winner_user), F.text)
|
||||
async def process_winner_user(message: Message, state: FSMContext):
|
||||
"""Обработка пользователя-победителя (по ID, username или номеру счета)"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
@@ -3391,6 +3268,8 @@ async def do_remove_winner(callback: CallbackQuery):
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
|
||||
# Удаляем победителя
|
||||
from src.core.models import WinnerVerification
|
||||
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id == winner_id))
|
||||
await session.execute(delete(Winner).where(Winner.id == winner_id))
|
||||
await session.commit()
|
||||
|
||||
@@ -3443,7 +3322,7 @@ async def choose_lottery_for_draw(callback: CallbackQuery):
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_draws")])
|
||||
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_lotteries")])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
@@ -3546,7 +3425,7 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проведении розыгрыша {lottery_id}: {e}", exc_info=True)
|
||||
await session.rollback()
|
||||
await callback.answer(f"❌ Ошибка: {e}", show_alert=True)
|
||||
await callback.answer(f"❌ Ошибка: {public_error(e)}", show_alert=True)
|
||||
return
|
||||
|
||||
if winners_dict:
|
||||
@@ -3587,7 +3466,7 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ К розыгрышам", callback_data="admin_draws")]
|
||||
[InlineKeyboardButton(text="◀️ К розыгрышам", callback_data="admin_lotteries")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
@@ -4095,7 +3974,7 @@ async def apply_display_type(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"💥 Исключение при смене типа отображения: {e}")
|
||||
text = f"❌ Ошибка: {str(e)}"
|
||||
text = f"❌ Ошибка: {public_error(e)}"
|
||||
await callback.answer("❌ Ошибка при сохранении!", show_alert=True)
|
||||
return
|
||||
await callback.answer("❌ Ошибка сохранения", show_alert=True)
|
||||
@@ -4402,6 +4281,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
winners_dict: Словарь с победителями {место: данные}
|
||||
"""
|
||||
import asyncio
|
||||
from html import escape
|
||||
|
||||
# Получаем розыгрыш
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
@@ -4428,7 +4308,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
# Формируем сообщение с результатами
|
||||
message = (
|
||||
f"📢 <b>Результаты розыгрыша</b>\n\n"
|
||||
f"🎯 <b>{lottery.title}</b>\n\n"
|
||||
f"🎯 <b>{escape(lottery.title)}</b>\n\n"
|
||||
f"🏆 <b>Победители:</b>\n"
|
||||
)
|
||||
|
||||
@@ -4451,7 +4331,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
|
||||
# Формируем строку победителя
|
||||
winner_name = nickname if nickname else display_name if display_name else f"Счет {winner.account_number}"
|
||||
message += f"{winner.place} место: {winner_name}\n"
|
||||
message += f"{winner.place} место: {escape(winner_name)}\n"
|
||||
|
||||
message += (
|
||||
f"\n🎁 Поздравляем победителей!\n"
|
||||
@@ -4459,6 +4339,8 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
)
|
||||
|
||||
# Рассылаем всем кроме победителей
|
||||
await session.commit()
|
||||
from src.utils.delivery import send_background
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
@@ -4468,9 +4350,8 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
continue
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
await send_background(bot, chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
success_count += 1
|
||||
@@ -4488,100 +4369,19 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_export_users")
|
||||
async def admin_export_users(callback: CallbackQuery):
|
||||
"""Экспорт всех пользователей в XLSX"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
await callback.answer("Готовлю файл")
|
||||
import asyncio
|
||||
from aiogram.types import BufferedInputFile
|
||||
from src.utils.spreadsheets import export_users
|
||||
async with async_session_maker() as session:
|
||||
users = await UserService.get_all_users(session)
|
||||
content = await asyncio.to_thread(export_users, users)
|
||||
await callback.message.answer_document(BufferedInputFile(content, filename="users.xlsx"),
|
||||
caption=f"Пользователей: {len(users)}")
|
||||
|
||||
await callback.answer("⏳ Формирую файл...", show_alert=False)
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
from io import BytesIO
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Получаем всех пользователей
|
||||
all_users = await UserService.get_all_users(session)
|
||||
|
||||
# Создаем Excel файл
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Пользователи"
|
||||
|
||||
# Заголовки
|
||||
headers = [
|
||||
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм',
|
||||
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ',
|
||||
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
|
||||
]
|
||||
|
||||
# Стиль для заголовков
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
|
||||
for col_num, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_num, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
# Данные пользователей
|
||||
for row_num, user in enumerate(all_users, 2):
|
||||
ws.cell(row=row_num, column=1, value=user.telegram_id)
|
||||
ws.cell(row=row_num, column=2, value=user.username or '')
|
||||
ws.cell(row=row_num, column=3, value=user.first_name or '')
|
||||
ws.cell(row=row_num, column=4, value=user.last_name or '')
|
||||
ws.cell(row=row_num, column=5, value=user.nickname or '')
|
||||
ws.cell(row=row_num, column=6, value=user.phone or '')
|
||||
ws.cell(row=row_num, column=7, value=user.club_card_number or '')
|
||||
ws.cell(row=row_num, column=8, value='Да' if user.is_registered else 'Нет')
|
||||
ws.cell(row=row_num, column=9, value='Да' if user.is_admin else 'Нет')
|
||||
ws.cell(row=row_num, column=10, value=user.verification_code or '')
|
||||
ws.cell(row=row_num, column=11, value=user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '')
|
||||
ws.cell(row=row_num, column=12, value=user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '')
|
||||
ws.cell(row=row_num, column=13, value='Да' if user.is_chat_banned else 'Нет')
|
||||
|
||||
# Автоподбор ширины колонок
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max_length + 2, 50)
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
# Сохраняем в BytesIO
|
||||
excel_file = BytesIO()
|
||||
wb.save(excel_file)
|
||||
excel_file.seek(0)
|
||||
|
||||
# Отправляем файл
|
||||
filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
file = BufferedInputFile(excel_file.read(), filename=filename)
|
||||
|
||||
registered_count = len([u for u in all_users if u.is_registered])
|
||||
|
||||
await callback.message.answer_document(
|
||||
document=file,
|
||||
caption=(
|
||||
f"📥 <b>Экспорт пользователей</b>\n\n"
|
||||
f"📊 Всего пользователей: {len(all_users)}\n"
|
||||
f"✅ Зарегистрировано: {registered_count}\n"
|
||||
f"📅 Дата экспорта: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Файл отправлен", show_alert=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка экспорта пользователей: {e}")
|
||||
await callback.answer("❌ Ошибка при создании файла", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_import_users")
|
||||
@@ -4619,172 +4419,32 @@ async def admin_import_users_start(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.import_users_json), F.document)
|
||||
async def admin_import_users_process(message: Message, state: FSMContext):
|
||||
"""Обработка импорта пользователей из XLSX"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
return
|
||||
|
||||
# Проверяем формат файла
|
||||
if not message.document.file_name.endswith('.xlsx'):
|
||||
await message.answer("❌ Неверный формат файла. Отправьте XLSX файл.")
|
||||
import asyncio
|
||||
from src.utils.spreadsheets import read_users_xlsx, MAX_FILE_BYTES
|
||||
from src.core.import_services import import_users
|
||||
if not (message.document.file_name or "").lower().endswith(".xlsx"):
|
||||
await message.answer("Отправьте XLSX файл.")
|
||||
return
|
||||
|
||||
status_msg = await message.answer("⏳ Загружаю файл...")
|
||||
|
||||
if (message.document.file_size or 0) > MAX_FILE_BYTES:
|
||||
await message.answer("Файл должен быть не больше 5 МБ.")
|
||||
return
|
||||
status = await message.answer("Импортирую пользователей…")
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
from io import BytesIO
|
||||
|
||||
# Скачиваем файл
|
||||
file = await message.bot.get_file(message.document.file_id)
|
||||
file_content = await message.bot.download_file(file.file_path)
|
||||
|
||||
# Читаем Excel файл
|
||||
excel_file = BytesIO(file_content.read())
|
||||
wb = load_workbook(excel_file, read_only=True)
|
||||
ws = wb.active
|
||||
|
||||
# Читаем данные
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
|
||||
if len(rows) < 2:
|
||||
await status_msg.edit_text("❌ Файл пуст или не содержит данных.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Первая строка - заголовки
|
||||
headers = [h if h else '' for h in rows[0]]
|
||||
|
||||
# Находим индекс колонки Telegram ID
|
||||
try:
|
||||
telegram_id_idx = headers.index('Telegram ID')
|
||||
except ValueError:
|
||||
await status_msg.edit_text("❌ Не найдена обязательная колонка 'Telegram ID'.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Создаем маппинг индексов для других полей
|
||||
field_mapping = {
|
||||
'Username': 'username',
|
||||
'Имя': 'first_name',
|
||||
'Фамилия': 'last_name',
|
||||
'Никнейм': 'nickname',
|
||||
'Телефон': 'phone',
|
||||
'Клубная карта': 'club_card_number',
|
||||
'Зарегистрирован': 'is_registered',
|
||||
'Код верификации': 'verification_code'
|
||||
}
|
||||
|
||||
users_data = []
|
||||
for row in rows[1:]: # Пропускаем заголовки
|
||||
if not row or len(row) <= telegram_id_idx or not row[telegram_id_idx]:
|
||||
continue
|
||||
|
||||
user_dict = {'telegram_id': row[telegram_id_idx]}
|
||||
|
||||
for header_name, field_name in field_mapping.items():
|
||||
try:
|
||||
idx = headers.index(header_name)
|
||||
if idx < len(row):
|
||||
value = row[idx]
|
||||
if field_name == 'is_registered':
|
||||
user_dict[field_name] = value in ['Да', 'Yes', 'True', True, 1]
|
||||
else:
|
||||
user_dict[field_name] = value if value else None
|
||||
except (ValueError, IndexError):
|
||||
user_dict[field_name] = None
|
||||
|
||||
users_data.append(user_dict)
|
||||
|
||||
await status_msg.edit_text(
|
||||
f"📊 Найдено пользователей в файле: {len(users_data)}\n"
|
||||
f"⏳ Импортирую..."
|
||||
)
|
||||
|
||||
# Импортируем пользователей
|
||||
async with async_session_maker() as session:
|
||||
added_count = 0
|
||||
updated_count = 0
|
||||
error_count = 0
|
||||
|
||||
for user_data in users_data:
|
||||
try:
|
||||
telegram_id = user_data.get('telegram_id')
|
||||
if not telegram_id:
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
# Преобразуем telegram_id в int если это строка
|
||||
try:
|
||||
telegram_id = int(telegram_id)
|
||||
except (ValueError, TypeError):
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
# Ищем существующего пользователя
|
||||
existing_user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
|
||||
if existing_user:
|
||||
# Обновляем существующего
|
||||
if user_data.get('username') is not None:
|
||||
existing_user.username = user_data.get('username')
|
||||
if user_data.get('first_name') is not None:
|
||||
existing_user.first_name = user_data.get('first_name')
|
||||
if user_data.get('last_name') is not None:
|
||||
existing_user.last_name = user_data.get('last_name')
|
||||
if user_data.get('nickname') is not None:
|
||||
existing_user.nickname = user_data.get('nickname')
|
||||
if user_data.get('phone') is not None:
|
||||
existing_user.phone = user_data.get('phone')
|
||||
if user_data.get('club_card_number') is not None:
|
||||
existing_user.club_card_number = user_data.get('club_card_number')
|
||||
if user_data.get('is_registered') is not None:
|
||||
existing_user.is_registered = user_data.get('is_registered', False)
|
||||
if user_data.get('verification_code') is not None:
|
||||
existing_user.verification_code = user_data.get('verification_code')
|
||||
# is_admin не обновляем из соображений безопасности
|
||||
|
||||
updated_count += 1
|
||||
else:
|
||||
# Создаем нового
|
||||
new_user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=user_data.get('username'),
|
||||
first_name=user_data.get('first_name'),
|
||||
last_name=user_data.get('last_name'),
|
||||
nickname=user_data.get('nickname'),
|
||||
phone=user_data.get('phone'),
|
||||
club_card_number=user_data.get('club_card_number'),
|
||||
is_registered=user_data.get('is_registered', False),
|
||||
is_admin=False, # Не импортируем админов из соображений безопасности
|
||||
verification_code=user_data.get('verification_code')
|
||||
)
|
||||
session.add(new_user)
|
||||
added_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка импорта пользователя {user_data.get('telegram_id')}: {e}")
|
||||
error_count += 1
|
||||
|
||||
# Сохраняем изменения
|
||||
await session.commit()
|
||||
|
||||
# Итоговый отчет
|
||||
await status_msg.edit_text(
|
||||
f"✅ <b>Импорт завершен!</b>\n\n"
|
||||
f"📊 <b>Статистика:</b>\n"
|
||||
f"➕ Добавлено: {added_count}\n"
|
||||
f"🔄 Обновлено: {updated_count}\n"
|
||||
f"❌ Ошибок: {error_count}\n"
|
||||
f"📝 Всего обработано: {added_count + updated_count}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
content = await message.bot.download_file(file.file_path)
|
||||
records = await asyncio.to_thread(read_users_xlsx, content.read())
|
||||
added, updated, errors = await import_users(records)
|
||||
await status.edit_text(f"Добавлено: {added}. Обновлено: {updated}. Ошибок: {errors}.")
|
||||
except ValueError as error:
|
||||
await status.edit_text(str(error))
|
||||
except Exception:
|
||||
logger.exception("Не удалось импортировать XLSX")
|
||||
await status.edit_text("Не удалось обработать файл. Проверьте формат данных.")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка импорта пользователей: {e}")
|
||||
await status_msg.edit_text(f"❌ Ошибка импорта: {str(e)}\n\nПроверьте формат файла.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -5176,7 +4836,7 @@ async def admin_broadcast_channels_menu(callback: CallbackQuery, state: FSMConte
|
||||
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="✨ Добавить канал/группу", callback_data="admin_broadcast_add_channel")],
|
||||
[InlineKeyboardButton(text="📜 Список всех", callback_data="admin_broadcast_list_channels")],
|
||||
[InlineKeyboardButton(text="📜 Список всех", callback_data="admin_broadcast_channels")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_broadcast")]
|
||||
]
|
||||
|
||||
@@ -5278,7 +4938,7 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
|
||||
f"• Бот не добавлен в канал/группу\n"
|
||||
f"• Неверный ID\n"
|
||||
f"• Бот не имеет прав администратора\n\n"
|
||||
f"Детали: {str(e)}",
|
||||
f"Детали: {public_error(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
@@ -5551,7 +5211,7 @@ async def admin_users_search_prompt(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminStates.user_management_search)
|
||||
|
||||
|
||||
@admin_router.message(AdminStates.user_management_search)
|
||||
@admin_router.message(AdminStates.user_management_search, F.text)
|
||||
async def admin_users_search_process(message: Message, state: FSMContext):
|
||||
"""Обработка поискового запроса"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
@@ -5932,7 +5592,7 @@ async def add_admin_start(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminStates.admin_add_search)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.admin_add_search))
|
||||
@admin_router.message(StateFilter(AdminStates.admin_add_search), F.text)
|
||||
async def search_user_for_admin(message: Message, state: FSMContext):
|
||||
"""Поиск пользователя для назначения админом"""
|
||||
if not is_super_admin(message.from_user.id):
|
||||
@@ -6085,4 +5745,4 @@ async def confirm_remove_admin(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
|
||||
# Экспорт роутера
|
||||
__all__ = ['admin_router']
|
||||
__all__ = ['admin_router']
|
||||
|
||||
Reference in New Issue
Block a user