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": "Пока пусто",