diff --git a/app/config.py b/app/config.py index e157f84c..e0075783 100644 --- a/app/config.py +++ b/app/config.py @@ -16,6 +16,11 @@ class Settings(BaseSettings): SUPPORT_MENU_ENABLED: bool = True SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both SUPPORT_MENU_ENABLED: bool = True + # SLA for support tickets + SUPPORT_TICKET_SLA_ENABLED: bool = True + SUPPORT_TICKET_SLA_MINUTES: int = 5 + SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: int = 60 + SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: int = 15 ADMIN_NOTIFICATIONS_ENABLED: bool = False ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None diff --git a/app/database/crud/ticket.py b/app/database/crud/ticket.py index add26178..6f566cdc 100644 --- a/app/database/crud/ticket.py +++ b/app/database/crud/ticket.py @@ -4,7 +4,7 @@ from sqlalchemy import select, desc, and_, or_, update, func from sqlalchemy.orm import selectinload from datetime import datetime -from app.database.models import Ticket, TicketMessage, TicketStatus, User +from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog class TicketCRUD: @@ -87,6 +87,40 @@ class TicketCRUD: result = await db.execute(query) return result.scalars().all() + @staticmethod + async def count_user_tickets_by_statuses( + db: AsyncSession, + user_id: int, + statuses: List[str] + ) -> int: + """Подсчитать количество тикетов пользователя по списку статусов""" + query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user_id) + if statuses: + query = query.where(Ticket.status.in_(statuses)) + result = await db.execute(query) + return int(result.scalar() or 0) + + @staticmethod + async def get_user_tickets_by_statuses( + db: AsyncSession, + user_id: int, + statuses: List[str], + limit: int = 20, + offset: int = 0 + ) -> List[Ticket]: + """Получить тикеты пользователя по списку статусов с пагинацией""" + query = ( + select(Ticket) + .where(Ticket.user_id == user_id) + .order_by(desc(Ticket.updated_at)) + .offset(offset) + .limit(limit) + ) + if statuses: + query = query.where(Ticket.status.in_(statuses)) + result = await db.execute(query) + return result.scalars().all() + @staticmethod async def user_has_active_ticket( db: AsyncSession, @@ -239,6 +273,54 @@ class TicketCRUD: return await TicketCRUD.update_ticket_status( db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow() ) + + @staticmethod + async def add_support_audit( + db: AsyncSession, + *, + actor_user_id: Optional[int], + actor_telegram_id: int, + is_moderator: bool, + action: str, + ticket_id: Optional[int] = None, + target_user_id: Optional[int] = None, + details: Optional[dict] = None, + ) -> None: + try: + log = SupportAuditLog( + actor_user_id=actor_user_id, + actor_telegram_id=actor_telegram_id, + is_moderator=bool(is_moderator), + action=action, + ticket_id=ticket_id, + target_user_id=target_user_id, + details=details or {}, + ) + db.add(log) + await db.commit() + except Exception: + await db.rollback() + # не мешаем основной логике + pass + + @staticmethod + async def list_support_audit( + db: AsyncSession, + *, + limit: int = 50, + offset: int = 0, + ) -> List[SupportAuditLog]: + from sqlalchemy import select, desc + result = await db.execute( + select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit) + ) + return result.scalars().all() + + @staticmethod + async def count_support_audit(db: AsyncSession) -> int: + from sqlalchemy import select, func + result = await db.execute(select(func.count()).select_from(SupportAuditLog)) + return int(result.scalar() or 0) @staticmethod async def get_open_tickets_count(db: AsyncSession) -> int: @@ -291,6 +373,14 @@ class TicketMessageCRUD: else: # Пользователь ответил - тикет открыт ticket.status = TicketStatus.OPEN.value + # Сбросить отметку последнего SLA-напоминания, чтобы снова напоминать от времени нового сообщения + try: + from sqlalchemy import inspect as sa_inspect + # если колонка существует в модели + if hasattr(ticket, 'last_sla_reminder_at'): + ticket.last_sla_reminder_at = None + except Exception: + pass ticket.updated_at = datetime.utcnow() diff --git a/app/database/models.py b/app/database/models.py index 2491eef2..b1e3014d 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -703,6 +703,23 @@ class SubscriptionServer(Base): subscription = relationship("Subscription", backref="subscription_servers") server_squad = relationship("ServerSquad", backref="subscription_servers") + +class SupportAuditLog(Base): + __tablename__ = "support_audit_logs" + + id = Column(Integer, primary_key=True, index=True) + actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + actor_telegram_id = Column(BigInteger, nullable=False) + is_moderator = Column(Boolean, default=False) + action = Column(String(50), nullable=False) # close_ticket, block_user_timed, block_user_perm, unblock_user + ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True) + target_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + details = Column(JSON, nullable=True) + created_at = Column(DateTime, default=func.now()) + + actor = relationship("User", foreign_keys=[actor_user_id]) + ticket = relationship("Ticket", foreign_keys=[ticket_id]) + class UserMessage(Base): __tablename__ = "user_messages" id = Column(Integer, primary_key=True, index=True) @@ -810,6 +827,8 @@ class Ticket(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) closed_at = Column(DateTime, nullable=True) + # SLA reminders + last_sla_reminder_at = Column(DateTime, nullable=True) # Связи user = relationship("User", backref="tickets") diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 2b1136ca..36c88c45 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -815,6 +815,30 @@ async def add_ticket_reply_block_columns(): logger.error(f"Ошибка добавления колонок блокировок в tickets: {e}") return False + +async def add_ticket_sla_columns(): + try: + col_exists = await check_column_exists('tickets', 'last_sla_reminder_at') + if col_exists: + return True + async with engine.begin() as conn: + db_type = await get_database_type() + if db_type == 'sqlite': + alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL" + elif db_type == 'postgresql': + alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at TIMESTAMP NULL" + elif db_type == 'mysql': + alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL" + else: + logger.error(f"Неподдерживаемый тип БД для добавления last_sla_reminder_at: {db_type}") + return False + await conn.execute(text(alter_sql)) + logger.info("✅ Добавлена колонка tickets.last_sla_reminder_at") + return True + except Exception as e: + logger.error(f"Ошибка добавления SLA колонки в tickets: {e}") + return False + async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -1107,6 +1131,79 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением полей блокировок в tickets") + logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ SLA В TICKETS ===") + sla_cols_added = await add_ticket_sla_columns() + if sla_cols_added: + logger.info("✅ Поля SLA в tickets готовы") + else: + logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets") + + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") + try: + async with engine.begin() as conn: + db_type = await get_database_type() + if not await check_table_exists('support_audit_logs'): + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE support_audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER NULL, + actor_telegram_id BIGINT NOT NULL, + is_moderator BOOLEAN NOT NULL DEFAULT 0, + action VARCHAR(50) NOT NULL, + ticket_id INTEGER NULL, + target_user_id INTEGER NULL, + details JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (actor_user_id) REFERENCES users(id), + FOREIGN KEY (ticket_id) REFERENCES tickets(id), + FOREIGN KEY (target_user_id) REFERENCES users(id) + ); + CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id); + CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id); + CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action); + """ + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE support_audit_logs ( + id SERIAL PRIMARY KEY, + actor_user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, + actor_telegram_id BIGINT NOT NULL, + is_moderator BOOLEAN NOT NULL DEFAULT FALSE, + action VARCHAR(50) NOT NULL, + ticket_id INTEGER NULL REFERENCES tickets(id) ON DELETE SET NULL, + target_user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, + details JSON NULL, + created_at TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id); + CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id); + CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action); + """ + else: + create_sql = """ + CREATE TABLE support_audit_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + actor_user_id INT NULL, + actor_telegram_id BIGINT NOT NULL, + is_moderator BOOLEAN NOT NULL DEFAULT 0, + action VARCHAR(50) NOT NULL, + ticket_id INT NULL, + target_user_id INT NULL, + details JSON NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id); + CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id); + CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action); + """ + await conn.execute(text(create_sql)) + logger.info("✅ Таблица support_audit_logs создана") + else: + logger.info("ℹ️ Таблица support_audit_logs уже существует") + except Exception as e: + logger.warning(f"⚠️ Проблемы с созданием таблицы support_audit_logs: {e}") + logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===") promo_groups_ready = await ensure_promo_groups_setup() if promo_groups_ready: diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index dfce24d7..a26fd7b3 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -10,14 +10,18 @@ from app.keyboards.admin import ( get_admin_users_submenu_keyboard, get_admin_promo_submenu_keyboard, get_admin_communications_submenu_keyboard, + get_admin_support_submenu_keyboard, get_admin_settings_submenu_keyboard, get_admin_system_submenu_keyboard ) from app.localization.texts import get_texts from app.handlers.admin import support_settings as support_settings_handlers from app.utils.decorators import admin_required, error_handler +from app.services.support_settings_service import SupportSettingsService from app.database.crud.rules import clear_all_rules, get_rules_statistics from app.localization.texts import clear_rules_cache +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from app.database.crud.ticket import TicketCRUD logger = logging.getLogger(__name__) @@ -113,6 +117,115 @@ async def show_communications_submenu( await callback.answer() +@admin_required +@error_handler +async def show_support_submenu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + texts = get_texts(db_user.language) + # Moderators have access only to tickets and not to settings + is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + + from app.keyboards.admin import get_admin_support_submenu_keyboard + kb = get_admin_support_submenu_keyboard(db_user.language) + if is_moderator_only: + # Rebuild keyboard to include only tickets and back to main menu + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")] + ]) + await callback.message.edit_text( + "🛟 **Поддержка**\n\n" + ("Доступ к тикетам." if is_moderator_only else "Управление тикетами и настройками поддержки:"), + reply_markup=kb, + parse_mode="Markdown" + ) + await callback.answer() + + +# Moderator panel entry (from main menu quick button) +async def show_moderator_panel( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")], + [InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")] + ]) + await callback.message.edit_text( + "🧑‍⚖️ Модерация поддержки\n\nДоступ к тикетам поддержки.", + parse_mode="HTML", + reply_markup=kb + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_support_audit( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + # pagination + page = 1 + if callback.data.startswith("admin_support_audit_page_"): + try: + page = int(callback.data.split("_")[-1]) + except Exception: + page = 1 + per_page = 10 + total = await TicketCRUD.count_support_audit(db) + total_pages = max(1, (total + per_page - 1) // per_page) + if page < 1: + page = 1 + if page > total_pages: + page = total_pages + offset = (page - 1) * per_page + logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset) + + lines = ["🧾 Аудит модераторов", ""] + if not logs: + lines.append("Пока пусто") + else: + for log in logs: + role = "Модератор" if getattr(log, 'is_moderator', False) else "Админ" + ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else '' + action_map = { + 'close_ticket': 'Закрытие тикета', + 'block_user_timed': 'Блокировка (время)', + 'block_user_perm': 'Блокировка (навсегда)', + 'unblock_user': 'Снятие блока', + } + action_text = action_map.get(log.action, log.action) + ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else "" + details = log.details or {} + extra = "" + if log.action == 'block_user_timed' and 'minutes' in details: + extra = f" ({details['minutes']} мин)" + lines.append(f"{ts} • {role} {log.actor_telegram_id} — {action_text}{ticket_part}{extra}") + + # keyboard with pagination + nav_row = [] + if total_pages > 1: + if page > 1: + nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"admin_support_audit_page_{page-1}")) + nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")) + if page < total_pages: + nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"admin_support_audit_page_{page+1}")) + + kb_rows = [] + if nav_row: + kb_rows.append(nav_row) + kb_rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_support")]) + kb = InlineKeyboardMarkup(inline_keyboard=kb_rows) + + await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb) + await callback.answer() + + @admin_required @error_handler async def show_settings_submenu( @@ -285,6 +398,15 @@ def register_handlers(dp: Dispatcher): F.data == "admin_submenu_communications" ) + dp.callback_query.register( + show_support_submenu, + F.data == "admin_submenu_support" + ) + dp.callback_query.register( + show_support_audit, + F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_") + ) + dp.callback_query.register( show_settings_submenu, F.data == "admin_submenu_settings" @@ -294,6 +416,10 @@ def register_handlers(dp: Dispatcher): show_system_submenu, F.data == "admin_submenu_system" ) + dp.callback_query.register( + show_moderator_panel, + F.data == "moderator_panel" + ) # Support settings module support_settings_handlers.register_handlers(dp) diff --git a/app/handlers/admin/support_settings.py b/app/handlers/admin/support_settings.py index eb93e841..adbf3ea7 100644 --- a/app/handlers/admin/support_settings.py +++ b/app/handlers/admin/support_settings.py @@ -7,6 +7,7 @@ from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User +from app.config import settings from app.localization.texts import get_texts from app.utils.decorators import admin_required, error_handler from app.services.support_settings_service import SupportSettingsService @@ -20,6 +21,10 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: texts = get_texts(language) mode = SupportSettingsService.get_system_mode() menu_enabled = SupportSettingsService.is_support_menu_enabled() + admin_notif = SupportSettingsService.get_admin_ticket_notifications_enabled() + user_notif = SupportSettingsService.get_user_ticket_notifications_enabled() + sla_enabled = SupportSettingsService.get_sla_enabled() + sla_minutes = SupportSettingsService.get_sla_minutes() rows: list[list[types.InlineKeyboardButton]] = [] @@ -40,8 +45,53 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc") ]) + # Notifications block rows.append([ - types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") + types.InlineKeyboardButton( + text=("🔔 Админ-уведомления: Включены" if admin_notif else "🔕 Админ-уведомления: Отключены"), + callback_data="admin_support_toggle_admin_notifications" + ) + ]) + rows.append([ + types.InlineKeyboardButton( + text=("🔔 Пользовательские уведомления: Включены" if user_notif else "🔕 Пользовательские уведомления: Отключены"), + callback_data="admin_support_toggle_user_notifications" + ) + ]) + + # SLA block + rows.append([ + types.InlineKeyboardButton( + text=("⏰ SLA: Включено" if sla_enabled else "⏹️ SLA: Отключено"), + callback_data="admin_support_toggle_sla" + ) + ]) + rows.append([ + types.InlineKeyboardButton( + text=f"⏳ Время SLA: {sla_minutes} мин", + callback_data="admin_support_set_sla_minutes" + ) + ]) + + # Moderators + moderators = SupportSettingsService.get_moderators() + mod_count = len(moderators) + rows.append([ + types.InlineKeyboardButton( + text=f"🧑‍⚖️ Модераторы: {mod_count}", callback_data="admin_support_list_moderators" + ) + ]) + rows.append([ + types.InlineKeyboardButton( + text="➕ Назначить модератора", callback_data="admin_support_add_moderator" + ), + types.InlineKeyboardButton( + text="➖ Удалить модератора", callback_data="admin_support_remove_moderator" + ) + ]) + + rows.append([ + types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support") ]) return types.InlineKeyboardMarkup(inline_keyboard=rows) @@ -78,6 +128,175 @@ async def toggle_support_menu( await show_support_settings(callback, db_user, db) +@admin_required +@error_handler +async def toggle_admin_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + current = SupportSettingsService.get_admin_ticket_notifications_enabled() + SupportSettingsService.set_admin_ticket_notifications_enabled(not current) + await show_support_settings(callback, db_user, db) + + +@admin_required +@error_handler +async def toggle_user_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + current = SupportSettingsService.get_user_ticket_notifications_enabled() + SupportSettingsService.set_user_ticket_notifications_enabled(not current) + await show_support_settings(callback, db_user, db) + + +@admin_required +@error_handler +async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + current = SupportSettingsService.get_sla_enabled() + SupportSettingsService.set_sla_enabled(not current) + await show_support_settings(callback, db_user, db) + + +from app.states import SupportSettingsStates + +@admin_required +@error_handler +async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportSettingsStates.waiting_for_desc) # temporary reuse replaced below + # we'll manage separate state below + + +from aiogram.fsm.state import State, StatesGroup + +class SupportAdvancedStates(StatesGroup): + waiting_for_sla_minutes = State() + waiting_for_moderator_id = State() + + +@admin_required +@error_handler +async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes) + await callback.answer() + + +@admin_required +@error_handler +async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + text = (message.text or "").strip() + try: + minutes = int(text) + if minutes <= 0 or minutes > 1440: + raise ValueError() + except Exception: + await message.answer("❌ Введите корректное число минут (1-1440)") + return + SupportSettingsService.set_sla_minutes(minutes) + await state.clear() + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + await message.answer("✅ Значение SLA сохранено", reply_markup=markup) + + +@admin_required +@error_handler +async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "🧑‍⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportAdvancedStates.waiting_for_moderator_id) + await callback.answer() + + +@admin_required +@error_handler +async def handle_add_moderator(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + text = (message.text or "").strip() + try: + tid = int(text) + except Exception: + await message.answer("❌ Введите корректный Telegram ID (число)") + return + if SupportSettingsService.add_moderator(tid): + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + await message.answer(f"✅ Пользователь {tid} назначен модератором", reply_markup=markup) + else: + await message.answer("❌ Не удалось сохранить") + await state.clear() + + +@admin_required +@error_handler +async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "🧑‍⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportAdvancedStates.waiting_for_moderator_id) + # We'll reuse the same state; next message will decide action via flag + await state.update_data(action="remove_moderator") + await callback.answer() + + +@admin_required +@error_handler +async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + data = await state.get_data() + action = data.get("action", "add") + text = (message.text or "").strip() + try: + tid = int(text) + except Exception: + await message.answer("❌ Введите корректный Telegram ID (число)") + return + ok = False + if action == "remove_moderator": + ok = SupportSettingsService.remove_moderator(tid) + msg = "✅ Модератор удалён" if ok else "❌ Не удалось удалить" + else: + ok = SupportSettingsService.add_moderator(tid) + msg = "✅ Пользователь назначен модератором" if ok else "❌ Не удалось назначить" + await state.clear() + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + await message.answer(msg, reply_markup=markup) + + +@admin_required +@error_handler +async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + moderators = SupportSettingsService.get_moderators() + if not moderators: + await callback.answer("Список пуст", show_alert=True) + return + text = "🧑‍⚖️ Модераторы\n\n" + "\n".join([f"• {tid}" for tid in moderators]) + markup = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup) + await callback.answer() + + @admin_required @error_handler async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession): @@ -177,9 +396,17 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async await callback.answer("Текст отправлен ниже") -@admin_required @error_handler async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + # Allow admins and moderators to delete informational notifications + try: + may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)) + except Exception: + may_delete = False + if not may_delete: + texts = get_texts(db_user.language if db_user else 'ru') + await callback.answer(texts.ACCESS_DENIED, show_alert=True) + return try: await callback.message.delete() finally: @@ -196,6 +423,15 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc") dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc") dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg") + dp.callback_query.register(toggle_admin_notifications, F.data == "admin_support_toggle_admin_notifications") + dp.callback_query.register(toggle_user_notifications, F.data == "admin_support_toggle_user_notifications") + dp.callback_query.register(toggle_sla, F.data == "admin_support_toggle_sla") + dp.callback_query.register(start_set_sla_minutes, F.data == "admin_support_set_sla_minutes") + dp.callback_query.register(start_add_moderator, F.data == "admin_support_add_moderator") + dp.callback_query.register(start_remove_moderator, F.data == "admin_support_remove_moderator") + dp.callback_query.register(list_moderators, F.data == "admin_support_list_moderators") dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc) + dp.message.register(handle_sla_minutes, SupportAdvancedStates.waiting_for_sla_minutes) + dp.message.register(handle_moderator_id, SupportAdvancedStates.waiting_for_moderator_id) diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index c943a5db..dcaab6d2 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -1,6 +1,7 @@ import logging -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from aiogram import Dispatcher, types, F, Bot +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, and_ @@ -18,6 +19,7 @@ from app.keyboards.inline import ( from app.localization.texts import get_texts from app.utils.pagination import paginate_list, get_pagination_info from app.services.admin_notification_service import AdminNotificationService +from app.services.support_settings_service import SupportSettingsService from app.config import settings from app.utils.cache import RateLimitCache @@ -33,6 +35,11 @@ async def show_admin_tickets( db: AsyncSession ): """Показать все тикеты для админов""" + # permission gate: admin or active moderator only + 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) # Определяем текущую страницу и scope @@ -59,6 +66,8 @@ async def show_admin_tickets( # total count for proper pagination total_count = await TicketCRUD.count_tickets_by_statuses(db, statuses) total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count > 0 else 1 + if current_page < 1: + current_page = 1 if current_page > total_pages: current_page = total_pages offset = (current_page - 1) * page_size @@ -81,9 +90,33 @@ async def show_admin_tickets( }) # Итоговые страницы уже посчитаны выше - await callback.message.edit_text( - texts.t("ADMIN_TICKETS_TITLE", "🎫 Все тикеты поддержки:"), - reply_markup=get_admin_tickets_keyboard(ticket_data, current_page=current_page, total_pages=total_pages, language=db_user.language, scope=scope) + header_text = ( + texts.t("ADMIN_TICKETS_TITLE_OPEN", "🎫 Открытые тикеты поддержки:") + if scope == "open" + else texts.t("ADMIN_TICKETS_TITLE_CLOSED", "🎫 Закрытые тикеты поддержки:") + ) + # Determine proper back target for moderators + back_cb = "admin_submenu_support" + try: + if not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id): + back_cb = "moderator_panel" + except Exception: + pass + + keyboard = get_admin_tickets_keyboard( + ticket_data, + current_page=current_page, + total_pages=total_pages, + language=db_user.language, + scope=scope, + back_callback=back_cb, + ) + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=header_text, + keyboard=keyboard, + parse_mode="HTML", ) await callback.answer() @@ -92,10 +125,29 @@ async def view_admin_ticket( callback: types.CallbackQuery, db_user: User, db: AsyncSession, - state: FSMContext + state: Optional[FSMContext] = None ): """Показать детали тикета для админа""" - ticket_id = int(callback.data.replace("admin_view_ticket_", "")) + 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 + data_str = callback.data or "" + ticket_id = None + try: + if data_str.startswith("admin_view_ticket_"): + ticket_id = int(data_str.replace("admin_view_ticket_", "")) + else: + ticket_id = int(data_str.split("_")[-1]) + except Exception: + ticket_id = None + if ticket_id is None: + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TICKET_NOT_FOUND", "Тикет не найден."), + show_alert=True + ) + return ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True) @@ -145,9 +197,10 @@ async def view_admin_ticket( # Добавим кнопку "Вложения", если есть фото has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or []) keyboard = get_admin_ticket_view_keyboard( - ticket_id, - ticket.is_closed, - db_user.language + ticket_id, + ticket.is_closed, + db_user.language, + is_user_blocked=ticket.is_user_reply_blocked ) if has_photos: try: @@ -155,23 +208,20 @@ async def view_admin_ticket( except Exception: pass - # Сначала пробуем отредактировать; если не вышло — удалим и отправим новое - try: - await callback.message.edit_text( - ticket_text, - reply_markup=keyboard, - ) - except Exception: + # Рендер через фото-утилиту (с логотипом), внутри есть фоллбеки на текст + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=ticket_text, + keyboard=keyboard, + parse_mode="HTML", + ) + # сохраняем id для дальнейших действий (ответ/статусы) + if state is not None: try: - await callback.message.delete() + await state.update_data(ticket_id=ticket_id) except Exception: pass - await callback.message.answer( - ticket_text, - reply_markup=keyboard, - ) - # сохраняем id для дальнейших действий (ответ/статусы) - await state.update_data(ticket_id=ticket_id) await callback.answer() @@ -181,6 +231,10 @@ async def reply_to_admin_ticket( db_user: User ): """Начать ответ на тикет от админа""" + 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 ticket_id = int(callback.data.replace("admin_reply_ticket_", "")) await state.update_data(ticket_id=ticket_id, reply_mode=True) @@ -200,6 +254,11 @@ async def handle_admin_ticket_reply( db_user: User, db: AsyncSession ): + if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)): + texts = get_texts(db_user.language) + await message.answer(texts.ACCESS_DENIED) + await state.clear() + return # Проверяем, что пользователь в правильном состоянии current_state = await state.get_state() if current_state != AdminTicketStates.waiting_for_reply: @@ -373,17 +432,42 @@ async def close_admin_ticket( 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 ticket_id = int(callback.data.replace("admin_close_ticket_", "")) try: success = await TicketCRUD.close_ticket(db, ticket_id) if success: + # audit + try: + is_mod = (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_mod, + action="close_ticket", + ticket_id=ticket_id, + target_user_id=None, + details={} + ) + except Exception: + pass texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), - show_alert=True - ) + # Notify with deletable inline message + try: + await callback.message.answer( + texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + ) + except Exception: + await callback.answer(texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), show_alert=True) # Обновляем inline-клавиатуру в текущем сообщении без кнопок действий await callback.message.edit_reply_markup( @@ -411,6 +495,10 @@ async def cancel_admin_ticket_reply( db_user: User ): """Отменить ответ админа на тикет""" + 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 await state.clear() texts = get_texts(db_user.language) @@ -433,13 +521,22 @@ async def block_user_in_ticket( 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 ticket_id = int(callback.data.replace("admin_block_user_ticket_", "")) texts = get_texts(db_user.language) + # Save original ticket message ids to update it after blocking without reopening + try: + await state.update_data(origin_chat_id=callback.message.chat.id, origin_message_id=callback.message.message_id) + except Exception: + pass await callback.message.edit_text( texts.t("ENTER_BLOCK_MINUTES", "Введите количество минут для блокировки пользователя (например, 15):"), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton( - text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"), + text=texts.t("CANCEL_REPLY", "❌ Отменить ввод"), callback_data="cancel_admin_ticket_reply" )] ]) @@ -455,6 +552,12 @@ async def handle_admin_block_duration_input( db_user: User, db: AsyncSession ): + # permission gate for message flow + if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)): + texts = get_texts(db_user.language) + await message.answer(texts.ACCESS_DENIED) + await state.clear() + return # Проверяем состояние current_state = await state.get_state() if current_state != AdminTicketStates.waiting_for_block_duration: @@ -467,6 +570,8 @@ async def handle_admin_block_duration_input( data = await state.get_data() ticket_id = data.get("ticket_id") + origin_chat_id = data.get("origin_chat_id") + origin_message_id = data.get("origin_message_id") try: minutes = int(reply_text) minutes = max(1, min(60*24*365, minutes)) # максимум 1 год @@ -490,15 +595,76 @@ async def handle_admin_block_duration_input( until = datetime.utcnow() + timedelta(minutes=minutes) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until) - if ok: - await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") - else: + if not ok: await message.answer("❌ Ошибка блокировки") - await state.clear() - await message.answer( - "✅ Блокировка установлена. Откройте тикет заново для обновления состояния.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text="👁️ Посмотреть тикет", callback_data=f"admin_view_ticket_{ticket_id}")]]) - ) + return + # audit + try: + is_mod = (not settings.is_admin(message.from_user.id) and SupportSettingsService.is_moderator(message.from_user.id)) + await TicketCRUD.add_support_audit( + db, + actor_user_id=db_user.id if db_user else None, + actor_telegram_id=message.from_user.id, + is_moderator=is_mod, + action="block_user_timed", + ticket_id=ticket_id, + target_user_id=ticket.user_id if ticket else None, + details={"minutes": minutes} + ) + except Exception: + pass + # Refresh original ticket card (caption/text and buttons) in place + try: + updated = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True) + texts = get_texts(db_user.language) + status_text = { + TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"), + TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"), + TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"), + TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании") + }.get(updated.status, updated.status) + user_name = updated.user.full_name if updated.user else "Unknown" + ticket_text = f"🎫 Тикет #{updated.id}\n\n" + ticket_text += f"👤 Пользователь: {user_name}\n" + ticket_text += f"📝 Заголовок: {updated.title}\n" + ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n" + ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n" + ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n" + if updated.is_user_reply_blocked: + if updated.user_reply_block_permanent: + ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n" + elif updated.user_reply_block_until: + ticket_text += f"⏳ Блок до: {updated.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n" + if updated.messages: + ticket_text += f"💬 Сообщения ({len(updated.messages)}):\n\n" + for msg in updated.messages: + sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка" + ticket_text += f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n" + ticket_text += f"{msg.message_text}\n\n" + if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo": + ticket_text += "📎 Вложение: фото\n\n" + + kb = get_admin_ticket_view_keyboard(updated.id, updated.is_closed, db_user.language, is_user_blocked=updated.is_user_reply_blocked) + has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in updated.messages or []) + if has_photos: + try: + kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{updated.id}")]) + except Exception: + pass + if origin_chat_id and origin_message_id: + try: + await message.bot.edit_message_caption(chat_id=origin_chat_id, message_id=origin_message_id, caption=ticket_text, reply_markup=kb, parse_mode="HTML") + except Exception: + try: + await message.bot.edit_message_text(chat_id=origin_chat_id, message_id=origin_message_id, text=ticket_text, reply_markup=kb, parse_mode="HTML") + except Exception: + await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + else: + await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + except Exception: + await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + finally: + await state.clear() except Exception as e: logger.error(f"Error setting block duration: {e}") texts = get_texts(db_user.language) @@ -515,11 +681,39 @@ async def unblock_user_in_ticket( 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 ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", "")) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=None) if ok: - await callback.answer("✅ Блок снят") - await view_admin_ticket(callback, db_user, db, FSMContext(callback.bot, callback.from_user.id)) + try: + await callback.message.answer( + "✅ Блок снят", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + ) + except Exception: + await callback.answer("✅ Блок снят") + # audit + try: + is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", "")) + 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_mod, + action="unblock_user", + ticket_id=ticket_id, + target_user_id=None, + details={} + ) + except Exception: + pass + await view_admin_ticket(callback, db_user, db) else: await callback.answer("❌ Ошибка", show_alert=True) @@ -529,11 +723,38 @@ async def block_user_permanently( 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 ticket_id = int(callback.data.replace("admin_block_user_perm_ticket_", "")) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=True, until=None) if ok: - await callback.answer("✅ Пользователь заблокирован навсегда") - await view_admin_ticket(callback, db_user, db, FSMContext(callback.bot, callback.from_user.id)) + try: + await callback.message.answer( + "✅ Пользователь заблокирован навсегда", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + ) + ) + except Exception: + await callback.answer("✅ Пользователь заблокирован") + # audit + try: + is_mod = (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_mod, + action="block_user_perm", + ticket_id=ticket_id, + target_user_id=None, + details={} + ) + except Exception: + pass + await view_admin_ticket(callback, db_user, db) else: await callback.answer("❌ Ошибка", show_alert=True) @@ -541,6 +762,12 @@ async def block_user_permanently( async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: str, db: AsyncSession): """Уведомить пользователя о новом ответе в тикете""" try: + # Respect runtime toggle for user ticket notifications + try: + if not SupportSettingsService.get_user_ticket_notifications_enabled(): + return + except Exception: + pass from app.localization.texts import get_texts # Получаем тикет с пользователем @@ -638,6 +865,11 @@ def register_handlers(dp: Dispatcher): db_user: User, db: AsyncSession ): + # permission gate for attachments view + 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: ticket_id = int(callback.data.replace("admin_ticket_attachments_", "")) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 9822a144..96f9a8db 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -16,6 +16,7 @@ from app.services.subscription_checkout_service import ( should_offer_checkout_resume, ) from app.utils.photo_message import edit_or_answer_photo +from app.services.support_settings_service import SupportSettingsService logger = logging.getLogger(__name__) @@ -48,6 +49,7 @@ async def show_main_menu( keyboard=get_main_menu_keyboard( language=db_user.language, is_admin=settings.is_admin(db_user.telegram_id), + is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)), has_had_paid_subscription=db_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, @@ -120,6 +122,7 @@ async def handle_back_to_menu( keyboard=get_main_menu_keyboard( language=db_user.language, is_admin=settings.is_admin(db_user.telegram_id), + is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)), has_had_paid_subscription=db_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py index 2afc72e0..b421f426 100644 --- a/app/handlers/tickets.py +++ b/app/handlers/tickets.py @@ -374,12 +374,17 @@ async def show_my_tickets( except ValueError: current_page = 1 - # Получаем тикеты пользователя (открытые/закрытые отдельно) - all_tickets = await TicketCRUD.get_user_tickets(db, db_user.id, limit=100) - open_tickets = [t for t in all_tickets if t.status != TicketStatus.CLOSED.value] - closed_tickets = [t for t in all_tickets if t.status == TicketStatus.CLOSED.value] - - if not open_tickets and not closed_tickets: + # Пагинация открытых тикетов из БД + per_page = 10 + total_open = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value]) + total_pages = max(1, (total_open + per_page - 1) // per_page) + current_page = max(1, min(current_page, total_pages)) + offset = (current_page - 1) * per_page + open_tickets = await TicketCRUD.get_user_tickets_by_statuses(db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value], limit=per_page, offset=offset) + + # Проверка на отсутствие тикетов совсем (ни открытых, ни закрытых) + has_closed_any = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value]) > 0 + if not open_tickets and not has_closed_any: await callback.message.edit_text( texts.t("NO_TICKETS", "У вас пока нет тикетов."), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ @@ -400,32 +405,19 @@ async def show_my_tickets( await callback.answer() return - # Открытые с пагинацией - open_data = [] - for t in open_tickets: - if t.status != TicketStatus.CLOSED.value: - open_data.append({'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji}) - per_page = 10 - pag = get_pagination_info(total_count=len(open_data), page=current_page, per_page=per_page) - # Корректируем текущую страницу в допустимые границы - current_page = max(1, min(current_page, pag["total_pages"])) - start_index = (current_page - 1) * per_page - end_index = start_index + per_page - page_items = open_data[start_index:end_index] - keyboard = get_my_tickets_keyboard(page_items, current_page=current_page, total_pages=pag["total_pages"], language=db_user.language) + # Открытые с пагинацией (DB) + open_data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in open_tickets] + keyboard = get_my_tickets_keyboard(open_data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_page_") # Добавим кнопку перехода к закрытым keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), callback_data="my_tickets_closed")]) - # Покажем список тикетов c логотипом, если режим включен - if settings.ENABLE_LOGO_MODE and callback.message.photo: - from app.utils.photo_message import edit_or_answer_photo - await edit_or_answer_photo( - callback=callback, - caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), - keyboard=keyboard, - parse_mode="HTML", - ) - else: - await callback.message.edit_text(texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), reply_markup=keyboard) + # Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости) + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), + keyboard=keyboard, + parse_mode="HTML", + ) await callback.answer() @@ -435,9 +427,18 @@ async def show_my_tickets_closed( db: AsyncSession ): texts = get_texts(db_user.language) - # Пагинация (при необходимости можно добавить аналогично open) - tickets = await TicketCRUD.get_user_tickets(db, db_user.id, status=TicketStatus.CLOSED.value, limit=10) - if not tickets: + # Пагинация закрытых + current_page = 1 + data_str = callback.data + if data_str.startswith("my_tickets_closed_page_"): + try: + current_page = int(data_str.replace("my_tickets_closed_page_", "")) + except ValueError: + current_page = 1 + + per_page = 10 + total_closed = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value]) + if total_closed == 0: await callback.message.edit_text( texts.t("NO_CLOSED_TICKETS", "Закрытых тикетов пока нет."), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ @@ -447,19 +448,20 @@ async def show_my_tickets_closed( ) await callback.answer() return + total_pages = max(1, (total_closed + per_page - 1) // per_page) + current_page = max(1, min(current_page, total_pages)) + offset = (current_page - 1) * per_page + tickets = await TicketCRUD.get_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value], limit=per_page, offset=offset) data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets] - kb = get_my_tickets_keyboard(data, current_page=1, language=db_user.language) + kb = get_my_tickets_keyboard(data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_closed_page_") kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")]) - if settings.ENABLE_LOGO_MODE and callback.message.photo: - from app.utils.photo_message import edit_or_answer_photo - await edit_or_answer_photo( - callback=callback, - caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), - keyboard=kb, - parse_mode="HTML", - ) - else: - await callback.message.edit_text(texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), reply_markup=kb) + from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( + callback=callback, + caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), + keyboard=kb, + parse_mode="HTML", + ) await callback.answer() @@ -758,7 +760,10 @@ async def handle_ticket_reply( if ticket.status == TicketStatus.CLOSED.value: texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_CLOSED", "✅ Тикет закрыт.") + texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]] + ) ) await state.clear() return @@ -767,7 +772,10 @@ async def handle_ticket_reply( if ticket.status == TicketStatus.CLOSED.value or ticket.is_user_reply_blocked: texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_CLOSED_NO_REPLY", "❌ Тикет закрыт, ответить невозможно.") + texts.t("TICKET_CLOSED_NO_REPLY", "❌ Тикет закрыт, ответить невозможно."), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]] + ) ) await state.clear() return @@ -981,6 +989,10 @@ def register_handlers(dp: Dispatcher): show_my_tickets_closed, F.data == "my_tickets_closed" ) + dp.callback_query.register( + show_my_tickets_closed, + F.data.startswith("my_tickets_closed_page_") + ) dp.callback_query.register( view_ticket, diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index c7a3a480..4137a918 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -10,6 +10,7 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")], [InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")], + [InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")], [InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")], [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")], [InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")], @@ -61,15 +62,28 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], + [ + InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"), + InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + ] + ]) + + +def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets") ], [ - InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings") + InlineKeyboardButton(text="🧾 Аудит модераторов", callback_data="admin_support_audit") ], [ - InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"), - InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel") + InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings") ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 033884f4..27400acb 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -67,6 +67,8 @@ def get_main_menu_keyboard( balance_kopeks: int = 0, subscription=None, show_resume_checkout: bool = False, + *, + is_moderator: bool = False, ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -198,6 +200,11 @@ def get_main_menu_keyboard( else: if settings.DEBUG: print("DEBUG KEYBOARD: Админ кнопка НЕ добавлена") + # Moderator access (limited support panel) + if (not is_admin) and is_moderator: + keyboard.append([ + InlineKeyboardButton(text="🧑‍⚖️ Модерация", callback_data="moderator_panel") + ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -1535,7 +1542,8 @@ def get_my_tickets_keyboard( tickets: List[dict], current_page: int = 1, total_pages: int = 1, - language: str = DEFAULT_LANGUAGE + language: str = DEFAULT_LANGUAGE, + page_prefix: str = "my_tickets_page_" ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -1563,7 +1571,7 @@ def get_my_tickets_keyboard( nav_row.append( InlineKeyboardButton( text=texts.t("PAGINATION_PREV", "⬅️"), - callback_data=f"my_tickets_page_{current_page - 1}" + callback_data=f"{page_prefix}{current_page - 1}" ) ) @@ -1578,7 +1586,7 @@ def get_my_tickets_keyboard( nav_row.append( InlineKeyboardButton( text=texts.t("PAGINATION_NEXT", "➡️"), - callback_data=f"my_tickets_page_{current_page + 1}" + callback_data=f"{page_prefix}{current_page + 1}" ) ) @@ -1641,7 +1649,9 @@ def get_admin_tickets_keyboard( current_page: int = 1, total_pages: int = 1, language: str = DEFAULT_LANGUAGE, - scope: str = "all" + scope: str = "all", + *, + back_callback: str = "admin_submenu_support" ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -1706,7 +1716,7 @@ def get_admin_tickets_keyboard( keyboard.append(nav_row) keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") + InlineKeyboardButton(text=texts.BACK, callback_data=back_callback) ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -1715,7 +1725,9 @@ def get_admin_tickets_keyboard( def get_admin_ticket_view_keyboard( ticket_id: int, is_closed: bool = False, - language: str = DEFAULT_LANGUAGE + language: str = DEFAULT_LANGUAGE, + *, + is_user_blocked: bool = False ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -1736,14 +1748,16 @@ def get_admin_ticket_view_keyboard( ) ]) - # Block controls: first row Unblock + Block forever, second row Block by time - keyboard.append([ - InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}"), - InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Блок навсегда"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}") - ]) - keyboard.append([ - InlineKeyboardButton(text=texts.t("BLOCK_BY_TIME", "⏳ Блокировка по времени"), callback_data=f"admin_block_user_ticket_{ticket_id}") - ]) + # Блок-контролы: когда не заблокирован — показать два варианта, когда заблокирован — только "Разблокировать" + if is_user_blocked: + keyboard.append([ + InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}") + ]) + else: + keyboard.append([ + InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Заблокировать"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}"), + InlineKeyboardButton(text=texts.t("BLOCK_BY_TIME", "⏳ Блок по времени"), callback_data=f"admin_block_user_ticket_{ticket_id}") + ]) keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="admin_tickets") diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 6995e761..bd81c8fa 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -401,4 +401,5 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." + } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 0fbb106e..ac0e159f 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -55,6 +55,8 @@ "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", "ADMIN_SUBSCRIPTIONS": "📱 Подписки", "ADMIN_USERS": "👥 Пользователи", + "ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:", + "ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:", "AUTOPAY_BUTTON": "💳 Автоплатёж", "AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!", "AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически", @@ -401,4 +403,5 @@ "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." + } diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 24deb059..040a2d27 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -818,7 +818,13 @@ class AdminNotificationService: """Публичный метод для отправки уведомлений по тикетам в админ-топик. Учитывает настройки включенности в settings. """ - if not self._is_enabled(): + # Respect runtime toggle for admin ticket notifications + try: + from app.services.support_settings_service import SupportSettingsService + runtime_enabled = SupportSettingsService.get_admin_ticket_notifications_enabled() + except Exception: + runtime_enabled = True + if not (self._is_enabled() and runtime_enabled): return False return await self._send_message(text, reply_markup=keyboard, ticket_event=True) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 2d0b96c7..a190aec4 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -3,7 +3,7 @@ import logging from datetime import datetime, timedelta from typing import Dict, List, Any, Optional, Set from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, or_ from sqlalchemy.orm import selectinload from app.config import settings @@ -21,7 +21,7 @@ from app.database.crud.notification import ( notification_sent, record_notification, ) -from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User +from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService from app.localization.texts import get_texts @@ -42,6 +42,7 @@ class MonitoringService: self.bot = bot self._notified_users: Set[str] = set() self._last_cleanup = datetime.utcnow() + self._sla_task = None async def start_monitoring(self): if self.is_running: @@ -50,6 +51,12 @@ class MonitoringService: self.is_running = True logger.info("🔄 Запуск службы мониторинга") + # Start dedicated SLA loop with its own interval for timely 5-min checks + try: + if not self._sla_task or self._sla_task.done(): + self._sla_task = asyncio.create_task(self._sla_loop()) + except Exception as e: + logger.error(f"Не удалось запустить SLA-мониторинг: {e}") while self.is_running: try: @@ -63,6 +70,11 @@ class MonitoringService: def stop_monitoring(self): self.is_running = False logger.info("ℹ️ Мониторинг остановлен") + try: + if self._sla_task and not self._sla_task.done(): + self._sla_task.cancel() + except Exception: + pass async def _monitoring_cycle(self): async for db in get_db(): @@ -576,6 +588,107 @@ class MonitoringService: is_success=False ) + async def _check_ticket_sla(self, db: AsyncSession): + try: + # Quick guards + # Allow runtime toggle from SupportSettingsService + try: + from app.services.support_settings_service import SupportSettingsService + sla_enabled_runtime = SupportSettingsService.get_sla_enabled() + except Exception: + sla_enabled_runtime = getattr(settings, 'SUPPORT_TICKET_SLA_ENABLED', True) + if not sla_enabled_runtime: + return + if not self.bot: + return + if not settings.is_admin_notifications_enabled(): + return + + from datetime import datetime, timedelta + try: + from app.services.support_settings_service import SupportSettingsService + sla_minutes = max(1, int(SupportSettingsService.get_sla_minutes())) + except Exception: + sla_minutes = max(1, int(getattr(settings, 'SUPPORT_TICKET_SLA_MINUTES', 5))) + cooldown_minutes = max(1, int(getattr(settings, 'SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES', 15))) + now = datetime.utcnow() + stale_before = now - timedelta(minutes=sla_minutes) + cooldown_before = now - timedelta(minutes=cooldown_minutes) + + # Tickets to remind: open, no admin reply yet after user's last message (status OPEN), stale by SLA, + # and either never reminded or cooldown passed + result = await db.execute( + select(Ticket) + .options(selectinload(Ticket.user)) + .where( + and_( + Ticket.status == TicketStatus.OPEN.value, + Ticket.updated_at <= stale_before, + or_(Ticket.last_sla_reminder_at.is_(None), Ticket.last_sla_reminder_at <= cooldown_before), + ) + ) + ) + tickets = result.scalars().all() + if not tickets: + return + + from app.services.admin_notification_service import AdminNotificationService + + reminders_sent = 0 + service = AdminNotificationService(self.bot) + + for ticket in tickets: + try: + waited_minutes = max(0, int((now - ticket.updated_at).total_seconds() // 60)) + title = (ticket.title or '').strip() + if len(title) > 60: + title = title[:57] + '...' + + text = ( + f"⏰ Ожидание ответа на тикет превышено\n\n" + f"🆔 ID: {ticket.id}\n" + f"👤 User ID: {ticket.user_id}\n" + f"📝 Заголовок: {title or '—'}\n" + f"⏱️ Ожидает ответа: {waited_minutes} мин\n" + ) + + sent = await service.send_ticket_event_notification(text) + if sent: + ticket.last_sla_reminder_at = now + reminders_sent += 1 + # commit after each to persist timestamp and avoid duplicate reminders on crash + await db.commit() + except Exception as notify_error: + logger.error(f"Ошибка отправки SLA-уведомления по тикету {ticket.id}: {notify_error}") + + if reminders_sent > 0: + await self._log_monitoring_event( + db, + "ticket_sla_reminders_sent", + f"Отправлено {reminders_sent} SLA-напоминаний по тикетам", + {"count": reminders_sent}, + ) + except Exception as e: + logger.error(f"Ошибка проверки SLA тикетов: {e}") + + async def _sla_loop(self): + try: + interval_seconds = max(10, int(getattr(settings, 'SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS', 60))) + except Exception: + interval_seconds = 60 + while self.is_running: + try: + async for db in get_db(): + try: + await self._check_ticket_sla(db) + finally: + break + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Ошибка в SLA-цикле: {e}") + await asyncio.sleep(interval_seconds) + async def _log_monitoring_event( self, db: AsyncSession, diff --git a/app/services/support_settings_service.py b/app/services/support_settings_service.py index 19be0b10..7943b84a 100644 --- a/app/services/support_settings_service.py +++ b/app/services/support_settings_service.py @@ -110,3 +110,112 @@ class SupportSettingsService: return cls._save() + # Notifications & SLA + @classmethod + def get_admin_ticket_notifications_enabled(cls) -> bool: + cls._load() + if "admin_ticket_notifications_enabled" in cls._data: + return bool(cls._data["admin_ticket_notifications_enabled"]) + # fallback to global admin notifications setting + return bool(settings.is_admin_notifications_enabled()) + + @classmethod + def set_admin_ticket_notifications_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["admin_ticket_notifications_enabled"] = bool(enabled) + return cls._save() + + @classmethod + def get_user_ticket_notifications_enabled(cls) -> bool: + cls._load() + if "user_ticket_notifications_enabled" in cls._data: + return bool(cls._data["user_ticket_notifications_enabled"]) + # fallback to global enable notifications + return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) + + @classmethod + def set_user_ticket_notifications_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["user_ticket_notifications_enabled"] = bool(enabled) + return cls._save() + + @classmethod + def get_sla_enabled(cls) -> bool: + cls._load() + if "ticket_sla_enabled" in cls._data: + return bool(cls._data["ticket_sla_enabled"]) + return bool(getattr(settings, "SUPPORT_TICKET_SLA_ENABLED", True)) + + @classmethod + def set_sla_enabled(cls, enabled: bool) -> bool: + cls._load() + cls._data["ticket_sla_enabled"] = bool(enabled) + return cls._save() + + @classmethod + def get_sla_minutes(cls) -> int: + cls._load() + minutes = cls._data.get("ticket_sla_minutes") + if isinstance(minutes, int) and minutes > 0: + return minutes + return int(getattr(settings, "SUPPORT_TICKET_SLA_MINUTES", 5)) + + @classmethod + def set_sla_minutes(cls, minutes: int) -> bool: + try: + minutes_int = int(minutes) + except Exception: + return False + if minutes_int <= 0: + return False + cls._load() + cls._data["ticket_sla_minutes"] = minutes_int + return cls._save() + + # Moderators management + @classmethod + def get_moderators(cls) -> list[int]: + cls._load() + raw = cls._data.get("moderators") or [] + moderators: list[int] = [] + for item in raw: + try: + moderators.append(int(item)) + except Exception: + continue + return moderators + + @classmethod + def is_moderator(cls, telegram_id: int) -> bool: + try: + tid = int(telegram_id) + except Exception: + return False + return tid in cls.get_moderators() + + @classmethod + def add_moderator(cls, telegram_id: int) -> bool: + try: + tid = int(telegram_id) + except Exception: + return False + cls._load() + moderators = set(cls.get_moderators()) + moderators.add(tid) + cls._data["moderators"] = sorted(moderators) + return cls._save() + + @classmethod + def remove_moderator(cls, telegram_id: int) -> bool: + try: + tid = int(telegram_id) + except Exception: + return False + cls._load() + moderators = set(cls.get_moderators()) + if tid in moderators: + moderators.remove(tid) + cls._data["moderators"] = sorted(moderators) + return cls._save() + return True + diff --git a/locales/ru.json b/locales/ru.json index 328c0d33..54cbba08 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -30,7 +30,7 @@ "TICKET_TITLE_INPUT": "Введите заголовок тикета:", "TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:", "TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:", - "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото без текста:", + "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:", "TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:", "TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.", "VIEW_TICKET": "👁️ Посмотреть тикет", @@ -68,7 +68,7 @@ "CLOSE_NOTIFICATION": "❌ Закрыть уведомление", "NOTIFICATION_CLOSED": "Уведомление закрыто.", "UNBLOCK": "✅ Разблокировать", - "BLOCK_FOREVER": "🚫 Блок навсегда", + "BLOCK_FOREVER": "🚫 Заблокировать", "BLOCK_BY_TIME": "⏳ Блокировка по времени", "TICKET_ATTACHMENTS": "📎 Вложения", "OPEN_TICKETS": "🔴 Открытые",