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 для справки по тегам.",