diff --git a/app/bot.py b/app/bot.py index ee2eda72..997b6ecb 100644 --- a/app/bot.py +++ b/app/bot.py @@ -43,6 +43,7 @@ from app.handlers.admin import ( reports as admin_reports, bot_configuration as admin_bot_configuration, pricing as admin_pricing, + privacy_policy as admin_privacy_policy, ) from app.handlers.stars_payments import register_stars_handlers @@ -149,6 +150,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_reports.register_handlers(dp) admin_bot_configuration.register_handlers(dp) admin_pricing.register_handlers(dp) + admin_privacy_policy.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/database/crud/privacy_policy.py b/app/database/crud/privacy_policy.py new file mode 100644 index 00000000..627a0e5e --- /dev/null +++ b/app/database/crud/privacy_policy.py @@ -0,0 +1,79 @@ +import logging +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import PrivacyPolicy + +logger = logging.getLogger(__name__) + + +async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]: + result = await db.execute( + select(PrivacyPolicy).where(PrivacyPolicy.language == language) + ) + return result.scalar_one_or_none() + + +async def upsert_privacy_policy( + db: AsyncSession, + language: str, + content: str, + *, + enable_if_new: bool = True, +) -> PrivacyPolicy: + policy = await get_privacy_policy(db, language) + + if policy: + policy.content = content or "" + policy.updated_at = datetime.utcnow() + else: + policy = PrivacyPolicy( + language=language, + content=content or "", + is_enabled=True if enable_if_new else False, + ) + db.add(policy) + + await db.commit() + await db.refresh(policy) + + logger.info( + "✅ Политика конфиденциальности для языка %s обновлена (ID: %s)", + language, + policy.id, + ) + + return policy + + +async def set_privacy_policy_enabled( + db: AsyncSession, + language: str, + enabled: bool, +) -> PrivacyPolicy: + policy = await get_privacy_policy(db, language) + + if policy: + policy.is_enabled = bool(enabled) + policy.updated_at = datetime.utcnow() + else: + policy = PrivacyPolicy( + language=language, + content="", + is_enabled=bool(enabled), + ) + db.add(policy) + + await db.commit() + await db.refresh(policy) + + logger.info( + "✅ Статус политики конфиденциальности для языка %s обновлен: %s", + language, + "enabled" if policy.is_enabled else "disabled", + ) + + return policy diff --git a/app/database/models.py b/app/database/models.py index 1d970966..f48f2f47 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -744,9 +744,9 @@ class Squad(Base): class ServiceRule(Base): __tablename__ = "service_rules" - + id = Column(Integer, primary_key=True, index=True) - + order = Column(Integer, default=0) title = Column(String(255), nullable=False) @@ -760,6 +760,17 @@ class ServiceRule(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) +class PrivacyPolicy(Base): + __tablename__ = "privacy_policies" + + id = Column(Integer, primary_key=True, index=True) + language = Column(String(10), nullable=False, unique=True) + content = Column(Text, nullable=False) + 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 SystemSetting(Base): __tablename__ = "system_settings" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 77ee3c55..a29a748f 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -2500,6 +2500,59 @@ async def create_web_api_tokens_table() -> bool: return False +async def create_privacy_policies_table() -> bool: + table_exists = await check_table_exists("privacy_policies") + if table_exists: + logger.info("ℹ️ Таблица privacy_policies уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + create_sql = """ + CREATE TABLE privacy_policies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + language VARCHAR(10) NOT NULL UNIQUE, + content TEXT NOT NULL, + 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 privacy_policies ( + id SERIAL PRIMARY KEY, + language VARCHAR(10) NOT NULL UNIQUE, + content TEXT NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + """ + else: + create_sql = """ + CREATE TABLE privacy_policies ( + id INT AUTO_INCREMENT PRIMARY KEY, + language VARCHAR(10) NOT NULL UNIQUE, + content TEXT NOT NULL, + 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("✅ Таблица privacy_policies создана") + return True + + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы privacy_policies: {error}") + return False + + async def ensure_default_web_api_token() -> bool: default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip() if not default_token: @@ -2582,6 +2635,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей web_api_tokens") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PRIVACY_POLICIES ===") + privacy_policies_ready = await create_privacy_policies_table() + if privacy_policies_ready: + logger.info("✅ Таблица privacy_policies готова") + else: + logger.warning("⚠️ Проблемы с таблицей privacy_policies") + logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===") default_token_ready = await ensure_default_web_api_token() if default_token_ready: @@ -2868,6 +2928,7 @@ async def check_migration_status(): "subscription_conversions_table": False, "promo_groups_table": False, "server_promo_groups_table": False, + "privacy_policies_table": False, "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, @@ -2892,6 +2953,7 @@ async def check_migration_status(): status["cryptobot_table"] = await check_table_exists('cryptobot_payments') status["user_messages_table"] = await check_table_exists('user_messages') status["welcome_texts_table"] = await check_table_exists('welcome_texts') + status["privacy_policies_table"] = await check_table_exists('privacy_policies') status["subscription_conversions_table"] = await check_table_exists('subscription_conversions') status["promo_groups_table"] = await check_table_exists('promo_groups') status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups') @@ -2941,6 +3003,7 @@ async def check_migration_status(): "cryptobot_table": "Таблица CryptoBot payments", "user_messages_table": "Таблица пользовательских сообщений", "welcome_texts_table": "Таблица приветственных текстов", + "privacy_policies_table": "Таблица политик конфиденциальности", "welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts", "broadcast_history_media_fields": "Медиа поля в broadcast_history", "subscription_conversions_table": "Таблица конверсий подписок", diff --git a/app/handlers/admin/privacy_policy.py b/app/handlers/admin/privacy_policy.py new file mode 100644 index 00000000..5f6a8b9f --- /dev/null +++ b/app/handlers/admin/privacy_policy.py @@ -0,0 +1,512 @@ +import html +import logging +from datetime import datetime + +from aiogram import Dispatcher, types, F +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.privacy_policy_service import PrivacyPolicyService +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler +from app.utils.validators import validate_html_tags, get_html_help_text + +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) + policy = await PrivacyPolicyService.get_policy( + db, + db_user.language, + fallback=False, + ) + + normalized_language = PrivacyPolicyService.normalize_language(db_user.language) + has_content = bool(policy and policy.content and policy.content.strip()) + + description = texts.t( + "ADMIN_PRIVACY_POLICY_DESCRIPTION", + "Политика конфиденциальности отображается в разделе «Инфо».", + ) + + status_text = texts.t( + "ADMIN_PRIVACY_POLICY_STATUS_DISABLED", + "⚠️ Показ политики выключен или текст отсутствует.", + ) + if policy and policy.is_enabled and has_content: + status_text = texts.t( + "ADMIN_PRIVACY_POLICY_STATUS_ENABLED", + "✅ Политика активна и показывается пользователям.", + ) + elif policy and policy.is_enabled: + status_text = texts.t( + "ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY", + "⚠️ Политика включена, но текст пуст — пользователи её не увидят.", + ) + + updated_at = _format_timestamp(getattr(policy, "updated_at", None)) + updated_block = "" + if updated_at: + updated_block = texts.t( + "ADMIN_PRIVACY_POLICY_UPDATED_AT", + "Последнее обновление: {timestamp}", + ).format(timestamp=updated_at) + + preview_block = texts.t( + "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY", + "Текст ещё не задан.", + ) + if has_content: + preview_title = texts.t( + "ADMIN_PRIVACY_POLICY_PREVIEW_TITLE", + "Превью текста:", + ) + preview_raw = policy.content.strip() + preview_trimmed = preview_raw[:400] + if len(preview_raw) > 400: + preview_trimmed += "..." + preview_block = ( + f"{preview_title}\n" + f"{html.escape(preview_trimmed)}" + ) + + language_block = texts.t( + "ADMIN_PRIVACY_POLICY_LANGUAGE", + "Язык: {lang}", + ).format(lang=normalized_language) + + header = texts.t( + "ADMIN_PRIVACY_POLICY_HEADER", + "🛡️ Политика конфиденциальности", + ) + actions_prompt = texts.t( + "ADMIN_PRIVACY_POLICY_ACTION_PROMPT", + "Выберите действие:", + ) + + message_parts = [ + header, + description, + language_block, + status_text, + ] + + if updated_block: + message_parts.append(updated_block) + + message_parts.append(preview_block) + message_parts.append(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_PRIVACY_POLICY_EDIT_BUTTON", + "✏️ Изменить текст", + ), + callback_data="admin_privacy_policy_edit", + ) + ]) + + if has_content: + buttons.append([ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PRIVACY_POLICY_VIEW_BUTTON", + "👀 Просмотреть текущий текст", + ), + callback_data="admin_privacy_policy_view", + ) + ]) + + toggle_text = texts.t( + "ADMIN_PRIVACY_POLICY_ENABLE_BUTTON", + "✅ Включить показ", + ) + if policy and policy.is_enabled: + toggle_text = texts.t( + "ADMIN_PRIVACY_POLICY_DISABLE_BUTTON", + "🚫 Отключить показ", + ) + + buttons.append([ + types.InlineKeyboardButton( + text=toggle_text, + callback_data="admin_privacy_policy_toggle", + ) + ]) + + buttons.append([ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PRIVACY_POLICY_HTML_HELP", + "ℹ️ HTML помощь", + ), + callback_data="admin_privacy_policy_help", + ) + ]) + + buttons.append([ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_submenu_settings", + ) + ]) + + return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy + + +@admin_required +@error_handler +async def show_privacy_policy_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_privacy_policy( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language) + logger.info( + "Админ %s переключил показ политики конфиденциальности: %s", + db_user.telegram_id, + "enabled" if updated_policy.is_enabled else "disabled", + ) + status_message = ( + texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена") + if updated_policy.is_enabled + else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена") + ) + + overview_text, markup, _ = await _build_overview(db_user, db) + await callback.message.edit_text( + overview_text, + reply_markup=markup, + ) + await callback.answer(status_message) + + +@admin_required +@error_handler +async def start_edit_privacy_policy( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + policy = await PrivacyPolicyService.get_policy( + db, + db_user.language, + fallback=False, + ) + + current_preview = "" + if policy and policy.content: + preview = policy.content.strip()[:400] + if len(policy.content.strip()) > 400: + preview += "..." + current_preview = ( + texts.t( + "ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW", + "Текущий текст (превью):", + ) + + f"\n{html.escape(preview)}\n\n" + ) + + prompt = texts.t( + "ADMIN_PRIVACY_POLICY_EDIT_PROMPT", + "Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.", + ) + + hint = texts.t( + "ADMIN_PRIVACY_POLICY_EDIT_HINT", + "Используйте /html_help для справки по тегам.", + ) + + message_text = ( + f"📝 {texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}\n\n" + f"{current_preview}{prompt}\n\n{hint}" + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PRIVACY_POLICY_HTML_HELP", + "ℹ️ HTML помощь", + ), + callback_data="admin_privacy_policy_help", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"), + callback_data="admin_privacy_policy_cancel", + ) + ], + ] + ) + + await callback.message.edit_text(message_text, reply_markup=keyboard) + await state.set_state(AdminStates.editing_privacy_policy) + await callback.answer() + + +@admin_required +@error_handler +async def cancel_edit_privacy_policy( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + await state.clear() + 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 process_privacy_policy_edit( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + new_text = message.text or "" + + if len(new_text) > 4000: + await message.answer( + texts.t( + "ADMIN_PRIVACY_POLICY_TOO_LONG", + "❌ Текст политики слишком длинный. Максимум 4000 символов.", + ) + ) + return + + is_valid, error_message = validate_html_tags(new_text) + if not is_valid: + await message.answer( + texts.t( + "ADMIN_PRIVACY_POLICY_HTML_ERROR", + "❌ Ошибка в HTML: {error}", + ).format(error=error_message) + ) + return + + await PrivacyPolicyService.save_policy(db, db_user.language, new_text) + logger.info( + "Админ %s обновил текст политики конфиденциальности (%d символов)", + db_user.telegram_id, + len(new_text), + ) + await state.clear() + + success_text = texts.t( + "ADMIN_PRIVACY_POLICY_SAVED", + "✅ Политика конфиденциальности обновлена.", + ) + + reply_markup = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PRIVACY_POLICY_BACK_BUTTON", + "⬅️ К настройкам политики", + ), + callback_data="admin_privacy_policy", + ) + ] + ] + ) + + await message.answer(success_text, reply_markup=reply_markup) + + +@admin_required +@error_handler +async def view_privacy_policy( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + policy = await PrivacyPolicyService.get_policy( + db, + db_user.language, + fallback=False, + ) + + if not policy or not policy.content or not policy.content.strip(): + await callback.answer( + texts.t( + "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT", + "Текст политики пока не задан.", + ), + show_alert=True, + ) + return + + content = policy.content.strip() + truncated = False + max_length = 3800 + if len(content) > max_length: + content = content[: max_length - 3] + "..." + truncated = True + + header = texts.t( + "ADMIN_PRIVACY_POLICY_VIEW_TITLE", + "👀 Текущий текст политики", + ) + + note = "" + if truncated: + note = texts.t( + "ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED", + "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.", + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PRIVACY_POLICY_BACK_BUTTON", + "⬅️ К настройкам политики", + ), + callback_data="admin_privacy_policy", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PRIVACY_POLICY_EDIT_BUTTON", + "✏️ Изменить текст", + ), + callback_data="admin_privacy_policy_edit", + ) + ], + ] + ) + + await callback.message.edit_text( + f"{header}\n\n{content}{note}", + reply_markup=keyboard, + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_privacy_policy_html_help( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + help_text = get_html_help_text() + + current_state = await state.get_state() + + buttons: list[list[types.InlineKeyboardButton]] = [] + + if current_state == AdminStates.editing_privacy_policy.state: + buttons.append([ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT", + "⬅️ Назад к редактированию", + ), + callback_data="admin_privacy_policy_edit", + ) + ]) + + buttons.append([ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PRIVACY_POLICY_BACK_BUTTON", + "⬅️ К настройкам политики", + ), + callback_data="admin_privacy_policy", + ) + ]) + + 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_privacy_policy_management, + F.data == "admin_privacy_policy", + ) + dp.callback_query.register( + toggle_privacy_policy, + F.data == "admin_privacy_policy_toggle", + ) + dp.callback_query.register( + start_edit_privacy_policy, + F.data == "admin_privacy_policy_edit", + ) + dp.callback_query.register( + cancel_edit_privacy_policy, + F.data == "admin_privacy_policy_cancel", + ) + dp.callback_query.register( + view_privacy_policy, + F.data == "admin_privacy_policy_view", + ) + dp.callback_query.register( + show_privacy_policy_html_help, + F.data == "admin_privacy_policy_help", + ) + + dp.message.register( + process_privacy_policy_edit, + AdminStates.editing_privacy_policy, + ) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 3bb757f0..fab18d5b 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -25,6 +25,7 @@ from app.utils.promo_offer import ( build_promo_offer_hint, build_test_access_hint, ) +from app.services.privacy_policy_service import PrivacyPolicyService logger = logging.getLogger(__name__) @@ -110,15 +111,127 @@ async def show_info_menu( prompt = texts.t("MENU_INFO_PROMPT", "Выберите раздел:") caption = f"{header}\n\n{prompt}" if prompt else header + privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language) + await edit_or_answer_photo( callback=callback, caption=caption, - keyboard=get_info_menu_keyboard(language=db_user.language), + keyboard=get_info_menu_keyboard( + language=db_user.language, + show_privacy_policy=privacy_enabled, + ), parse_mode="HTML", ) await callback.answer() +async def show_privacy_policy( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + raw_page = 1 + if callback.data and ":" in callback.data: + try: + raw_page = int(callback.data.split(":", 1)[1]) + except ValueError: + raw_page = 1 + + if raw_page < 1: + raw_page = 1 + + policy = await PrivacyPolicyService.get_active_policy(db, db_user.language) + + if not policy: + await callback.answer( + texts.t( + "PRIVACY_POLICY_NOT_AVAILABLE", + "Политика конфиденциальности временно недоступна.", + ), + show_alert=True, + ) + return + + pages = PrivacyPolicyService.split_content_into_pages(policy.content) + + if not pages: + await callback.answer( + texts.t( + "PRIVACY_POLICY_EMPTY_ALERT", + "Политика конфиденциальности ещё не заполнена.", + ), + show_alert=True, + ) + return + + total_pages = len(pages) + current_page = raw_page if raw_page <= total_pages else total_pages + + header = texts.t( + "PRIVACY_POLICY_HEADER", + "🛡️ Политика конфиденциальности", + ) + body = pages[current_page - 1] + + footer_template = texts.t( + "PRIVACY_POLICY_PAGE_INFO", + "Страница {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}" + + message_text = header + if body: + message_text += f"\n\n{body}" + if footer: + message_text += f"\n\n{footer}" + + 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_privacy_policy:{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_privacy_policy:{current_page + 1}", + ) + ) + + keyboard_rows.append(nav_row) + + 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_language_menu( callback: types.CallbackQuery, db_user: User, @@ -388,6 +501,16 @@ def register_handlers(dp: Dispatcher): F.data == "menu_info", ) + dp.callback_query.register( + show_privacy_policy, + F.data == "menu_privacy_policy", + ) + + dp.callback_query.register( + show_privacy_policy, + F.data.startswith("menu_privacy_policy:"), + ) + dp.callback_query.register( show_language_menu, F.data == "menu_language" diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 2f5200cf..72a66fbd 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -178,6 +178,12 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM callback_data="maintenance_panel" ) ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SETTINGS_PRIVACY_POLICY", "🛡️ Политика конф."), + callback_data="admin_privacy_policy", + ) + ], [ InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") ] diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 4d55d594..5bfa995b 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -280,12 +280,25 @@ def get_main_menu_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_info_menu_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: +def get_info_menu_keyboard( + language: str = DEFAULT_LANGUAGE, + show_privacy_policy: bool = False, +) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons: List[List[InlineKeyboardButton]] = [ - [InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")] - ] + buttons: List[List[InlineKeyboardButton]] = [] + + if show_privacy_policy: + buttons.append([ + InlineKeyboardButton( + text=texts.t("MENU_PRIVACY_POLICY", "🛡️ Политика конф."), + callback_data="menu_privacy_policy", + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules") + ]) server_status_mode = settings.get_server_status_mode() server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов") diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index be98b240..f66baaec 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -234,6 +234,11 @@ "MENU_INFO": "ℹ️ Инфо", "MENU_INFO_HEADER": "ℹ️ Инфо", "MENU_INFO_PROMPT": "Выберите раздел:", + "MENU_PRIVACY_POLICY": "🛡️ Политика конф.", + "PRIVACY_POLICY_HEADER": "🛡️ Политика конфиденциальности", + "PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.", + "PRIVACY_POLICY_EMPTY_ALERT": "Политика конфиденциальности ещё не заполнена.", + "PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}", "MENU_LANGUAGE": "🌐 Язык", "MENU_PROMOCODE": "🎫 Промокод", "MENU_REFERRALS": "🤝 Партнерка", @@ -683,6 +688,37 @@ "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока", "ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n", "ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:", + "ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конф.", + "ADMIN_PRIVACY_POLICY_HEADER": "🛡️ Политика конфиденциальности", + "ADMIN_PRIVACY_POLICY_DESCRIPTION": "Политика конфиденциальности отображается в разделе «Инфо».", + "ADMIN_PRIVACY_POLICY_LANGUAGE": "Язык: {lang}", + "ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Показ политики выключен или текст отсутствует.", + "ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Политика активна и показывается пользователям.", + "ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Политика включена, но текст пуст — пользователи её не увидят.", + "ADMIN_PRIVACY_POLICY_UPDATED_AT": "Последнее обновление: {timestamp}", + "ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "Превью текста:", + "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "Текст ещё не задан.", + "ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Выберите действие:", + "ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Изменить текст", + "ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 Просмотреть текущий текст", + "ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Включить показ", + "ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Отключить показ", + "ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ HTML помощь", + "ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Текущий текст (превью):", + "ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.", + "ADMIN_PRIVACY_POLICY_EDIT_HINT": "Используйте /html_help для справки по тегам.", + "ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Редактирование политики", + "ADMIN_PRIVACY_POLICY_CANCEL": "❌ Отмена", + "ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ Текст политики слишком длинный. Максимум 4000 символов.", + "ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ Ошибка в HTML: {error}", + "ADMIN_PRIVACY_POLICY_SAVED": "✅ Политика конфиденциальности обновлена.", + "ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ К настройкам политики", + "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "Текст политики пока не задан.", + "ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 Текущий текст политики", + "ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.", + "ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена", + "ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена", + "ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию", "ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n", "ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:", "ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены", diff --git a/app/services/privacy_policy_service.py b/app/services/privacy_policy_service.py new file mode 100644 index 00000000..bba935fb --- /dev/null +++ b/app/services/privacy_policy_service.py @@ -0,0 +1,178 @@ +import logging +from typing import Optional, List + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.privacy_policy import ( + get_privacy_policy, + set_privacy_policy_enabled, + upsert_privacy_policy, +) +from app.database.models import PrivacyPolicy + +logger = logging.getLogger(__name__) + + +class PrivacyPolicyService: + """Utility helpers around privacy policy storage and presentation.""" + + 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 PrivacyPolicyService._normalize_language(language) + + @classmethod + async def get_policy( + cls, + db: AsyncSession, + language: str, + *, + fallback: bool = False, + ) -> Optional[PrivacyPolicy]: + lang = cls._normalize_language(language) + policy = await get_privacy_policy(db, lang) + + if policy or not fallback: + return policy + + default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE) + if lang != default_lang: + return await get_privacy_policy(db, default_lang) + + return policy + + @classmethod + async def get_active_policy( + cls, + db: AsyncSession, + language: str, + ) -> Optional[PrivacyPolicy]: + lang = cls._normalize_language(language) + policy = await get_privacy_policy(db, lang) + + if policy and policy.is_enabled and policy.content.strip(): + return policy + + default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE) + if lang != default_lang: + fallback_policy = await get_privacy_policy(db, default_lang) + if fallback_policy and fallback_policy.is_enabled and fallback_policy.content.strip(): + return fallback_policy + + return None + + @classmethod + async def is_policy_enabled(cls, db: AsyncSession, language: str) -> bool: + policy = await cls.get_active_policy(db, language) + return policy is not None + + @classmethod + async def save_policy( + cls, + db: AsyncSession, + language: str, + content: str, + ) -> PrivacyPolicy: + lang = cls._normalize_language(language) + enable_if_new = True + policy = await upsert_privacy_policy( + db, + lang, + content, + enable_if_new=enable_if_new, + ) + logger.info("✅ Политика конфиденциальности обновлена для языка %s", lang) + return policy + + @classmethod + async def set_enabled( + cls, + db: AsyncSession, + language: str, + enabled: bool, + ) -> PrivacyPolicy: + lang = cls._normalize_language(language) + return await set_privacy_policy_enabled(db, lang, enabled) + + @classmethod + async def toggle_enabled( + cls, + db: AsyncSession, + language: str, + ) -> PrivacyPolicy: + lang = cls._normalize_language(language) + policy = await get_privacy_policy(db, lang) + + if policy: + new_status = not policy.is_enabled + else: + new_status = True + + return await set_privacy_policy_enabled(db, lang, new_status) + + @staticmethod + def split_content_into_pages( + content: str, + *, + max_length: int = None, + ) -> List[str]: + if not content: + return [] + + normalized = content.replace("\r\n", "\n").strip() + if not normalized: + return [] + + max_len = max_length or PrivacyPolicyService.MAX_PAGE_LENGTH + + if len(normalized) <= max_len: + 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) <= max_len: + current = candidate + continue + + flush_current() + + if len(paragraph) <= max_len: + current = paragraph + continue + + start_index = 0 + while start_index < len(paragraph): + chunk = paragraph[start_index:start_index + max_len] + pages.append(chunk.strip()) + start_index += max_len + + current = "" + + flush_current() + + if not pages: + return [normalized[:max_len]] + + return pages diff --git a/app/states.py b/app/states.py index ab6a0eab..1e42df99 100644 --- a/app/states.py +++ b/app/states.py @@ -87,6 +87,7 @@ class AdminStates(StatesGroup): editing_user_traffic = State() editing_rules_page = State() + editing_privacy_policy = State() editing_notification_value = State() confirming_sync = State() diff --git a/locales/en.json b/locales/en.json index 6d008896..54cf63dd 100644 --- a/locales/en.json +++ b/locales/en.json @@ -333,6 +333,11 @@ "MENU_INFO": "ℹ️ Info", "MENU_INFO_HEADER": "ℹ️ Info", "MENU_INFO_PROMPT": "Choose a section:", + "MENU_PRIVACY_POLICY": "🛡️ Privacy policy", + "PRIVACY_POLICY_HEADER": "🛡️ Privacy policy", + "PRIVACY_POLICY_NOT_AVAILABLE": "The privacy policy is temporarily unavailable.", + "PRIVACY_POLICY_EMPTY_ALERT": "The privacy policy text has not been provided yet.", + "PRIVACY_POLICY_PAGE_INFO": "Page {current} of {total}", "MENU_PROMOCODE": "🎫 Promo code", "MENU_REFERRALS": "🤝 Referral program", "MENU_RULES": "📋 Service rules", @@ -753,6 +758,37 @@ "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock", "ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n", "ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:", + "ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Privacy policy", + "ADMIN_PRIVACY_POLICY_HEADER": "🛡️ Privacy policy", + "ADMIN_PRIVACY_POLICY_DESCRIPTION": "The privacy policy is shown in the Info section.", + "ADMIN_PRIVACY_POLICY_LANGUAGE": "Language: {lang}", + "ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Policy display is disabled or empty.", + "ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Policy is active and visible to users.", + "ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Policy is enabled but the text is empty — users won't see it.", + "ADMIN_PRIVACY_POLICY_UPDATED_AT": "Last updated: {timestamp}", + "ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "Text preview:", + "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "No text provided yet.", + "ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Choose an action:", + "ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Edit text", + "ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 View current text", + "ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Enable display", + "ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Disable display", + "ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ HTML help", + "ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Current text (preview):", + "ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Send the new privacy policy text. HTML markup is allowed.", + "ADMIN_PRIVACY_POLICY_EDIT_HINT": "Use /html_help for supported tags.", + "ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Privacy policy editing", + "ADMIN_PRIVACY_POLICY_CANCEL": "❌ Cancel", + "ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ The policy text is too long. Maximum 4000 characters.", + "ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ HTML error: {error}", + "ADMIN_PRIVACY_POLICY_SAVED": "✅ Privacy policy updated.", + "ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ Back to policy settings", + "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "The privacy policy text is not set yet.", + "ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 Current policy text", + "ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ The text is shortened for display. Users will see the full version in the menu.", + "ADMIN_PRIVACY_POLICY_ENABLED": "✅ Policy enabled", + "ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Policy disabled", + "ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Back to editing", "ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n", "ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:", "ADMIN_SETTINGS_BOT_CONFIG": "🧩 Bot configuration",