Separate staff roles and protect message delivery with operator guides
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -107,11 +107,6 @@ class AdminStates(StatesGroup):
|
||||
user_management_view = State() # Просмотр пользователя
|
||||
|
||||
# Управление админами
|
||||
admin_management_action = State() # Выбор действия (добавить/удалить)
|
||||
admin_add_search = State() # Поиск пользователя для назначения админом
|
||||
admin_add_confirm = State() # Подтверждение назначения
|
||||
admin_remove_select = State() # Выбор админа для удаления
|
||||
admin_remove_confirm = State() # Подтверждение удаления
|
||||
|
||||
|
||||
admin_router = Router()
|
||||
@@ -136,6 +131,7 @@ def get_admin_main_keyboard() -> InlineKeyboardMarkup:
|
||||
[InlineKeyboardButton(text="📢 Рассылки", callback_data="admin_broadcast"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
|
||||
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")],
|
||||
[InlineKeyboardButton(text="📖 Инструкция администратора", callback_data="guide:admin")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="back_to_main")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
@@ -3571,7 +3567,7 @@ async def show_admin_settings(callback: CallbackQuery):
|
||||
|
||||
# Кнопка управления админами - только для главных админов
|
||||
if is_super_admin(callback.from_user.id):
|
||||
buttons.append([InlineKeyboardButton(text="👑 Управление админами", callback_data="admin_manage_admins")])
|
||||
buttons.append([InlineKeyboardButton(text="👥 Сотрудники и права", callback_data="staff:menu")])
|
||||
|
||||
buttons.extend([
|
||||
[InlineKeyboardButton(text="💿 Экспорт пользователей", callback_data="admin_export_users")],
|
||||
@@ -3733,27 +3729,11 @@ async def cleanup_inactive_users(callback: CallbackQuery):
|
||||
)
|
||||
inactive_users = result.scalars().all()
|
||||
|
||||
# Проверяем, что у них нет связанных данных
|
||||
deleted_count = 0
|
||||
for user in inactive_users:
|
||||
# Проверяем участия
|
||||
participations = await session.execute(
|
||||
select(Participation).where(Participation.user_id == user.id)
|
||||
)
|
||||
if participations.scalars().first():
|
||||
continue
|
||||
|
||||
# Проверяем счета
|
||||
accounts = await session.execute(
|
||||
select(Account).where(Account.user_id == user.id)
|
||||
)
|
||||
if accounts.scalars().first():
|
||||
continue
|
||||
|
||||
# Безопасно удаляем
|
||||
await session.delete(user)
|
||||
deleted_count += 1
|
||||
|
||||
if await UserService.delete_user(session, user.id):
|
||||
deleted_count += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -4660,7 +4640,11 @@ async def broadcast_select_channel(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminStates.broadcast_message)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document)
|
||||
BROADCAST_FAILURE_TEXT = "❌ Рассылка прервана. Часть сообщений могла быть доставлена. Проверьте журнал рассылок перед повторной отправкой. Меню доступно: /admin."
|
||||
BROADCAST_BUSY_TEXT = "⏳ Ваша предыдущая рассылка ещё выполняется или заняты оба места отправки. Повторите запуск позже через /admin → Рассылки."
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document | F.animation | F.audio | F.voice | F.sticker)
|
||||
async def admin_broadcast_send(message: Message, state: FSMContext):
|
||||
"""Обработка и отправка рассылки"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
@@ -4669,21 +4653,40 @@ async def admin_broadcast_send(message: Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
broadcast_type = data.get('broadcast_type', 'direct')
|
||||
|
||||
if broadcast_type == 'direct':
|
||||
# Рассылка в ЛС
|
||||
await _broadcast_direct(message, state)
|
||||
else:
|
||||
# Рассылка в канал/группу
|
||||
await _broadcast_channel(message, state, data)
|
||||
from src.utils.broadcast_jobs import start_broadcast
|
||||
await state.clear()
|
||||
|
||||
async def run():
|
||||
if broadcast_type == 'direct':
|
||||
await _broadcast_direct(message)
|
||||
else:
|
||||
await _broadcast_channel(message, data)
|
||||
|
||||
async def report_error():
|
||||
await message.answer(BROADCAST_FAILURE_TEXT)
|
||||
|
||||
if not start_broadcast(message.from_user.id, run, report_error):
|
||||
await message.answer(BROADCAST_BUSY_TEXT)
|
||||
|
||||
|
||||
async def _broadcast_direct(message: Message, state: FSMContext):
|
||||
async def _edit_broadcast_status(status_msg, text, **kwargs):
|
||||
import asyncio
|
||||
import logging
|
||||
try:
|
||||
async with asyncio.timeout(15):
|
||||
await status_msg.edit_text(text, **kwargs)
|
||||
except Exception as error:
|
||||
# A missing status message must not turn a completed delivery into a failed job.
|
||||
logging.getLogger(__name__).warning("Cannot update broadcast report: %s", type(error).__name__)
|
||||
|
||||
|
||||
async def _broadcast_direct(message: Message):
|
||||
"""Рассылка в личные сообщения"""
|
||||
# Отправляем уведомление о начале рассылки
|
||||
status_msg = await message.answer(
|
||||
"📤 <b>Начинаю рассылку в ЛС...</b>\n\n"
|
||||
"⏳ Подождите, это может занять некоторое время.\n"
|
||||
"💡 Используется Redis очередь и отслеживание заблокированных пользователей.",
|
||||
"Меню доступно во время отправки: /admin.\n"
|
||||
"Недоступные получатели будут пропущены; результат появится здесь.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -4703,12 +4706,11 @@ async def _broadcast_direct(message: Message, state: FSMContext):
|
||||
|
||||
# Проверяем, есть ли пользователи для рассылки
|
||||
if not registered_users:
|
||||
await status_msg.edit_text(
|
||||
await _edit_broadcast_status(status_msg,
|
||||
"⚠️ <b>Нет зарегистрированных пользователей</b>\n\n"
|
||||
"Рассылка невозможна, так как нет ни одного зарегистрированного пользователя.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Используем новый сервис рассылок
|
||||
@@ -4723,7 +4725,7 @@ async def _broadcast_direct(message: Message, state: FSMContext):
|
||||
delivery_percent = (stats['success'] / stats['total'] * 100) if stats['total'] > 0 else 0
|
||||
|
||||
# Итоговый отчет
|
||||
await status_msg.edit_text(
|
||||
await _edit_broadcast_status(status_msg,
|
||||
f"✅ <b>Рассылка завершена!</b>\n\n"
|
||||
f"📊 <b>Статистика:</b>\n"
|
||||
f"👥 Всего получателей: {stats['total']}\n"
|
||||
@@ -4734,16 +4736,14 @@ async def _broadcast_direct(message: Message, state: FSMContext):
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def _broadcast_channel(message: Message, state: FSMContext, data: dict):
|
||||
async def _broadcast_channel(message: Message, data: dict):
|
||||
"""Рассылка в канал или группу"""
|
||||
channel_db_id = data.get('channel_db_id')
|
||||
|
||||
if not channel_db_id:
|
||||
await message.answer("❌ Ошибка: не выбран канал")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Получаем информацию о канале и администратора
|
||||
@@ -4764,7 +4764,6 @@ async def _broadcast_channel(message: Message, state: FSMContext, data: dict):
|
||||
|
||||
if not channel:
|
||||
await message.answer("❌ Ошибка: канал не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Отправляем уведомление
|
||||
@@ -4783,20 +4782,19 @@ async def _broadcast_channel(message: Message, state: FSMContext, data: dict):
|
||||
)
|
||||
|
||||
if success:
|
||||
await status_msg.edit_text(
|
||||
await _edit_broadcast_status(status_msg,
|
||||
f"✅ <b>Сообщение отправлено!</b>\n\n"
|
||||
f"📱 {'Канал' if channel.chat_type == 'channel' else 'Группа'}: {channel.title}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await status_msg.edit_text(
|
||||
await _edit_broadcast_status(status_msg,
|
||||
f"❌ <b>Ошибка отправки</b>\n\n"
|
||||
f"Не удалось отправить сообщение в {'канал' if channel.chat_type == 'channel' else 'группу'} {channel.title}\n"
|
||||
f"Проверьте права бота и попробуйте снова.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -5494,255 +5492,4 @@ async def admin_user_unban(callback: CallbackQuery, state: FSMContext):
|
||||
# УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ
|
||||
# =========================
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_manage_admins")
|
||||
async def manage_admins_menu(callback: CallbackQuery):
|
||||
"""Главное меню управления администраторами"""
|
||||
if not is_super_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Только главные администраторы могут управлять правами", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
text = "👑 <b>Управление администраторами</b>\n\n"
|
||||
text += f"Главные администраторы (.env): <code>{len(ADMIN_IDS)}</code>\n\n"
|
||||
text += "Выберите действие:"
|
||||
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="➕ Назначить админа", callback_data="admin_add_admin")],
|
||||
[InlineKeyboardButton(text="➖ Удалить админа", callback_data="admin_remove_admin")],
|
||||
[InlineKeyboardButton(text="📋 Список админов", callback_data="admin_list_admins_view")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_settings")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_list_admins_view")
|
||||
async def list_admins_view(callback: CallbackQuery):
|
||||
"""Показать список всех администраторов"""
|
||||
if not is_super_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
# Получаем всех администраторов (назначенных через БД)
|
||||
result = await session.execute(
|
||||
select(User).where(User.is_admin == True).order_by(User.created_at.desc())
|
||||
)
|
||||
db_admins = result.scalars().all()
|
||||
|
||||
text = "👑 <b>Список администраторов</b>\n\n"
|
||||
|
||||
# Главные администраторы из .env
|
||||
text += "<b>Главные администраторы (из .env):</b>\n"
|
||||
for admin_id in ADMIN_IDS:
|
||||
text += f"🔴 ID: <code>{admin_id}</code>\n"
|
||||
|
||||
text += "\n"
|
||||
|
||||
# Назначенные администраторы
|
||||
if db_admins:
|
||||
text += "<b>Назначенные администраторы:</b>\n"
|
||||
for admin in db_admins:
|
||||
icon = "🟠" # Назначенный админ
|
||||
name = admin.first_name or admin.username or f"@ID_{admin.telegram_id}"
|
||||
text += f"{icon} {name} (ID: <code>{admin.telegram_id}</code>)\n"
|
||||
else:
|
||||
text += "<b>Назначенные администраторы:</b> нет\n"
|
||||
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_manage_admins")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_add_admin")
|
||||
async def add_admin_start(callback: CallbackQuery, state: FSMContext):
|
||||
"""Начать добавление нового администратора"""
|
||||
if not is_super_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
text = "👤 <b>Назначение администратора</b>\n\n"
|
||||
text += "Введите Telegram ID пользователя или его имя для поиска:"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(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):
|
||||
await message.answer("❌ Доступ запрещен")
|
||||
return
|
||||
|
||||
search_query = message.text.strip()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = None
|
||||
|
||||
# Пробуем найти по ID
|
||||
try:
|
||||
telegram_id = int(search_query)
|
||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
except ValueError:
|
||||
# Если не число, ищем по имени или username
|
||||
users = await UserService.search_users(session, search_query, limit=5)
|
||||
if users:
|
||||
user = users[0]
|
||||
|
||||
if not user:
|
||||
await message.answer("❌ Пользователь не найден")
|
||||
await state.set_state(AdminStates.admin_add_search)
|
||||
return
|
||||
|
||||
# Проверяем, не главный ли админ из .env
|
||||
if user.telegram_id in ADMIN_IDS:
|
||||
await message.answer("❌ Это главный администратор (.env). Уже имеет максимальные права")
|
||||
await state.set_state(AdminStates.admin_add_search)
|
||||
return
|
||||
|
||||
# Проверяем, не админ ли уже
|
||||
if user.is_admin:
|
||||
await message.answer("❌ Этот пользователь уже администратор")
|
||||
await state.set_state(AdminStates.admin_add_search)
|
||||
return
|
||||
|
||||
# Сохраняем в state и просим подтверждение
|
||||
await state.update_data(admin_user_id=user.id, admin_telegram_id=user.telegram_id)
|
||||
|
||||
text = "👤 <b>Подтверждение назначения администратора</b>\n\n"
|
||||
text += f"Имя: {user.first_name or 'не указано'}\n"
|
||||
text += f"Username: {user.username or 'нет'}\n"
|
||||
text += f"Telegram ID: <code>{user.telegram_id}</code>\n"
|
||||
text += f"Зарегистрирован: {user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else 'нет'}\n\n"
|
||||
text += "Вы уверены, что хотите дать этому пользователю права администратора?"
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="✅ Да, назначить", callback_data="admin_add_confirm_yes"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")],
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.admin_add_confirm)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_add_confirm_yes")
|
||||
async def confirm_add_admin(callback: CallbackQuery, state: FSMContext):
|
||||
"""Подтвердить назначение админа"""
|
||||
if not is_super_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
admin_telegram_id = data.get('admin_telegram_id')
|
||||
|
||||
async with async_session_maker() as session:
|
||||
success = await UserService.set_admin(session, admin_telegram_id, is_admin=True)
|
||||
|
||||
if success:
|
||||
await callback.answer("✅ Администратор успешно назначен", show_alert=True)
|
||||
await state.clear()
|
||||
await manage_admins_menu(callback)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка при назначении администратора", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_remove_admin")
|
||||
async def remove_admin_start(callback: CallbackQuery, state: FSMContext):
|
||||
"""Начать удаление администратора"""
|
||||
if not is_super_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
# Получаем всех назначенных администраторов
|
||||
result = await session.execute(
|
||||
select(User).where(User.is_admin == True).order_by(User.created_at.desc())
|
||||
)
|
||||
admins = result.scalars().all()
|
||||
|
||||
if not admins:
|
||||
await callback.answer("❌ Нет назначенных администраторов", show_alert=True)
|
||||
return
|
||||
|
||||
text = "🗑️ <b>Выберите администратора для удаления</b>\n\n"
|
||||
|
||||
buttons = []
|
||||
for admin in admins[:20]: # Максимум 20 администраторов на странице
|
||||
name = admin.first_name or admin.username or f"@ID_{admin.telegram_id}"
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=f"🟠 {name}",
|
||||
callback_data=f"admin_remove_select:{admin.telegram_id}"
|
||||
)])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.admin_remove_select)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_remove_select:"))
|
||||
async def confirm_remove_admin(callback: CallbackQuery, state: FSMContext):
|
||||
"""Подтвердить удаление администратора"""
|
||||
if not is_super_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||
return
|
||||
|
||||
admin_telegram_id = int(callback.data.split(":")[1])
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, admin_telegram_id)
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Снять права администратора
|
||||
success = await UserService.set_admin(session, admin_telegram_id, is_admin=False)
|
||||
|
||||
if success:
|
||||
await callback.answer("✅ Права администратора удалены", show_alert=True)
|
||||
await state.clear()
|
||||
await manage_admins_menu(callback)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка при удалении прав", show_alert=True)
|
||||
|
||||
|
||||
# Экспорт роутера
|
||||
__all__ = ['admin_router']
|
||||
__all__ = ["admin_router"]
|
||||
|
||||
Reference in New Issue
Block a user