From ede9259347295efe5576983b1001da6e171da312 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 17:04:34 +0300 Subject: [PATCH] Import json in app settings service --- app/bot.py | 2 + app/config.py | 214 ++++++++++++++++++- app/database/crud/app_settings.py | 50 +++++ app/database/models.py | 10 + app/database/universal_migration.py | 106 +++++++++- app/handlers/admin/configuration.py | 300 +++++++++++++++++++++++++++ app/keyboards/admin.py | 87 +++++++- app/services/app_settings_service.py | 171 +++++++++++++++ app/states.py | 1 + main.py | 18 +- 10 files changed, 948 insertions(+), 11 deletions(-) create mode 100644 app/database/crud/app_settings.py create mode 100644 app/handlers/admin/configuration.py create mode 100644 app/services/app_settings_service.py diff --git a/app/bot.py b/app/bot.py index a13e2237..885928f6 100644 --- a/app/bot.py +++ b/app/bot.py @@ -39,6 +39,7 @@ from app.handlers.admin import ( welcome_text as admin_welcome_text, tickets as admin_tickets, reports as admin_reports, + configuration as admin_configuration, ) from app.handlers.stars_payments import register_stars_handlers @@ -141,6 +142,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_welcome_text.register_welcome_text_handlers(dp) admin_tickets.register_handlers(dp) admin_reports.register_handlers(dp) + admin_configuration.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/config.py b/app/config.py index 9de0a3a0..452b7ab5 100644 --- a/app/config.py +++ b/app/config.py @@ -4,14 +4,59 @@ import re import html from collections import defaultdict from datetime import time -from typing import List, Optional, Union, Dict +from typing import Any, Dict, List, Optional, Union, get_args, get_origin +import json from pydantic_settings import BaseSettings from pydantic import field_validator, Field from pathlib import Path class Settings(BaseSettings): - + + _initial_values: Dict[str, Any] = {} + EDITABLE_EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"} + CATEGORY_ALIASES: Dict[str, str] = { + "ADMIN": "Администрирование", + "APP": "App Config", + "AUTOPAY": "Автоплатежи", + "AVAILABLE": "Доступность", + "BACKUP": "Резервные копии", + "BASE": "Базовые значения", + "CAMPAIGN": "Кампании", + "CHANNEL": "Телеграм-каналы", + "CONNECT": "Кнопка подключения", + "CRYPTOBOT": "CryptoBot", + "DATABASE": "База данных", + "DEFAULT": "Значения по умолчанию", + "DEBUG": "Отладка", + "ENABLE": "Флаги", + "HAPP": "Happ", + "INACTIVE": "Неактивные", + "LOG": "Логирование", + "LOGO": "Логотип", + "MAINTENANCE": "Техработы", + "MINIAPP": "Mini App", + "MONITORING": "Мониторинг", + "NOTIFICATION": "Уведомления", + "PAYMENT": "Платежи", + "PAL24": "PayPalych", + "POSTGRES": "PostgreSQL", + "PRICE": "Цены", + "REFERRAL": "Рефералы", + "REMNAWAVE": "Remnawave API", + "SERVER": "Серверы", + "SKIP": "Пропуски", + "SQLITE": "SQLite", + "SUPPORT": "Поддержка", + "TELEGRAM": "Telegram", + "TRAFFIC": "Трафик", + "TRIAL": "Триал", + "TRIBUTE": "Tribute", + "VERSION": "Версии", + "WEBHOOK": "Webhook", + "YOOKASSA": "YooKassa", + } + BOT_TOKEN: str ADMIN_IDS: str = "" SUPPORT_USERNAME: str = "@support" @@ -964,7 +1009,170 @@ class Settings(BaseSettings): def is_support_contact_enabled(self) -> bool: return self.get_support_system_mode() in {"contact", "both"} - + + def model_post_init(self, __context: Any) -> None: # type: ignore[override] + try: + super().model_post_init(__context) + except AttributeError: + pass + object.__setattr__(self, "_initial_values", self.model_dump()) + + def get_initial_value(self, key: str) -> Any: + return (self._initial_values or {}).get(key, getattr(self, key, None)) + + def get_initial_editable_values(self) -> Dict[str, Any]: + return { + key: self.get_initial_value(key) + for key in self.model_fields.keys() + if self.is_editable_field(key) + } + + def is_editable_field(self, key: str) -> bool: + return key in self.model_fields and key not in self.EDITABLE_EXCLUDED_KEYS + + def get_field_category_key(self, key: str) -> str: + if "_" in key: + return key.split("_", 1)[0].upper() + return "GENERAL" + + def get_field_category_label(self, key: str) -> str: + category_key = self.get_field_category_key(key) + if category_key == "GENERAL": + return "Прочее" + return self.CATEGORY_ALIASES.get(category_key, category_key.capitalize()) + + def get_field_label(self, key: str) -> str: + return key.replace("_", " ").strip().title() + + def get_field_type_annotation(self, key: str) -> Any: + field = self.model_fields.get(key) + if not field: + raise KeyError(key) + return field.annotation + + def get_field_type_name(self, key: str) -> str: + annotation = self.get_field_type_annotation(key) + origin = get_origin(annotation) + if origin is Union: + args = [arg for arg in get_args(annotation) if arg is not type(None)] + if not args: + return "str" + return f"Optional[{self._type_to_name(args[0])}]" + return self._type_to_name(annotation) + + def _type_to_name(self, annotation: Any) -> str: + if annotation in {str, Optional[str]}: + return "str" + if annotation is bool: + return "bool" + if annotation is int: + return "int" + if annotation is float: + return "float" + if annotation in {list, List[str]}: + return "list" + if annotation in {dict, Dict[str, Any]}: + return "dict" + if hasattr(annotation, "__name__"): + return annotation.__name__ + return str(annotation) + + def format_value_for_display(self, key: str, value: Any | None = None) -> str: + actual_value = getattr(self, key, None) if value is None else value + if actual_value is None: + return "—" + if isinstance(actual_value, bool): + return "✅ Включено" if actual_value else "🚫 Выключено" + if isinstance(actual_value, (list, dict)): + try: + return json.dumps(actual_value, ensure_ascii=False) + except Exception: + return str(actual_value) + return str(actual_value) + + def serialize_value_for_storage(self, key: str, value: Any | None = None) -> Any: + actual_value = getattr(self, key, None) if value is None else value + if isinstance(actual_value, Path): + return str(actual_value) + if isinstance(actual_value, set): + return list(actual_value) + return actual_value + + def cast_value(self, key: str, value: Any) -> Any: + annotation = self.get_field_type_annotation(key) + return self._convert_value(annotation, value) + + def parse_raw_value(self, key: str, raw_value: str) -> Any: + annotation = self.get_field_type_annotation(key) + return self._convert_value(annotation, raw_value) + + def _convert_value(self, annotation: Any, value: Any) -> Any: + origin = get_origin(annotation) + if origin is Union: + args = [arg for arg in get_args(annotation) if arg is not type(None)] + if not args: + return value + if value in ("", None, "none", "None", "null", "Null"): + return None + return self._convert_value(args[0], value) + + if annotation is bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + value_str = str(value).strip().lower() + if value_str in {"true", "1", "yes", "on", "да"}: + return True + if value_str in {"false", "0", "no", "off", "нет"}: + return False + raise ValueError("Некорректное булево значение") + + if annotation is int: + if isinstance(value, int): + return value + if value in ("", None): + raise ValueError("Значение не может быть пустым") + return int(str(value).strip()) + + if annotation is float: + if isinstance(value, float): + return value + if isinstance(value, int): + return float(value) + if value in ("", None): + raise ValueError("Значение не может быть пустым") + return float(str(value).replace(',', '.')) + + if annotation in {str, Any}: + return "" if value is None else str(value) + + if annotation in {list, List[str]}: + if isinstance(value, list): + return value + return [item.strip() for item in str(value).split(',') if item.strip()] + + if annotation in {dict, Dict[str, Any]}: + if isinstance(value, dict): + return value + try: + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + raise ValueError("Ожидается JSON-объект") + + return value + + def apply_override(self, key: str, value: Any) -> None: + object.__setattr__(self, key, value) + + def apply_overrides(self, overrides: Dict[str, Any]) -> None: + for key, value in overrides.items(): + if self.is_editable_field(key): + self.apply_override(key, value) + model_config = { "env_file": ".env", "env_file_encoding": "utf-8", diff --git a/app/database/crud/app_settings.py b/app/database/crud/app_settings.py new file mode 100644 index 00000000..421b03a6 --- /dev/null +++ b/app/database/crud/app_settings.py @@ -0,0 +1,50 @@ +from datetime import datetime +from typing import List, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import AppSetting + + +class AppSettingsCRUD: + @staticmethod + async def list_all(db: AsyncSession) -> List[AppSetting]: + result = await db.execute(select(AppSetting).order_by(AppSetting.setting_key)) + return list(result.scalars()) + + @staticmethod + async def get_by_key(db: AsyncSession, setting_key: str) -> Optional[AppSetting]: + result = await db.execute( + select(AppSetting).where(AppSetting.setting_key == setting_key) + ) + return result.scalar_one_or_none() + + @staticmethod + async def upsert(db: AsyncSession, setting_key: str, value: str) -> AppSetting: + record = await AppSettingsCRUD.get_by_key(db, setting_key) + now = datetime.utcnow() + + if record: + record.value = value + record.updated_at = now + else: + record = AppSetting( + setting_key=setting_key, + value=value, + created_at=now, + updated_at=now, + ) + db.add(record) + + await db.flush() + return record + + @staticmethod + async def delete_by_key(db: AsyncSession, setting_key: str) -> bool: + record = await AppSettingsCRUD.get_by_key(db, setting_key) + if not record: + return False + await db.delete(record) + await db.flush() + return True diff --git a/app/database/models.py b/app/database/models.py index bc36c94e..80ab7214 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -25,6 +25,16 @@ from sqlalchemy.sql import func Base = declarative_base() +class AppSetting(Base): + __tablename__ = "app_settings" + + id = Column(Integer, primary_key=True, autoincrement=True) + setting_key = Column(String(120), unique=True, nullable=False, index=True) + value = Column(Text, nullable=True) + created_at = Column(DateTime, default=func.now(), nullable=False) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False) + + server_squad_promo_groups = Table( "server_squad_promo_groups", Base.metadata, diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index b0edb1b6..1691373f 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1,7 +1,8 @@ +import json import logging -from sqlalchemy import text, inspect -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text from app.database.database import engine +from app.config import settings logger = logging.getLogger(__name__) @@ -172,6 +173,98 @@ async def check_index_exists(table_name: str, index_name: str) -> bool: ) return False + +async def ensure_app_settings_table() -> bool: + try: + table_exists = await check_table_exists('app_settings') + + async with engine.begin() as conn: + db_type = await get_database_type() + + if not table_exists: + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE app_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + setting_key VARCHAR(120) NOT NULL UNIQUE, + value TEXT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE IF NOT EXISTS app_settings ( + id SERIAL PRIMARY KEY, + setting_key VARCHAR(120) UNIQUE NOT NULL, + value TEXT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + """ + elif db_type == 'mysql': + create_sql = """ + CREATE TABLE IF NOT EXISTS app_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + setting_key VARCHAR(120) NOT NULL UNIQUE, + value TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + """ + else: + create_sql = None + + if create_sql: + await conn.execute(text(create_sql)) + logger.info("✅ Таблица app_settings создана") + else: + logger.info("ℹ️ Таблица app_settings уже существует") + + defaults = settings.get_initial_editable_values() + + if defaults: + for key, default_value in defaults.items(): + if not settings.is_editable_field(key): + continue + + serialized = json.dumps( + settings.serialize_value_for_storage(key, default_value), + ensure_ascii=False + ) + + if db_type == 'sqlite': + insert_sql = """ + INSERT OR IGNORE INTO app_settings (setting_key, value, created_at, updated_at) + VALUES (:setting_key, :value, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """ + elif db_type == 'postgresql': + insert_sql = """ + INSERT INTO app_settings (setting_key, value, created_at, updated_at) + VALUES (:setting_key, :value, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (setting_key) DO NOTHING + """ + elif db_type == 'mysql': + insert_sql = """ + INSERT INTO app_settings (setting_key, value, created_at, updated_at) + VALUES (:setting_key, :value, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE setting_key = VALUES(setting_key) + """ + else: + insert_sql = None + + if insert_sql: + await conn.execute( + text(insert_sql), + {"setting_key": key, "value": serialized} + ) + + return True + + except Exception as e: + logger.error(f"Ошибка создания или обновления таблицы app_settings: {e}") + return False + async def create_cryptobot_payments_table(): table_exists = await check_table_exists('cryptobot_payments') if table_exists: @@ -1839,7 +1932,14 @@ async def run_universal_migration(): try: db_type = await get_database_type() logger.info(f"Тип базы данных: {db_type}") - + + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ APP_SETTINGS ===") + app_settings_ready = await ensure_app_settings_table() + if app_settings_ready: + logger.info("✅ Таблица app_settings готова") + else: + logger.warning("⚠️ Проблемы с таблицей app_settings") + referral_migration_success = await add_referral_system_columns() if not referral_migration_success: logger.warning("⚠️ Проблемы с миграцией реферальной системы") diff --git a/app/handlers/admin/configuration.py b/app/handlers/admin/configuration.py new file mode 100644 index 00000000..0585021c --- /dev/null +++ b/app/handlers/admin/configuration.py @@ -0,0 +1,300 @@ +import html +import logging +import math +from typing import Dict, List, Tuple + +from aiogram import Bot, Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.admin import ( + get_bot_config_categories_keyboard, + get_bot_config_settings_keyboard, +) +from app.services.app_settings_service import AppSettingsService +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + +CONFIGS_PER_PAGE = 8 +CANCEL_COMMANDS = {"отмена", "cancel", "/cancel"} + + +@admin_required +@error_handler +async def show_bot_config_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + await state.clear() + categories = AppSettingsService.get_categories() + keyboard = get_bot_config_categories_keyboard(categories) + + intro_lines = [ + "🧩 Конфигурация бота", + "", + "Изменения применяются сразу.", + "Отправьте reset для возврата значения по умолчанию.", + "Некоторые изменения могут потребовать перезапуска бота.", + ] + + if not categories: + intro_lines.append("\nНастройки недоступны для редактирования.") + + text = "\n".join(intro_lines) + + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + await state.update_data( + bot_config_message_id=callback.message.message_id, + bot_config_category_key=None, + bot_config_category_page=1, + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_bot_config_category( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + data = callback.data + if data == "admin_bot_config_cat:noop": + await callback.answer() + return + + parts = data.split(":") + if len(parts) < 2: + await callback.answer() + return + + category_key = parts[1].upper() + try: + page = int(parts[2]) if len(parts) > 2 else 1 + except ValueError: + page = 1 + + await state.clear() + await _render_category_view(callback.message, state, category_key, page) + await callback.answer() + + +@admin_required +@error_handler +async def prompt_bot_config_edit( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + key = callback.data.split(":", 1)[-1] + try: + field_info = AppSettingsService.get_field_info(key) + except ValueError as error: + await callback.answer(str(error), show_alert=True) + return + + state_data = await state.get_data() + category_key = state_data.get("bot_config_category_key") or field_info["category_key"] + page = state_data.get("bot_config_category_page", 1) + message_id = state_data.get("bot_config_message_id", callback.message.message_id) + + default_display = settings.format_value_for_display(key, field_info["default_value"]) + current_display = field_info["display_value"] or "—" + + text = ( + "✏️ Изменение настройки\n\n" + f"Ключ: {key}\n" + f"Название: {html.escape(field_info['label'])}\n" + f"Тип: {field_info['type']}\n" + f"Текущее значение: {html.escape(str(current_display))}\n" + f"По умолчанию: {html.escape(str(default_display))}\n\n" + "Отправьте новое значение одним сообщением.\n" + "Для возврата по умолчанию отправьте reset.\n" + "Для отмены отправьте отмена или /cancel." + ) + + back_callback = f"admin_bot_config_cat:{category_key}:{page}" + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)] + ] + ) + + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + + await state.update_data( + bot_config_edit_key=key, + bot_config_category_key=category_key, + bot_config_category_page=page, + bot_config_message_id=message_id, + ) + await state.set_state(AdminStates.editing_bot_config_value) + await callback.answer() + + +@admin_required +@error_handler +async def process_bot_config_edit( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + state_data = await state.get_data() + key = state_data.get("bot_config_edit_key") + category_key = state_data.get("bot_config_category_key") + page = state_data.get("bot_config_category_page", 1) + message_id = state_data.get("bot_config_message_id") + + if not key: + await message.answer("⚠️ Не удалось определить настройку. Выберите её снова из меню.") + await state.clear() + return + + text = (message.text or "").strip() + + if text.lower() in CANCEL_COMMANDS: + await message.answer("❌ Изменение отменено.") + await state.clear() + if category_key and message_id: + await _edit_category_message(message.bot, message.chat.id, message_id, category_key, page, state) + else: + await message.answer("ℹ️ Вернитесь к списку настроек через меню администратора.") + return + + try: + result = await AppSettingsService.update_setting(key, text) + except ValueError as error: + await message.answer(f"❌ {error}") + return + + display_value = html.escape(str(result["display"])) if result.get("display") is not None else "—" + await message.answer( + f"✅ Настройка {key} обновлена до значения {display_value}", + parse_mode="HTML" + ) + + await state.clear() + + if category_key and message_id: + await _edit_category_message(message.bot, message.chat.id, message_id, category_key, page, state) + else: + await message.answer("ℹ️ Откройте категорию настроек заново через меню администратора.") + + +async def _render_category_view( + message: types.Message, + state: FSMContext, + category_key: str, + page: int, +) -> None: + text, keyboard, actual_page = _build_category_view(category_key, page) + try: + await message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + except TelegramBadRequest as error: + logger.debug("Не удалось обновить сообщение конфигурации: %s", error) + await state.update_data( + bot_config_category_key=category_key, + bot_config_category_page=actual_page, + bot_config_message_id=message.message_id, + ) + + +async def _edit_category_message( + bot: Bot, + chat_id: int, + message_id: int, + category_key: str, + page: int, + state: FSMContext, +) -> None: + text, keyboard, actual_page = _build_category_view(category_key, page) + try: + await bot.edit_message_text( + text=text, + chat_id=chat_id, + message_id=message_id, + reply_markup=keyboard, + parse_mode="HTML", + ) + except TelegramBadRequest as error: + logger.debug("Не удалось обновить сообщение конфигурации: %s", error) + return + + await state.update_data( + bot_config_category_key=category_key, + bot_config_category_page=actual_page, + bot_config_message_id=message_id, + ) + + +def _build_category_view( + category_key: str, + page: int, +) -> Tuple[str, InlineKeyboardMarkup, int]: + fields = AppSettingsService.get_category_fields(category_key) + total = len(fields) + total_pages = max(1, math.ceil(total / CONFIGS_PER_PAGE)) + page = max(1, min(page, total_pages)) + start_index = (page - 1) * CONFIGS_PER_PAGE + end_index = start_index + CONFIGS_PER_PAGE + page_fields = fields[start_index:end_index] + + category_label = ( + fields[0]["category_label"] + if fields + else settings.get_field_category_label(category_key) + ) + + lines = [ + f"🧩 {category_label}", + "", + "Выберите настройку для редактирования.", + "Отправьте reset для возврата по умолчанию.", + "", + ] + + if not page_fields: + lines.append("Настройки отсутствуют.") + else: + for field in page_fields: + indicator = "⭐" if field.get("is_overridden") else "•" + value_display = field.get("display_value") + value_str = html.escape(str(value_display)) if value_display is not None else "—" + value_str = value_str.replace("\n", " ") + if len(value_str) > 120: + value_str = value_str[:117] + "…" + lines.append(f"{indicator} {field['key']}{value_str}") + + text = "\n".join(lines) + keyboard = get_bot_config_settings_keyboard(category_key, fields, page, CONFIGS_PER_PAGE, total_pages) + return text, keyboard, page + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register( + show_bot_config_menu, + F.data == "admin_bot_config" + ) + dp.callback_query.register( + show_bot_config_category, + F.data.startswith("admin_bot_config_cat:") + ) + dp.callback_query.register( + prompt_bot_config_edit, + F.data.startswith("admin_bot_config_edit:") + ) + dp.message.register( + process_bot_config_edit, + AdminStates.editing_bot_config_value + ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 13c37204..ba19195a 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Any +from typing import Any, Dict, List, Optional, Tuple from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from app.localization.texts import get_texts @@ -93,12 +93,15 @@ def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMa def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"), InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring") ], + [ + InlineKeyboardButton(text="🧩 Конфигурация бота", callback_data="admin_bot_config") + ], [ InlineKeyboardButton( text=texts.t("ADMIN_MONITORING_SETTINGS", "⚙️ Настройки мониторинга"), @@ -115,6 +118,86 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM ]) +def get_bot_config_categories_keyboard(categories: List[Dict[str, Any]]) -> InlineKeyboardMarkup: + rows: List[List[InlineKeyboardButton]] = [] + + if categories: + for i in range(0, len(categories), 2): + chunk = categories[i:i + 2] + row: List[InlineKeyboardButton] = [] + for category in chunk: + count = len(category.get("fields", [])) + text = f"{category['label']} ({count})" + row.append( + InlineKeyboardButton( + text=text, + callback_data=f"admin_bot_config_cat:{category['key']}:1" + ) + ) + rows.append(row) + + rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")]) + + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def get_bot_config_settings_keyboard( + category_key: str, + fields: List[Dict[str, Any]], + page: int, + per_page: int, + total_pages: int +) -> InlineKeyboardMarkup: + rows: List[List[InlineKeyboardButton]] = [] + + start_index = (page - 1) * per_page + end_index = start_index + per_page + page_fields = fields[start_index:end_index] + + for field in page_fields: + label = field.get("label") or field["key"].title() + if field.get("is_overridden"): + text = f"⭐ {label}" + else: + text = label + rows.append([ + InlineKeyboardButton( + text=text[:40], + callback_data=f"admin_bot_config_edit:{field['key']}" + ) + ]) + + if total_pages > 1: + nav_row: List[InlineKeyboardButton] = [] + if page > 1: + nav_row.append( + InlineKeyboardButton( + text="⬅️", + callback_data=f"admin_bot_config_cat:{category_key}:{page - 1}" + ) + ) + nav_row.append( + InlineKeyboardButton( + text=f"{page}/{total_pages}", + callback_data="admin_bot_config_cat:noop" + ) + ) + if page < total_pages: + nav_row.append( + InlineKeyboardButton( + text="➡️", + callback_data=f"admin_bot_config_cat:{category_key}:{page + 1}" + ) + ) + rows.append(nav_row) + + rows.append([ + InlineKeyboardButton(text="⬅️ Категории", callback_data="admin_bot_config") + ]) + + return InlineKeyboardMarkup(inline_keyboard=rows) + + def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/services/app_settings_service.py b/app/services/app_settings_service.py new file mode 100644 index 00000000..504fc47f --- /dev/null +++ b/app/services/app_settings_service.py @@ -0,0 +1,171 @@ +import json +import logging +from typing import Any, Dict, List + +from app.config import settings +from app.database.crud.app_settings import AppSettingsCRUD +from app.database.database import AsyncSessionLocal + +logger = logging.getLogger(__name__) + + +class AppSettingsService: + RESET_COMMANDS = {"--reset", "reset", "/reset", "default", "сброс", "по умолчанию"} + + @classmethod + async def load_overrides(cls) -> None: + try: + async with AsyncSessionLocal() as session: + records = await AppSettingsCRUD.list_all(session) + existing_keys = {record.setting_key for record in records} + defaults = settings.get_initial_editable_values() + + added = False + for key, default_value in defaults.items(): + if not settings.is_editable_field(key) or key in existing_keys: + continue + serialized = cls._serialize_for_storage(key, default_value) + await AppSettingsCRUD.upsert(session, key, serialized) + added = True + + if added: + await session.commit() + records = await AppSettingsCRUD.list_all(session) + else: + await session.commit() + except Exception as error: + logger.error("Не удалось синхронизировать таблицу app_settings: %s", error) + records = [] + + overrides: Dict[str, Any] = {} + for record in records: + key = record.setting_key + if not settings.is_editable_field(key): + continue + try: + value = cls._deserialize_value(key, record.value) + except Exception as error: + logger.warning("Не удалось разобрать значение настройки %s: %s", key, error) + continue + overrides[key] = value + + if overrides: + settings.apply_overrides(overrides) + logger.info("Применены %d пользовательских настроек", len(overrides)) + + @classmethod + def get_categories(cls) -> List[Dict[str, Any]]: + categories: Dict[str, Dict[str, Any]] = {} + for field in cls._collect_fields(): + category_key = field["category_key"] + category = categories.setdefault( + category_key, + { + "key": category_key, + "label": field["category_label"], + "fields": [], + }, + ) + category["fields"].append(field) + + for category in categories.values(): + category["fields"].sort(key=lambda item: item["label"].lower()) + + def sort_key(item: Dict[str, Any]) -> tuple[int, str]: + return (1 if item["label"] == "Прочее" else 0, item["label"].lower()) + + return sorted(categories.values(), key=sort_key) + + @classmethod + def get_category_fields(cls, category_key: str) -> List[Dict[str, Any]]: + category_key = category_key.upper() + fields = [ + field + for field in cls._collect_fields() + if field["category_key"] == category_key + ] + fields.sort(key=lambda item: item["label"].lower()) + return fields + + @classmethod + def get_field_info(cls, key: str) -> Dict[str, Any]: + if not settings.is_editable_field(key): + raise ValueError("Недоступная настройка") + field = settings.model_fields.get(key) + if not field: + raise ValueError("Настройка не найдена") + return { + "key": key, + "label": settings.get_field_label(key), + "type": settings.get_field_type_name(key), + "current_value": getattr(settings, key), + "display_value": settings.format_value_for_display(key), + "default_value": settings.get_initial_value(key), + "category_key": settings.get_field_category_key(key), + "category_label": settings.get_field_category_label(key), + "is_overridden": settings.get_initial_value(key) != getattr(settings, key), + } + + @classmethod + async def update_setting(cls, key: str, raw_value: str) -> Dict[str, Any]: + if not settings.is_editable_field(key): + raise ValueError("Эту настройку нельзя изменять через админку") + + if raw_value is None: + raise ValueError("Значение не может быть пустым") + + stripped = raw_value.strip() + use_default = stripped.lower() in cls.RESET_COMMANDS + + if use_default: + parsed_value = settings.get_initial_value(key) + else: + parsed_value = settings.parse_raw_value(key, stripped) + + serialized = cls._serialize_for_storage(key, parsed_value) + + async with AsyncSessionLocal() as session: + await AppSettingsCRUD.upsert(session, key, serialized) + await session.commit() + + settings.apply_override(key, parsed_value) + + return { + "key": key, + "value": parsed_value, + "display": settings.format_value_for_display(key, parsed_value), + "is_default": settings.get_initial_value(key) == parsed_value, + } + + @classmethod + def _collect_fields(cls) -> List[Dict[str, Any]]: + fields: List[Dict[str, Any]] = [] + for key in settings.model_fields.keys(): + if not settings.is_editable_field(key): + continue + fields.append( + { + "key": key, + "label": settings.get_field_label(key), + "type": settings.get_field_type_name(key), + "value": getattr(settings, key), + "display_value": settings.format_value_for_display(key), + "category_key": settings.get_field_category_key(key), + "category_label": settings.get_field_category_label(key), + "is_overridden": settings.get_initial_value(key) != getattr(settings, key), + } + ) + return fields + + @classmethod + def _serialize_for_storage(cls, key: str, value: Any) -> str: + prepared = settings.serialize_value_for_storage(key, value) + return json.dumps(prepared, ensure_ascii=False) + + @classmethod + def _deserialize_value(cls, key: str, raw_value: str | None) -> Any: + if raw_value is None or raw_value == "": + stored = None + else: + stored = json.loads(raw_value) + return settings.cast_value(key, stored) diff --git a/app/states.py b/app/states.py index f824f9a5..208eb18a 100644 --- a/app/states.py +++ b/app/states.py @@ -87,6 +87,7 @@ class AdminStates(StatesGroup): editing_rules_page = State() editing_notification_value = State() + editing_bot_config_value = State() confirming_sync = State() diff --git a/main.py b/main.py index 254e5c5d..0f3da88d 100644 --- a/main.py +++ b/main.py @@ -21,6 +21,7 @@ from app.database.universal_migration import run_universal_migration from app.services.backup_service import backup_service from app.services.reporting_service import reporting_service from app.localization.loader import ensure_locale_templates +from app.services.app_settings_service import AppSettingsService class GracefulExit: @@ -73,18 +74,29 @@ async def main(): logger.info("🔧 Выполняем проверку и миграцию базы данных...") try: migration_success = await run_universal_migration() - + if migration_success: logger.info("✅ Миграция базы данных завершена успешно") else: logger.warning("⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск") - + except Exception as migration_error: logger.error(f"❌ Ошибка выполнения миграции: {migration_error}") logger.warning("⚠️ Продолжаем запуск без миграции") else: logger.info("ℹ️ Миграция пропущена (SKIP_MIGRATION=true)") - + + try: + await AppSettingsService.load_overrides() + new_level = getattr(logging, settings.LOG_LEVEL, logging.INFO) + root_logger = logging.getLogger() + root_logger.setLevel(new_level) + for handler in root_logger.handlers: + handler.setLevel(new_level) + logger.info("✅ Настройки из базы данных применены") + except Exception as settings_error: + logger.error(f"❌ Не удалось загрузить настройки из базы данных: {settings_error}") + logger.info("🤖 Настройка бота...") bot, dp = await setup_bot()