diff --git a/app/bot.py b/app/bot.py index d5815987..a9d04550 100644 --- a/app/bot.py +++ b/app/bot.py @@ -45,6 +45,7 @@ from app.handlers.admin import ( pricing as admin_pricing, privacy_policy as admin_privacy_policy, public_offer as admin_public_offer, + faq as admin_faq, ) from app.handlers.stars_payments import register_stars_handlers @@ -153,6 +154,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_pricing.register_handlers(dp) admin_privacy_policy.register_handlers(dp) admin_public_offer.register_handlers(dp) + admin_faq.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/database/crud/faq.py b/app/database/crud/faq.py new file mode 100644 index 00000000..efff8c4a --- /dev/null +++ b/app/database/crud/faq.py @@ -0,0 +1,150 @@ +import logging +from datetime import datetime +from typing import Iterable, Optional + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import FaqPage, FaqSetting + +logger = logging.getLogger(__name__) + + +async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]: + result = await db.execute( + select(FaqSetting).where(FaqSetting.language == language) + ) + return result.scalar_one_or_none() + + +async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> FaqSetting: + setting = await get_faq_setting(db, language) + + if setting: + setting.is_enabled = bool(enabled) + setting.updated_at = datetime.utcnow() + else: + setting = FaqSetting( + language=language, + is_enabled=bool(enabled), + ) + db.add(setting) + + await db.commit() + await db.refresh(setting) + + logger.info( + "✅ Статус FAQ для языка %s обновлен: %s", + language, + "enabled" if setting.is_enabled else "disabled", + ) + + return setting + + +async def upsert_faq_setting(db: AsyncSession, language: str, enabled: bool) -> FaqSetting: + return await set_faq_enabled(db, language, enabled) + + +async def get_faq_pages( + db: AsyncSession, + language: str, + *, + include_inactive: bool = False, +) -> list[FaqPage]: + query = select(FaqPage).where(FaqPage.language == language) + + if not include_inactive: + query = query.where(FaqPage.is_active.is_(True)) + + query = query.order_by(FaqPage.display_order.asc(), FaqPage.id.asc()) + + result = await db.execute(query) + pages = list(result.scalars().all()) + return pages + + +async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]: + result = await db.execute(select(FaqPage).where(FaqPage.id == page_id)) + return result.scalar_one_or_none() + + +async def create_faq_page( + db: AsyncSession, + *, + language: str, + title: str, + content: str, + display_order: Optional[int] = None, + is_active: bool = True, +) -> FaqPage: + if display_order is None: + result = await db.execute( + select(func.max(FaqPage.display_order)).where(FaqPage.language == language) + ) + max_order = result.scalar() or 0 + display_order = max_order + 1 + + page = FaqPage( + language=language, + title=title, + content=content, + display_order=display_order, + is_active=is_active, + ) + + db.add(page) + await db.commit() + await db.refresh(page) + + logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language) + + return page + + +async def update_faq_page( + db: AsyncSession, + page: FaqPage, + *, + title: Optional[str] = None, + content: Optional[str] = None, + display_order: Optional[int] = None, + is_active: Optional[bool] = None, +) -> FaqPage: + if title is not None: + page.title = title + if content is not None: + page.content = content + if display_order is not None: + page.display_order = display_order + if is_active is not None: + page.is_active = bool(is_active) + + page.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(page) + + logger.info("✅ Страница FAQ %s обновлена", page.id) + + return page + + +async def delete_faq_page(db: AsyncSession, page_id: int) -> None: + await db.execute(delete(FaqPage).where(FaqPage.id == page_id)) + await db.commit() + logger.info("🗑️ Страница FAQ %s удалена", page_id) + + +async def bulk_update_order( + db: AsyncSession, + pages: Iterable[tuple[int, int]], +) -> None: + for page_id, order in pages: + await db.execute( + update(FaqPage) + .where(FaqPage.id == page_id) + .values(display_order=order, updated_at=datetime.utcnow()) + ) + await db.commit() + diff --git a/app/database/models.py b/app/database/models.py index 3536fecb..1780d75a 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -782,9 +782,32 @@ class PublicOffer(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) +class FaqSetting(Base): + __tablename__ = "faq_settings" + + id = Column(Integer, primary_key=True, index=True) + language = Column(String(10), nullable=False, unique=True) + is_enabled = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + +class FaqPage(Base): + __tablename__ = "faq_pages" + + id = Column(Integer, primary_key=True, index=True) + language = Column(String(10), nullable=False, index=True) + title = Column(String(255), nullable=False) + content = Column(Text, nullable=False) + display_order = Column(Integer, default=0, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + class SystemSetting(Base): __tablename__ = "system_settings" - + id = Column(Integer, primary_key=True, index=True) key = Column(String(255), unique=True, nullable=False) value = Column(Text, nullable=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 791d5d4d..90d637ad 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -2606,6 +2606,120 @@ async def create_public_offers_table() -> bool: return False +async def create_faq_settings_table() -> bool: + table_exists = await check_table_exists("faq_settings") + if table_exists: + logger.info("ℹ️ Таблица faq_settings уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + create_sql = """ + CREATE TABLE faq_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + language VARCHAR(10) NOT NULL UNIQUE, + is_enabled BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """ + elif db_type == "postgresql": + create_sql = """ + CREATE TABLE faq_settings ( + id SERIAL PRIMARY KEY, + language VARCHAR(10) NOT NULL UNIQUE, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + """ + else: + create_sql = """ + CREATE TABLE faq_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + language VARCHAR(10) NOT NULL UNIQUE, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB; + """ + + await conn.execute(text(create_sql)) + logger.info("✅ Таблица faq_settings создана") + return True + + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы faq_settings: {error}") + return False + + +async def create_faq_pages_table() -> bool: + table_exists = await check_table_exists("faq_pages") + if table_exists: + logger.info("ℹ️ Таблица faq_pages уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + create_sql = """ + CREATE TABLE faq_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + language VARCHAR(10) NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX idx_faq_pages_language ON faq_pages(language); + """ + elif db_type == "postgresql": + create_sql = """ + CREATE TABLE faq_pages ( + id SERIAL PRIMARY KEY, + language VARCHAR(10) NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX idx_faq_pages_language ON faq_pages(language); + CREATE INDEX idx_faq_pages_order ON faq_pages(language, display_order); + """ + else: + create_sql = """ + CREATE TABLE faq_pages ( + id INT AUTO_INCREMENT PRIMARY KEY, + language VARCHAR(10) NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + display_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB; + CREATE INDEX idx_faq_pages_language ON faq_pages(language); + CREATE INDEX idx_faq_pages_order ON faq_pages(language, display_order); + """ + + await conn.execute(text(create_sql)) + logger.info("✅ Таблица faq_pages создана") + return True + + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы faq_pages: {error}") + return False + + async def ensure_default_web_api_token() -> bool: default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip() if not default_token: @@ -2702,6 +2816,20 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей public_offers") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_SETTINGS ===") + faq_settings_ready = await create_faq_settings_table() + if faq_settings_ready: + logger.info("✅ Таблица faq_settings готова") + else: + logger.warning("⚠️ Проблемы с таблицей faq_settings") + + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_PAGES ===") + faq_pages_ready = await create_faq_pages_table() + if faq_pages_ready: + logger.info("✅ Таблица faq_pages готова") + else: + logger.warning("⚠️ Проблемы с таблицей faq_pages") + logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===") default_token_ready = await ensure_default_web_api_token() if default_token_ready: diff --git a/app/handlers/admin/faq.py b/app/handlers/admin/faq.py new file mode 100644 index 00000000..90b461c5 --- /dev/null +++ b/app/handlers/admin/faq.py @@ -0,0 +1,1065 @@ +import html +import logging +from datetime import datetime + +from aiogram import Dispatcher, F, types +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.localization.texts import get_texts +from app.services.faq_service import FaqService +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler +from app.utils.validators import get_html_help_text, validate_html_tags + +logger = logging.getLogger(__name__) + + +def _format_timestamp(value: datetime | None) -> str: + if not value: + return "" + try: + return value.strftime("%d.%m.%Y %H:%M") + except Exception: + return "" + + +async def _build_overview( + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + normalized_language = FaqService.normalize_language(db_user.language) + setting = await FaqService.get_setting( + db, + db_user.language, + fallback=False, + ) + + pages = await FaqService.get_pages( + db, + db_user.language, + include_inactive=True, + fallback=False, + ) + + total_pages = len(pages) + active_pages = sum(1 for page in pages if page.is_active) + + description = texts.t( + "ADMIN_FAQ_DESCRIPTION", + "FAQ отображается в разделе «Инфо».", + ) + + if setting and not setting.is_enabled: + status_text = texts.t( + "ADMIN_FAQ_STATUS_DISABLED", + "⚠️ Показ FAQ выключен.", + ) + elif active_pages: + status_text = texts.t( + "ADMIN_FAQ_STATUS_ENABLED", + "✅ FAQ включён. Активных страниц: {count}.", + ).format(count=active_pages) + elif total_pages: + status_text = texts.t( + "ADMIN_FAQ_STATUS_ENABLED_EMPTY", + "⚠️ FAQ включён, но нет активных страниц.", + ) + else: + status_text = texts.t( + "ADMIN_FAQ_STATUS_EMPTY", + "⚠️ FAQ ещё не настроен.", + ) + + pages_overview = texts.t( + "ADMIN_FAQ_PAGES_EMPTY", + "Страницы ещё не созданы.", + ) + + if pages: + rows: list[str] = [] + for index, page in enumerate(pages, start=1): + title = (page.title or "").strip() + if not title: + title = texts.t("FAQ_PAGE_UNTITLED", "Без названия") + if len(title) > 60: + title = f"{title[:57]}..." + + status_label = texts.t( + "ADMIN_FAQ_PAGE_STATUS_ACTIVE", + "✅ Активна", + ) + if not page.is_active: + status_label = texts.t( + "ADMIN_FAQ_PAGE_STATUS_INACTIVE", + "🚫 Выключена", + ) + + updated = _format_timestamp(getattr(page, "updated_at", None)) + updated_block = f" ({updated})" if updated else "" + rows.append( + f"{index}. {html.escape(title)} — {status_label}{updated_block}" + ) + + pages_list_header = texts.t( + "ADMIN_FAQ_PAGES_OVERVIEW", + "Список страниц:\n{items}", + ) + pages_overview = pages_list_header.format(items="\n".join(rows)) + + language_block = texts.t( + "ADMIN_FAQ_LANGUAGE", + "Язык: {lang}", + ).format(lang=normalized_language) + + stats_block = texts.t( + "ADMIN_FAQ_PAGE_STATS", + "Всего страниц: {total}", + ).format(total=total_pages) + + header = texts.t("ADMIN_FAQ_HEADER", "❓ FAQ") + actions_prompt = texts.t( + "ADMIN_FAQ_ACTION_PROMPT", + "Выберите действие:", + ) + + message_parts = [ + header, + description, + language_block, + status_text, + stats_block, + pages_overview, + actions_prompt, + ] + + overview_text = "\n\n".join(part for part in message_parts if part) + + buttons: list[list[types.InlineKeyboardButton]] = [] + + buttons.append([ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_FAQ_ADD_PAGE_BUTTON", + "➕ Добавить страницу", + ), + callback_data="admin_faq_create", + ) + ]) + + for page in pages[:25]: + title = (page.title or "").strip() + if not title: + title = texts.t("FAQ_PAGE_UNTITLED", "Без названия") + if len(title) > 40: + title = f"{title[:37]}..." + buttons.append([ + types.InlineKeyboardButton( + text=f"{page.display_order}. {title}", + callback_data=f"admin_faq_page:{page.id}", + ) + ]) + + toggle_text = texts.t( + "ADMIN_FAQ_ENABLE_BUTTON", + "✅ Включить показ", + ) + if setting and setting.is_enabled: + toggle_text = texts.t( + "ADMIN_FAQ_DISABLE_BUTTON", + "🚫 Отключить показ", + ) + + buttons.append([ + types.InlineKeyboardButton( + text=toggle_text, + callback_data="admin_faq_toggle", + ) + ]) + + buttons.append([ + types.InlineKeyboardButton( + text=texts.t("ADMIN_FAQ_HTML_HELP", "ℹ️ HTML помощь"), + callback_data="admin_faq_help", + ) + ]) + + buttons.append([ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_submenu_settings", + ) + ]) + + return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons) + + +@admin_required +@error_handler +async def show_faq_management( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + overview_text, markup = await _build_overview(db_user, db) + + await callback.message.edit_text( + overview_text, + reply_markup=markup, + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_faq( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + setting = await FaqService.toggle_enabled(db, db_user.language) + + if setting.is_enabled: + alert_text = texts.t( + "ADMIN_FAQ_ENABLED_ALERT", + "✅ FAQ включён.", + ) + else: + alert_text = texts.t( + "ADMIN_FAQ_DISABLED_ALERT", + "🚫 FAQ отключён.", + ) + + overview_text, markup = await _build_overview(db_user, db) + + await callback.message.edit_text( + overview_text, + reply_markup=markup, + ) + await callback.answer(alert_text, show_alert=True) + + +@admin_required +@error_handler +async def start_create_faq_page( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + await state.set_state(AdminStates.creating_faq_title) + await state.update_data(faq_language=db_user.language) + + await callback.message.edit_text( + texts.t( + "ADMIN_FAQ_ENTER_TITLE", + "Введите заголовок для новой страницы FAQ:", + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_FAQ_CANCEL_BUTTON", + "⬅️ Отмена", + ), + callback_data="admin_faq_cancel", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def cancel_faq_creation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + await state.clear() + await show_faq_management(callback, db_user, db) + + +@admin_required +@error_handler +async def process_new_faq_title( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + title = (message.text or "").strip() + + if not title: + await message.answer( + texts.t( + "ADMIN_FAQ_TITLE_EMPTY", + "❌ Заголовок не может быть пустым.", + ) + ) + return + + if len(title) > 255: + await message.answer( + texts.t( + "ADMIN_FAQ_TITLE_TOO_LONG", + "❌ Заголовок слишком длинный. Максимум 255 символов.", + ) + ) + return + + await state.update_data(faq_title=title) + await state.set_state(AdminStates.creating_faq_content) + + await message.answer( + texts.t( + "ADMIN_FAQ_ENTER_CONTENT", + "Отправьте содержимое страницы FAQ. Допускается HTML.", + ) + ) + + +@admin_required +@error_handler +async def process_new_faq_content( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + content = message.text or "" + + if len(content) > 6000: + await message.answer( + texts.t( + "ADMIN_FAQ_CONTENT_TOO_LONG", + "❌ Текст слишком длинный. Максимум 6000 символов.", + ) + ) + return + + if not content.strip(): + await message.answer( + texts.t( + "ADMIN_FAQ_CONTENT_EMPTY", + "❌ Текст не может быть пустым.", + ) + ) + return + + is_valid, error_message = validate_html_tags(content) + if not is_valid: + await message.answer( + texts.t( + "ADMIN_FAQ_HTML_ERROR", + "❌ Ошибка в HTML: {error}", + ).format(error=error_message) + ) + return + + data = await state.get_data() + title = data.get("faq_title") or texts.t("FAQ_PAGE_UNTITLED", "Без названия") + language = data.get("faq_language", db_user.language) + + await FaqService.create_page( + db, + language=language, + title=title, + content=content, + ) + + logger.info( + "Админ %s создал страницу FAQ (%d символов)", + db_user.telegram_id, + len(content), + ) + + await state.clear() + + success_text = texts.t( + "ADMIN_FAQ_PAGE_CREATED", + "✅ Страница FAQ создана.", + ) + + reply_markup = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_FAQ_BACK_TO_LIST", + "⬅️ К настройкам FAQ", + ), + callback_data="admin_faq", + ) + ] + ] + ) + + await message.answer(success_text, reply_markup=reply_markup) + + +@admin_required +@error_handler +async def show_faq_page_details( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + raw_id = (callback.data or "").split(":", 1)[-1] + try: + page_id = int(raw_id) + except ValueError: + await callback.answer() + return + + page = await FaqService.get_page( + db, + page_id, + db_user.language, + fallback=False, + include_inactive=True, + ) + + if not page: + await callback.answer( + texts.t( + "ADMIN_FAQ_PAGE_NOT_FOUND", + "⚠️ Страница не найдена.", + ), + show_alert=True, + ) + return + + header = texts.t("ADMIN_FAQ_PAGE_HEADER", "📄 Страница FAQ") + title = (page.title or "").strip() or texts.t("FAQ_PAGE_UNTITLED", "Без названия") + status_label = texts.t( + "ADMIN_FAQ_PAGE_STATUS_ACTIVE", + "✅ Активна", + ) + if not page.is_active: + status_label = texts.t( + "ADMIN_FAQ_PAGE_STATUS_INACTIVE", + "🚫 Выключена", + ) + + updated_at = _format_timestamp(getattr(page, "updated_at", None)) + updated_block = "" + if updated_at: + updated_block = texts.t( + "ADMIN_FAQ_PAGE_UPDATED", + "Обновлено: {timestamp}", + ).format(timestamp=updated_at) + + preview = (page.content or "").strip() + preview_text = texts.t( + "ADMIN_FAQ_PAGE_PREVIEW_EMPTY", + "Текст ещё не задан.", + ) + if preview: + preview_trimmed = preview[:400] + if len(preview) > 400: + preview_trimmed += "..." + preview_text = ( + texts.t("ADMIN_FAQ_PAGE_PREVIEW", "Превью:\n{content}") + .format(content=html.escape(preview_trimmed)) + ) + + message_parts = [ + header, + texts.t( + "ADMIN_FAQ_PAGE_TITLE", + "Заголовок: {title}", + ).format(title=html.escape(title)), + texts.t( + "ADMIN_FAQ_PAGE_STATUS", + "Статус: {status}", + ).format(status=status_label), + preview_text, + updated_block, + ] + + message_text = "\n\n".join(part for part in message_parts if part) + + buttons: list[list[types.InlineKeyboardButton]] = [] + + buttons.append([ + types.InlineKeyboardButton( + text=texts.t("ADMIN_FAQ_EDIT_TITLE_BUTTON", "✏️ Изменить заголовок"), + callback_data=f"admin_faq_edit_title:{page.id}", + ) + ]) + buttons.append([ + types.InlineKeyboardButton( + text=texts.t("ADMIN_FAQ_EDIT_CONTENT_BUTTON", "📝 Изменить текст"), + callback_data=f"admin_faq_edit_content:{page.id}", + ) + ]) + + toggle_text = texts.t("ADMIN_FAQ_PAGE_ENABLE_BUTTON", "✅ Включить страницу") + if page.is_active: + toggle_text = texts.t( + "ADMIN_FAQ_PAGE_DISABLE_BUTTON", + "🚫 Выключить страницу", + ) + + buttons.append([ + types.InlineKeyboardButton( + text=toggle_text, + callback_data=f"admin_faq_toggle_page:{page.id}", + ) + ]) + + buttons.append([ + types.InlineKeyboardButton( + text=texts.t("ADMIN_FAQ_PAGE_MOVE_UP", "⬆️ Выше"), + callback_data=f"admin_faq_move:{page.id}:up", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_FAQ_PAGE_MOVE_DOWN", "⬇️ Ниже"), + callback_data=f"admin_faq_move:{page.id}:down", + ), + ]) + + buttons.append([ + types.InlineKeyboardButton( + text=texts.t("ADMIN_FAQ_PAGE_DELETE_BUTTON", "🗑️ Удалить"), + callback_data=f"admin_faq_delete:{page.id}", + ) + ]) + + buttons.append([ + types.InlineKeyboardButton( + text=texts.t("ADMIN_FAQ_BACK_TO_LIST", "⬅️ К настройкам FAQ"), + callback_data="admin_faq", + ) + ]) + + await callback.message.edit_text( + message_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons), + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_edit_faq_title( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + raw_id = (callback.data or "").split(":", 1)[-1] + try: + page_id = int(raw_id) + except ValueError: + await callback.answer() + return + + page = await FaqService.get_page( + db, + page_id, + db_user.language, + fallback=False, + include_inactive=True, + ) + + if not page: + await callback.answer( + texts.t( + "ADMIN_FAQ_PAGE_NOT_FOUND", + "⚠️ Страница не найдена.", + ), + show_alert=True, + ) + return + + await state.set_state(AdminStates.editing_faq_title) + await state.update_data(faq_page_id=page.id) + + await callback.message.edit_text( + texts.t( + "ADMIN_FAQ_EDIT_TITLE_PROMPT", + "Введите новый заголовок для страницы:", + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_FAQ_CANCEL_BUTTON", + "⬅️ Отмена", + ), + callback_data=f"admin_faq_page:{page.id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_faq_title( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + title = (message.text or "").strip() + + if not title: + await message.answer( + texts.t( + "ADMIN_FAQ_TITLE_EMPTY", + "❌ Заголовок не может быть пустым.", + ) + ) + return + + if len(title) > 255: + await message.answer( + texts.t( + "ADMIN_FAQ_TITLE_TOO_LONG", + "❌ Заголовок слишком длинный. Максимум 255 символов.", + ) + ) + return + + data = await state.get_data() + page_id = data.get("faq_page_id") + + if not page_id: + await state.clear() + await message.answer(texts.t("ADMIN_FAQ_UNEXPECTED_STATE", "⚠️ Состояние сброшено.")) + return + + page = await FaqService.get_page( + db, + page_id, + db_user.language, + fallback=False, + include_inactive=True, + ) + + if not page: + await message.answer( + texts.t("ADMIN_FAQ_PAGE_NOT_FOUND", "⚠️ Страница не найдена."), + ) + await state.clear() + return + + await FaqService.update_page(db, page, title=title) + await state.clear() + + await message.answer( + texts.t("ADMIN_FAQ_TITLE_UPDATED", "✅ Заголовок обновлён."), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton( + text=texts.t("ADMIN_FAQ_BACK_TO_LIST", "⬅️ К настройкам FAQ"), + callback_data="admin_faq", + )]] + ), + ) + + +@admin_required +@error_handler +async def start_edit_faq_content( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + raw_id = (callback.data or "").split(":", 1)[-1] + try: + page_id = int(raw_id) + except ValueError: + await callback.answer() + return + + page = await FaqService.get_page( + db, + page_id, + db_user.language, + fallback=False, + include_inactive=True, + ) + + if not page: + await callback.answer( + texts.t("ADMIN_FAQ_PAGE_NOT_FOUND", "⚠️ Страница не найдена."), + show_alert=True, + ) + return + + await state.set_state(AdminStates.editing_faq_content) + await state.update_data(faq_page_id=page.id) + + await callback.message.edit_text( + texts.t( + "ADMIN_FAQ_EDIT_CONTENT_PROMPT", + "Отправьте новый текст для страницы FAQ.", + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_FAQ_CANCEL_BUTTON", + "⬅️ Отмена", + ), + callback_data=f"admin_faq_page:{page.id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_faq_content( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + content = message.text or "" + + if len(content) > 6000: + await message.answer( + texts.t( + "ADMIN_FAQ_CONTENT_TOO_LONG", + "❌ Текст слишком длинный. Максимум 6000 символов.", + ) + ) + return + + if not content.strip(): + await message.answer( + texts.t( + "ADMIN_FAQ_CONTENT_EMPTY", + "❌ Текст не может быть пустым.", + ) + ) + return + + is_valid, error_message = validate_html_tags(content) + if not is_valid: + await message.answer( + texts.t( + "ADMIN_FAQ_HTML_ERROR", + "❌ Ошибка в HTML: {error}", + ).format(error=error_message) + ) + return + + data = await state.get_data() + page_id = data.get("faq_page_id") + + if not page_id: + await state.clear() + await message.answer(texts.t("ADMIN_FAQ_UNEXPECTED_STATE", "⚠️ Состояние сброшено.")) + return + + page = await FaqService.get_page( + db, + page_id, + db_user.language, + fallback=False, + include_inactive=True, + ) + + if not page: + await message.answer( + texts.t("ADMIN_FAQ_PAGE_NOT_FOUND", "⚠️ Страница не найдена."), + ) + await state.clear() + return + + await FaqService.update_page(db, page, content=content) + await state.clear() + + await message.answer( + texts.t("ADMIN_FAQ_CONTENT_UPDATED", "✅ Текст страницы обновлён."), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton( + text=texts.t("ADMIN_FAQ_BACK_TO_LIST", "⬅️ К настройкам FAQ"), + callback_data="admin_faq", + )]] + ), + ) + + +@admin_required +@error_handler +async def toggle_faq_page( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + parts = (callback.data or "").split(":") + try: + page_id = int(parts[1]) + except (ValueError, IndexError): + await callback.answer() + return + + page = await FaqService.get_page( + db, + page_id, + db_user.language, + fallback=False, + include_inactive=True, + ) + + if not page: + await callback.answer( + texts.t("ADMIN_FAQ_PAGE_NOT_FOUND", "⚠️ Страница не найдена."), + show_alert=True, + ) + return + + updated_page = await FaqService.update_page(db, page, is_active=not page.is_active) + + alert_text = texts.t( + "ADMIN_FAQ_PAGE_ENABLED_ALERT", + "✅ Страница включена.", + ) + if not updated_page.is_active: + alert_text = texts.t( + "ADMIN_FAQ_PAGE_DISABLED_ALERT", + "🚫 Страница выключена.", + ) + + await callback.answer(alert_text, show_alert=True) + await show_faq_page_details(callback, db_user, db) + + +@admin_required +@error_handler +async def delete_faq_page( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + parts = (callback.data or "").split(":") + try: + page_id = int(parts[1]) + except (ValueError, IndexError): + await callback.answer() + return + + page = await FaqService.get_page( + db, + page_id, + db_user.language, + fallback=False, + include_inactive=True, + ) + + if not page: + await callback.answer( + texts.t("ADMIN_FAQ_PAGE_NOT_FOUND", "⚠️ Страница не найдена."), + show_alert=True, + ) + return + + await FaqService.delete_page(db, page.id) + + remaining_pages = await FaqService.get_pages( + db, + db_user.language, + include_inactive=True, + fallback=False, + ) + + if remaining_pages: + remaining_sorted = sorted( + remaining_pages, + key=lambda item: (item.display_order, item.id), + ) + await FaqService.reorder_pages(db, db_user.language, remaining_sorted) + + await callback.answer( + texts.t("ADMIN_FAQ_PAGE_DELETED", "🗑️ Страница удалена."), + show_alert=True, + ) + + await show_faq_management(callback, db_user, db) + + +@admin_required +@error_handler +async def move_faq_page( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + parts = (callback.data or "").split(":") + try: + page_id = int(parts[1]) + direction = parts[2] + except (ValueError, IndexError): + await callback.answer() + return + + pages = await FaqService.get_pages( + db, + db_user.language, + include_inactive=True, + fallback=False, + ) + + if not pages: + await callback.answer() + return + + pages_sorted = sorted(pages, key=lambda item: (item.display_order, item.id)) + + index = next((i for i, page in enumerate(pages_sorted) if page.id == page_id), None) + + if index is None: + await callback.answer() + return + + if direction == "up" and index > 0: + pages_sorted[index - 1], pages_sorted[index] = ( + pages_sorted[index], + pages_sorted[index - 1], + ) + elif direction == "down" and index < len(pages_sorted) - 1: + pages_sorted[index + 1], pages_sorted[index] = ( + pages_sorted[index], + pages_sorted[index + 1], + ) + else: + await callback.answer() + return + + await FaqService.reorder_pages(db, db_user.language, pages_sorted) + + await callback.answer( + texts.t("ADMIN_FAQ_PAGE_REORDERED", "✅ Порядок обновлён."), + show_alert=True, + ) + await show_faq_page_details(callback, db_user, db) + + +@admin_required +@error_handler +async def show_faq_html_help( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + help_text = get_html_help_text() + + buttons = [[ + types.InlineKeyboardButton( + text=texts.t("ADMIN_FAQ_BACK_TO_LIST", "⬅️ К настройкам FAQ"), + callback_data="admin_faq", + ) + ]] + + await callback.message.edit_text( + help_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons), + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register( + show_faq_management, + F.data == "admin_faq", + ) + dp.callback_query.register( + toggle_faq, + F.data == "admin_faq_toggle", + ) + dp.callback_query.register( + start_create_faq_page, + F.data == "admin_faq_create", + ) + dp.callback_query.register( + cancel_faq_creation, + F.data == "admin_faq_cancel", + ) + dp.callback_query.register( + show_faq_page_details, + F.data.startswith("admin_faq_page:"), + ) + dp.callback_query.register( + start_edit_faq_title, + F.data.startswith("admin_faq_edit_title:"), + ) + dp.callback_query.register( + start_edit_faq_content, + F.data.startswith("admin_faq_edit_content:"), + ) + dp.callback_query.register( + toggle_faq_page, + F.data.startswith("admin_faq_toggle_page:"), + ) + dp.callback_query.register( + delete_faq_page, + F.data.startswith("admin_faq_delete:"), + ) + dp.callback_query.register( + move_faq_page, + F.data.startswith("admin_faq_move:"), + ) + dp.callback_query.register( + show_faq_html_help, + F.data == "admin_faq_help", + ) + + dp.message.register( + process_new_faq_title, + AdminStates.creating_faq_title, + ) + dp.message.register( + process_new_faq_content, + AdminStates.creating_faq_content, + ) + dp.message.register( + process_edit_faq_title, + AdminStates.editing_faq_title, + ) + dp.message.register( + process_edit_faq_content, + AdminStates.editing_faq_content, + ) + diff --git a/app/handlers/menu.py b/app/handlers/menu.py index a15d42c2..479d5dfb 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -1,3 +1,4 @@ +import html import logging from aiogram import Dispatcher, types, F from aiogram.filters import StateFilter @@ -27,6 +28,7 @@ from app.utils.promo_offer import ( ) from app.services.privacy_policy_service import PrivacyPolicyService from app.services.public_offer_service import PublicOfferService +from app.services.faq_service import FaqService logger = logging.getLogger(__name__) @@ -114,6 +116,7 @@ async def show_info_menu( privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language) public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language) + faq_enabled = await FaqService.is_enabled(db, db_user.language) await edit_or_answer_photo( callback=callback, @@ -122,12 +125,180 @@ async def show_info_menu( language=db_user.language, show_privacy_policy=privacy_enabled, show_public_offer=public_offer_enabled, + show_faq=faq_enabled, ), parse_mode="HTML", ) await callback.answer() +async def show_faq_pages( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + pages = await FaqService.get_pages(db, db_user.language) + if not pages: + await callback.answer( + texts.t("FAQ_NOT_AVAILABLE", "FAQ временно недоступен."), + show_alert=True, + ) + return + + header = texts.t("FAQ_HEADER", "❓ FAQ") + prompt = texts.t("FAQ_PAGES_PROMPT", "Выберите вопрос:" ) + caption = f"{header}\n\n{prompt}" if prompt else header + + buttons: list[list[types.InlineKeyboardButton]] = [] + for index, page in enumerate(pages, start=1): + raw_title = (page.title or "").strip() + if not raw_title: + raw_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия") + if len(raw_title) > 60: + raw_title = f"{raw_title[:57]}..." + buttons.append([ + types.InlineKeyboardButton( + text=f"{index}. {raw_title}", + callback_data=f"menu_faq_page:{page.id}:1", + ) + ]) + + buttons.append([ + types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info") + ]) + + await callback.message.edit_text( + caption, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons), + ) + await callback.answer() + + +async def show_faq_page( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + raw_data = callback.data or "" + parts = raw_data.split(":") + + page_id = None + requested_page = 1 + + if len(parts) >= 2: + try: + page_id = int(parts[1]) + except ValueError: + page_id = None + + if len(parts) >= 3: + try: + requested_page = int(parts[2]) + except ValueError: + requested_page = 1 + + if not page_id: + await callback.answer() + return + + page = await FaqService.get_page(db, page_id, db_user.language) + + if not page or not page.is_active: + await callback.answer( + texts.t("FAQ_PAGE_NOT_AVAILABLE", "Эта страница FAQ недоступна."), + show_alert=True, + ) + return + + content_pages = FaqService.split_content_into_pages(page.content) + + if not content_pages: + await callback.answer( + texts.t("FAQ_PAGE_EMPTY", "Текст для этой страницы ещё не добавлен."), + show_alert=True, + ) + return + + total_pages = len(content_pages) + current_page = max(1, min(requested_page, total_pages)) + + header = texts.t("FAQ_HEADER", "❓ FAQ") + title_template = texts.t("FAQ_PAGE_TITLE", "{title}") + page_title = (page.title or "").strip() + if not page_title: + page_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия") + title_block = title_template.format(title=html.escape(page_title)) + + body = content_pages[current_page - 1] + + footer_template = texts.t( + "FAQ_PAGE_FOOTER", + "Страница {current} из {total}", + ) + footer = "" + if total_pages > 1 and footer_template: + try: + footer = footer_template.format(current=current_page, total=total_pages) + except Exception: + footer = f"{current_page}/{total_pages}" + + parts_to_join = [header, title_block] + if body: + parts_to_join.append(body) + if footer: + parts_to_join.append(f"{footer}") + + message_text = "\n\n".join(segment for segment in parts_to_join if segment) + + keyboard_rows: list[list[types.InlineKeyboardButton]] = [] + + if total_pages > 1: + nav_row: list[types.InlineKeyboardButton] = [] + if current_page > 1: + nav_row.append( + types.InlineKeyboardButton( + text=texts.t("PAGINATION_PREV", "⬅️"), + callback_data=f"menu_faq_page:{page.id}:{current_page - 1}", + ) + ) + + nav_row.append( + types.InlineKeyboardButton( + text=f"{current_page}/{total_pages}", + callback_data="noop", + ) + ) + + if current_page < total_pages: + nav_row.append( + types.InlineKeyboardButton( + text=texts.t("PAGINATION_NEXT", "➡️"), + callback_data=f"menu_faq_page:{page.id}:{current_page + 1}", + ) + ) + + keyboard_rows.append(nav_row) + + keyboard_rows.append([ + types.InlineKeyboardButton( + text=texts.t("FAQ_BACK_TO_LIST", "⬅️ К списку FAQ"), + callback_data="menu_faq", + ) + ]) + keyboard_rows.append([ + types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info") + ]) + + await callback.message.edit_text( + message_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + ) + await callback.answer() + async def show_privacy_policy( callback: types.CallbackQuery, db_user: User, @@ -611,6 +782,16 @@ def register_handlers(dp: Dispatcher): F.data == "menu_info", ) + dp.callback_query.register( + show_faq_pages, + F.data == "menu_faq", + ) + + dp.callback_query.register( + show_faq_page, + F.data.startswith("menu_faq_page:"), + ) + dp.callback_query.register( show_privacy_policy, F.data == "menu_privacy_policy", diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 2a59970b..076fe30e 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -190,6 +190,12 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM callback_data="admin_public_offer", ) ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SETTINGS_FAQ", "❓ FAQ"), + callback_data="admin_faq", + ) + ], [ InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") ] diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 15624917..0d86e119 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -284,11 +284,20 @@ def get_info_menu_keyboard( language: str = DEFAULT_LANGUAGE, show_privacy_policy: bool = False, show_public_offer: bool = False, + show_faq: bool = False, ) -> InlineKeyboardMarkup: texts = get_texts(language) buttons: List[List[InlineKeyboardButton]] = [] + if show_faq: + buttons.append([ + InlineKeyboardButton( + text=texts.t("MENU_FAQ", "❓ FAQ"), + callback_data="menu_faq", + ) + ]) + if show_privacy_policy: buttons.append([ InlineKeyboardButton( diff --git a/app/services/faq_service.py b/app/services/faq_service.py new file mode 100644 index 00000000..e9b5986e --- /dev/null +++ b/app/services/faq_service.py @@ -0,0 +1,269 @@ +import logging +from typing import List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.faq import ( + bulk_update_order, + create_faq_page, + delete_faq_page, + get_faq_page_by_id, + get_faq_pages, + get_faq_setting, + set_faq_enabled, + update_faq_page, +) +from app.database.models import FaqPage, FaqSetting + + +logger = logging.getLogger(__name__) + + +class FaqService: + MAX_PAGE_LENGTH = 3500 + + @staticmethod + def _normalize_language(language: str) -> str: + base_language = language or settings.DEFAULT_LANGUAGE or "ru" + return base_language.split("-")[0].lower() + + @staticmethod + def normalize_language(language: str) -> str: + return FaqService._normalize_language(language) + + @classmethod + async def get_setting( + cls, + db: AsyncSession, + language: str, + *, + fallback: bool = True, + ) -> Optional[FaqSetting]: + lang = cls._normalize_language(language) + setting = await get_faq_setting(db, lang) + + if setting or not fallback: + return setting + + default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE) + if lang != default_lang: + return await get_faq_setting(db, default_lang) + + return setting + + @classmethod + async def is_enabled(cls, db: AsyncSession, language: str) -> bool: + pages = await cls.get_pages(db, language) + return bool(pages) + + @classmethod + async def set_enabled( + cls, + db: AsyncSession, + language: str, + enabled: bool, + ) -> FaqSetting: + lang = cls._normalize_language(language) + return await set_faq_enabled(db, lang, enabled) + + @classmethod + async def toggle_enabled( + cls, + db: AsyncSession, + language: str, + ) -> FaqSetting: + lang = cls._normalize_language(language) + setting = await get_faq_setting(db, lang) + new_status = True + if setting: + new_status = not setting.is_enabled + return await set_faq_enabled(db, lang, new_status) + + @classmethod + async def get_pages( + cls, + db: AsyncSession, + language: str, + *, + include_inactive: bool = False, + fallback: bool = True, + ) -> List[FaqPage]: + lang = cls._normalize_language(language) + pages = await get_faq_pages(db, lang, include_inactive=include_inactive) + + if pages: + setting = await get_faq_setting(db, lang) + if setting and not setting.is_enabled and not include_inactive: + return [] + return pages + + if not fallback: + return [] + + default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE) + if lang == default_lang: + return [] + + fallback_pages = await get_faq_pages( + db, + default_lang, + include_inactive=include_inactive, + ) + + if not fallback_pages: + return [] + + setting = await get_faq_setting(db, default_lang) + if setting and not setting.is_enabled and not include_inactive: + return [] + + return fallback_pages + + @classmethod + async def get_page( + cls, + db: AsyncSession, + page_id: int, + language: str, + *, + fallback: bool = True, + include_inactive: bool = False, + ) -> Optional[FaqPage]: + page = await get_faq_page_by_id(db, page_id) + if not page: + return None + + lang = cls._normalize_language(language) + default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE) + + if not include_inactive and not page.is_active: + return None + + if page.language == lang: + return page + + if fallback and page.language == default_lang: + return page + + return None + + @classmethod + async def create_page( + cls, + db: AsyncSession, + *, + language: str, + title: str, + content: str, + ) -> FaqPage: + lang = cls._normalize_language(language) + page = await create_faq_page( + db, + language=lang, + title=title, + content=content, + is_active=True, + ) + + setting = await get_faq_setting(db, lang) + if not setting: + await set_faq_enabled(db, lang, True) + + return page + + @classmethod + async def update_page( + cls, + db: AsyncSession, + page: FaqPage, + *, + title: Optional[str] = None, + content: Optional[str] = None, + display_order: Optional[int] = None, + is_active: Optional[bool] = None, + ) -> FaqPage: + return await update_faq_page( + db, + page, + title=title, + content=content, + display_order=display_order, + is_active=is_active, + ) + + @classmethod + async def delete_page(cls, db: AsyncSession, page_id: int) -> None: + await delete_faq_page(db, page_id) + + @classmethod + async def reorder_pages( + cls, + db: AsyncSession, + language: str, + pages: List[FaqPage], + ) -> None: + lang = cls._normalize_language(language) + ordered = [page for page in pages if page.language == lang] + payload = [(page.id, index + 1) for index, page in enumerate(ordered)] + await bulk_update_order(db, payload) + + @staticmethod + def split_content_into_pages( + content: str, + *, + max_length: Optional[int] = None, + ) -> List[str]: + if not content: + return [] + + normalized = content.replace("\r\n", "\n").strip() + if not normalized: + return [] + + limit = max_length or FaqService.MAX_PAGE_LENGTH + if len(normalized) <= limit: + return [normalized] + + paragraphs = [ + paragraph.strip() + for paragraph in normalized.split("\n\n") + if paragraph.strip() + ] + + pages: List[str] = [] + current = "" + + def flush_current() -> None: + nonlocal current + if current: + pages.append(current.strip()) + current = "" + + for paragraph in paragraphs: + candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph + if len(candidate) <= limit: + current = candidate + continue + + flush_current() + + if len(paragraph) <= limit: + current = paragraph + continue + + start = 0 + while start < len(paragraph): + chunk = paragraph[start : start + limit] + pages.append(chunk.strip()) + start += limit + + current = "" + + flush_current() + + if not pages: + return [normalized[:limit]] + + return pages + diff --git a/app/states.py b/app/states.py index 7527313b..b7eb5765 100644 --- a/app/states.py +++ b/app/states.py @@ -89,6 +89,10 @@ class AdminStates(StatesGroup): editing_rules_page = State() editing_privacy_policy = State() editing_public_offer = State() + creating_faq_title = State() + creating_faq_content = State() + editing_faq_title = State() + editing_faq_content = State() editing_notification_value = State() confirming_sync = State() diff --git a/locales/en.json b/locales/en.json index 8869e22e..3556bc86 100644 --- a/locales/en.json +++ b/locales/en.json @@ -333,6 +333,16 @@ "MENU_INFO": "ℹ️ Info", "MENU_INFO_HEADER": "ℹ️ Info", "MENU_INFO_PROMPT": "Choose a section:", + "MENU_FAQ": "❓ FAQ", + "FAQ_HEADER": "❓ FAQ", + "FAQ_PAGES_PROMPT": "Select a topic:", + "FAQ_NOT_AVAILABLE": "FAQ is temporarily unavailable.", + "FAQ_PAGE_UNTITLED": "Untitled", + "FAQ_PAGE_NOT_AVAILABLE": "This FAQ page is not available.", + "FAQ_PAGE_EMPTY": "Content for this page has not been added yet.", + "FAQ_PAGE_TITLE": "{title}", + "FAQ_PAGE_FOOTER": "Page {current} of {total}", + "FAQ_BACK_TO_LIST": "⬅️ Back to FAQ list", "MENU_PRIVACY_POLICY": "🛡️ Privacy policy", "PRIVACY_POLICY_HEADER": "🛡️ Privacy policy", "PRIVACY_POLICY_NOT_AVAILABLE": "The privacy policy is temporarily unavailable.", @@ -1059,6 +1069,7 @@ "PUBLIC_OFFER_HEADER": "📄 Public Offer", "PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}", "ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer", + "ADMIN_SETTINGS_FAQ": "❓ FAQ", "ADMIN_PUBLIC_OFFER_HEADER": "📄 Public offer", "ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.", "ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: {lang}", @@ -1074,6 +1085,56 @@ "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display", "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display", "ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML help", + "ADMIN_FAQ_HEADER": "❓ FAQ", + "ADMIN_FAQ_DESCRIPTION": "FAQ is shown in the “Info” section.", + "ADMIN_FAQ_LANGUAGE": "Language: {lang}", + "ADMIN_FAQ_PAGE_STATS": "Total pages: {total}", + "ADMIN_FAQ_STATUS_DISABLED": "⚠️ FAQ display is turned off.", + "ADMIN_FAQ_STATUS_ENABLED": "✅ FAQ is enabled. Active pages: {count}.", + "ADMIN_FAQ_STATUS_ENABLED_EMPTY": "⚠️ FAQ is enabled but there are no active pages.", + "ADMIN_FAQ_STATUS_EMPTY": "⚠️ FAQ has not been configured yet.", + "ADMIN_FAQ_PAGES_EMPTY": "No pages have been created yet.", + "ADMIN_FAQ_PAGES_OVERVIEW": "Page list:\n{items}", + "ADMIN_FAQ_ACTION_PROMPT": "Choose an action:", + "ADMIN_FAQ_ADD_PAGE_BUTTON": "➕ Add page", + "ADMIN_FAQ_ENABLE_BUTTON": "✅ Enable display", + "ADMIN_FAQ_DISABLE_BUTTON": "🚫 Disable display", + "ADMIN_FAQ_HTML_HELP": "ℹ️ HTML help", + "ADMIN_FAQ_ENTER_TITLE": "Enter a title for the new FAQ page:", + "ADMIN_FAQ_CANCEL_BUTTON": "⬅️ Cancel", + "ADMIN_FAQ_TITLE_EMPTY": "❌ Title cannot be empty.", + "ADMIN_FAQ_TITLE_TOO_LONG": "❌ Title is too long. Maximum 255 characters.", + "ADMIN_FAQ_ENTER_CONTENT": "Send the content of the FAQ page. HTML is allowed.", + "ADMIN_FAQ_CONTENT_TOO_LONG": "❌ Text is too long. Maximum 6000 characters.", + "ADMIN_FAQ_CONTENT_EMPTY": "❌ Text cannot be empty.", + "ADMIN_FAQ_HTML_ERROR": "❌ HTML error: {error}", + "ADMIN_FAQ_PAGE_CREATED": "✅ FAQ page created.", + "ADMIN_FAQ_BACK_TO_LIST": "⬅️ Back to FAQ settings", + "ADMIN_FAQ_ENABLED_ALERT": "✅ FAQ enabled.", + "ADMIN_FAQ_DISABLED_ALERT": "🚫 FAQ disabled.", + "ADMIN_FAQ_PAGE_NOT_FOUND": "⚠️ Page not found.", + "ADMIN_FAQ_PAGE_HEADER": "📄 FAQ page", + "ADMIN_FAQ_PAGE_STATUS_ACTIVE": "✅ Active", + "ADMIN_FAQ_PAGE_STATUS_INACTIVE": "🚫 Disabled", + "ADMIN_FAQ_PAGE_UPDATED": "Updated: {timestamp}", + "ADMIN_FAQ_PAGE_PREVIEW_EMPTY": "Content has not been provided yet.", + "ADMIN_FAQ_PAGE_PREVIEW": "Preview:\n{content}", + "ADMIN_FAQ_PAGE_TITLE": "Title: {title}", + "ADMIN_FAQ_PAGE_STATUS": "Status: {status}", + "ADMIN_FAQ_EDIT_TITLE_BUTTON": "✏️ Edit title", + "ADMIN_FAQ_EDIT_CONTENT_BUTTON": "📝 Edit text", + "ADMIN_FAQ_PAGE_ENABLE_BUTTON": "✅ Enable page", + "ADMIN_FAQ_PAGE_DISABLE_BUTTON": "🚫 Disable page", + "ADMIN_FAQ_PAGE_MOVE_UP": "⬆️ Up", + "ADMIN_FAQ_PAGE_MOVE_DOWN": "⬇️ Down", + "ADMIN_FAQ_PAGE_DELETE_BUTTON": "🗑️ Delete", + "ADMIN_FAQ_TITLE_UPDATED": "✅ Title updated.", + "ADMIN_FAQ_UNEXPECTED_STATE": "⚠️ State was reset.", + "ADMIN_FAQ_CONTENT_UPDATED": "✅ Page text updated.", + "ADMIN_FAQ_PAGE_ENABLED_ALERT": "✅ Page enabled.", + "ADMIN_FAQ_PAGE_DISABLED_ALERT": "🚫 Page disabled.", + "ADMIN_FAQ_PAGE_DELETED": "🗑️ Page deleted.", + "ADMIN_FAQ_PAGE_REORDERED": "✅ Order updated.", "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):", "ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.", "ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.", diff --git a/locales/ru.json b/locales/ru.json index 868f9e4f..c68624a4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -246,6 +246,16 @@ "MENU_INFO": "ℹ️ Инфо", "MENU_INFO_HEADER": "ℹ️ Инфо", "MENU_INFO_PROMPT": "Выберите раздел:", + "MENU_FAQ": "❓ FAQ", + "FAQ_HEADER": "❓ FAQ", + "FAQ_PAGES_PROMPT": "Выберите вопрос:", + "FAQ_NOT_AVAILABLE": "FAQ временно недоступен.", + "FAQ_PAGE_UNTITLED": "Без названия", + "FAQ_PAGE_NOT_AVAILABLE": "Эта страница FAQ недоступна.", + "FAQ_PAGE_EMPTY": "Текст для этой страницы ещё не добавлен.", + "FAQ_PAGE_TITLE": "{title}", + "FAQ_PAGE_FOOTER": "Страница {current} из {total}", + "FAQ_BACK_TO_LIST": "⬅️ К списку FAQ", "MENU_LANGUAGE": "🌐 Язык", "MENU_PROMOCODE": "🎫 Промокод", "MENU_REFERRALS": "🤝 Партнерка", @@ -1023,6 +1033,7 @@ "PUBLIC_OFFER_HEADER": "📄 Публичная оферта", "PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}", "ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта", + "ADMIN_SETTINGS_FAQ": "❓ FAQ", "ADMIN_PUBLIC_OFFER_HEADER": "📄 Публичная оферта", "ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».", "ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: {lang}", @@ -1038,6 +1049,56 @@ "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ", "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ", "ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML помощь", + "ADMIN_FAQ_HEADER": "❓ FAQ", + "ADMIN_FAQ_DESCRIPTION": "FAQ отображается в разделе «Инфо».", + "ADMIN_FAQ_LANGUAGE": "Язык: {lang}", + "ADMIN_FAQ_PAGE_STATS": "Всего страниц: {total}", + "ADMIN_FAQ_STATUS_DISABLED": "⚠️ Показ FAQ выключен.", + "ADMIN_FAQ_STATUS_ENABLED": "✅ FAQ включён. Активных страниц: {count}.", + "ADMIN_FAQ_STATUS_ENABLED_EMPTY": "⚠️ FAQ включён, но нет активных страниц.", + "ADMIN_FAQ_STATUS_EMPTY": "⚠️ FAQ ещё не настроен.", + "ADMIN_FAQ_PAGES_EMPTY": "Страницы ещё не созданы.", + "ADMIN_FAQ_PAGES_OVERVIEW": "Список страниц:\n{items}", + "ADMIN_FAQ_ACTION_PROMPT": "Выберите действие:", + "ADMIN_FAQ_ADD_PAGE_BUTTON": "➕ Добавить страницу", + "ADMIN_FAQ_ENABLE_BUTTON": "✅ Включить показ", + "ADMIN_FAQ_DISABLE_BUTTON": "🚫 Отключить показ", + "ADMIN_FAQ_HTML_HELP": "ℹ️ HTML помощь", + "ADMIN_FAQ_ENTER_TITLE": "Введите заголовок для новой страницы FAQ:", + "ADMIN_FAQ_CANCEL_BUTTON": "⬅️ Отмена", + "ADMIN_FAQ_TITLE_EMPTY": "❌ Заголовок не может быть пустым.", + "ADMIN_FAQ_TITLE_TOO_LONG": "❌ Заголовок слишком длинный. Максимум 255 символов.", + "ADMIN_FAQ_ENTER_CONTENT": "Отправьте содержимое страницы FAQ. Допускается HTML.", + "ADMIN_FAQ_CONTENT_TOO_LONG": "❌ Текст слишком длинный. Максимум 6000 символов.", + "ADMIN_FAQ_CONTENT_EMPTY": "❌ Текст не может быть пустым.", + "ADMIN_FAQ_HTML_ERROR": "❌ Ошибка в HTML: {error}", + "ADMIN_FAQ_PAGE_CREATED": "✅ Страница FAQ создана.", + "ADMIN_FAQ_BACK_TO_LIST": "⬅️ К настройкам FAQ", + "ADMIN_FAQ_ENABLED_ALERT": "✅ FAQ включён.", + "ADMIN_FAQ_DISABLED_ALERT": "🚫 FAQ отключён.", + "ADMIN_FAQ_PAGE_NOT_FOUND": "⚠️ Страница не найдена.", + "ADMIN_FAQ_PAGE_HEADER": "📄 Страница FAQ", + "ADMIN_FAQ_PAGE_STATUS_ACTIVE": "✅ Активна", + "ADMIN_FAQ_PAGE_STATUS_INACTIVE": "🚫 Выключена", + "ADMIN_FAQ_PAGE_UPDATED": "Обновлено: {timestamp}", + "ADMIN_FAQ_PAGE_PREVIEW_EMPTY": "Текст ещё не задан.", + "ADMIN_FAQ_PAGE_PREVIEW": "Превью:\n{content}", + "ADMIN_FAQ_PAGE_TITLE": "Заголовок: {title}", + "ADMIN_FAQ_PAGE_STATUS": "Статус: {status}", + "ADMIN_FAQ_EDIT_TITLE_BUTTON": "✏️ Изменить заголовок", + "ADMIN_FAQ_EDIT_CONTENT_BUTTON": "📝 Изменить текст", + "ADMIN_FAQ_PAGE_ENABLE_BUTTON": "✅ Включить страницу", + "ADMIN_FAQ_PAGE_DISABLE_BUTTON": "🚫 Выключить страницу", + "ADMIN_FAQ_PAGE_MOVE_UP": "⬆️ Выше", + "ADMIN_FAQ_PAGE_MOVE_DOWN": "⬇️ Ниже", + "ADMIN_FAQ_PAGE_DELETE_BUTTON": "🗑️ Удалить", + "ADMIN_FAQ_TITLE_UPDATED": "✅ Заголовок обновлён.", + "ADMIN_FAQ_UNEXPECTED_STATE": "⚠️ Состояние сброшено.", + "ADMIN_FAQ_CONTENT_UPDATED": "✅ Текст страницы обновлён.", + "ADMIN_FAQ_PAGE_ENABLED_ALERT": "✅ Страница включена.", + "ADMIN_FAQ_PAGE_DISABLED_ALERT": "🚫 Страница выключена.", + "ADMIN_FAQ_PAGE_DELETED": "🗑️ Страница удалена.", + "ADMIN_FAQ_PAGE_REORDERED": "✅ Порядок обновлён.", "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):", "ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.", "ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",