diff --git a/app/database/crud/ticket.py b/app/database/crud/ticket.py index 6f566cdc..7dd3ef52 100644 --- a/app/database/crud/ticket.py +++ b/app/database/crud/ticket.py @@ -274,6 +274,30 @@ class TicketCRUD: db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow() ) + @staticmethod + async def close_all_open_tickets( + db: AsyncSession, + ) -> List[int]: + """Закрыть все открытые тикеты. Возвращает список идентификаторов закрытых тикетов.""" + open_statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value] + result = await db.execute( + select(Ticket.id).where(Ticket.status.in_(open_statuses)) + ) + ticket_ids = result.scalars().all() + + if not ticket_ids: + return [] + + now = datetime.utcnow() + await db.execute( + update(Ticket) + .where(Ticket.id.in_(ticket_ids)) + .values(status=TicketStatus.CLOSED.value, closed_at=now, updated_at=now) + ) + await db.commit() + + return ticket_ids + @staticmethod async def add_support_audit( db: AsyncSession, diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index c6f2bbe1..fbc9bf13 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -207,6 +207,7 @@ async def show_support_audit( 'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"), 'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"), 'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"), + 'close_all_tickets': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS", "Массовое закрытие тикетов"), 'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"), } action_text = action_map.get(log.action, log.action) @@ -215,6 +216,8 @@ async def show_support_audit( extra = "" if log.action == 'block_user_timed' and 'minutes' in details: extra = f" ({details['minutes']} мин)" + elif log.action == 'close_all_tickets' and 'count' in details: + extra = f" ({details['count']})" lines.append(f"{ts} • {role} {log.actor_telegram_id} — {action_text}{ticket_part}{extra}") # keyboard with pagination diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index 8fbc5407..dfa1224d 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -461,6 +461,81 @@ async def mark_ticket_as_answered( ) +async def close_all_open_admin_tickets( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Закрыть все открытые тикеты.""" + if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): + texts = get_texts(db_user.language) + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + closed_ticket_ids = await TicketCRUD.close_all_open_tickets(db) + except Exception as error: + logger.error("Error closing all open tickets: %s", error) + await callback.answer( + texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."), + show_alert=True + ) + return + + closed_count = len(closed_ticket_ids) + + if closed_count == 0: + await callback.answer( + texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS_EMPTY", "ℹ️ Нет открытых тикетов для закрытия."), + show_alert=True + ) + return + + try: + is_moderator = ( + not settings.is_admin(callback.from_user.id) + and SupportSettingsService.is_moderator(callback.from_user.id) + ) + await TicketCRUD.add_support_audit( + db, + actor_user_id=db_user.id if db_user else None, + actor_telegram_id=callback.from_user.id, + is_moderator=is_moderator, + action="close_all_tickets", + ticket_id=None, + target_user_id=None, + details={ + "count": closed_count, + "ticket_ids": closed_ticket_ids, + } + ) + except Exception as audit_error: + logger.warning("Failed to add support audit for bulk close: %s", audit_error) + + # Обновляем список тикетов + await show_admin_tickets(callback, db_user, db) + + success_text = texts.t( + "ADMIN_CLOSE_ALL_OPEN_TICKETS_SUCCESS", + "✅ Закрыто открытых тикетов: {count}" + ).format(count=closed_count) + + notification_keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + + try: + await callback.message.answer(success_text, reply_markup=notification_keyboard) + except Exception: + # Если не удалось отправить отдельное сообщение, пробуем ответить алертом + try: + await callback.answer(success_text, show_alert=True) + except Exception: + pass + + async def close_admin_ticket( callback: types.CallbackQuery, db_user: User, @@ -935,7 +1010,8 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets") dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_open") dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_closed") - + dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open") + dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_")) # Ответы на тикеты diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 0ef929bd..37e9633b 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2316,6 +2316,12 @@ def get_admin_tickets_keyboard( keyboard.append(switch_row) if open_rows and scope in ("all", "open"): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS", "🔒 Закрыть все открытые"), + callback_data="admin_tickets_close_all_open" + ) + ]) keyboard.append([InlineKeyboardButton(text=texts.t("OPEN_TICKETS_HEADER", "Открытые тикеты"), callback_data="noop")]) keyboard.extend(open_rows) if closed_rows and scope in ("all", "closed"): diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index fea1974f..2e33aa60 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -581,8 +581,12 @@ "ADMIN_SUBSCRIPTIONS_PRICING": "⚙️ Pricing settings", "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑‍⚖️ Assign moderator\n\nSend the user's Telegram ID (number)", "ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit", + "ADMIN_CLOSE_ALL_OPEN_TICKETS": "🔒 Close all open tickets", + "ADMIN_CLOSE_ALL_OPEN_TICKETS_EMPTY": "ℹ️ No open tickets to close.", + "ADMIN_CLOSE_ALL_OPEN_TICKETS_SUCCESS": "✅ Closed open tickets: {count}", "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Permanent block", "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Timed block", + "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS": "Mass close tickets", "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Ticket closed", "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock", "ADMIN_SUPPORT_AUDIT_EMPTY": "Nothing here yet", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 031867ec..5228e566 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -581,8 +581,12 @@ "ADMIN_SUBSCRIPTIONS_PRICING": "⚙️ Настройки цен", "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑‍⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)", "ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов", + "ADMIN_CLOSE_ALL_OPEN_TICKETS": "🔒 Закрыть все открытые", + "ADMIN_CLOSE_ALL_OPEN_TICKETS_EMPTY": "ℹ️ Нет открытых тикетов для закрытия.", + "ADMIN_CLOSE_ALL_OPEN_TICKETS_SUCCESS": "✅ Закрыто открытых тикетов: {count}", "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Блокировка (навсегда)", "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Блокировка (время)", + "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS": "Массовое закрытие тикетов", "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Закрытие тикета", "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока", "ADMIN_SUPPORT_AUDIT_EMPTY": "Пока пусто",