From 8acbbb286cb91adbe3ec43c6772edcdea56f1218 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 29 Mar 2025 12:13:17 +0200 Subject: [PATCH] [upd] nested settings --- src/bot/callbacks/settings/input.py | 30 ++- src/bot/callbacks/settings/main.py | 14 +- src/bot/callbacks/settings/section.py | 25 +- src/bot/callbacks/settings/selectors.py | 83 +++++-- src/bot/keyboards/settings/generator.py | 299 +++++++++++++++--------- src/bot/modules/settings/model.py | 101 +++++++- src/bot/modules/settings/settings.py | 125 +++++++--- src/utils/db/models/settings.py | 31 ++- 8 files changed, 497 insertions(+), 211 deletions(-) diff --git a/src/bot/callbacks/settings/input.py b/src/bot/callbacks/settings/input.py index f267446..a45462b 100644 --- a/src/bot/callbacks/settings/input.py +++ b/src/bot/callbacks/settings/input.py @@ -23,15 +23,15 @@ async def on_input_setting( state: FSMContext, user_settings: FromDishka[UserSettings], ): - _, _, section, field = callback.data.split(":") + _, _, path, field = callback.data.split(":") - await state.update_data(section=section, field=field) + await state.update_data(path=path, field=field) await state.set_state(SettingsStates.waiting_for_input) await callback.answer() await bot.send_message( chat_id=callback.from_user.id, - text=SettingsMenuGenerator.get_input_prompt(section, field, user_settings), + text=SettingsMenuGenerator.get_input_prompt(path, field, user_settings), parse_mode="HTML", ) @@ -43,22 +43,32 @@ async def on_input_value( user_settings: FromDishka[UserSettings], ): data = await state.get_data() - section = data["section"] + path = data["path"] field = data["field"] await state.clear() - section_obj = getattr(user_settings, section) + if path == "root": + setattr(user_settings, field, message.text) - setattr(section_obj, field, message.text) + await UserSettingsDocument.update_setting( + message.from_user.id, field, message.text + ) + else: + try: + section = user_settings.get_setting(path) + setattr(section, field, message.text) - await UserSettingsDocument.update_field( - message.from_user.id, section, field, message.text - ) + await UserSettingsDocument.update_setting( + message.from_user.id, f"{path}.{field}", message.text + ) + except (AttributeError, KeyError): + await message.answer("Setting not found") + return await message.answer( "✅ Setting updated!", reply_markup=SettingsMenuGenerator.get_section_menu( - section, user_settings, router + path, user_settings, router ), ) diff --git a/src/bot/callbacks/settings/main.py b/src/bot/callbacks/settings/main.py index fe825f0..9b44752 100644 --- a/src/bot/callbacks/settings/main.py +++ b/src/bot/callbacks/settings/main.py @@ -1,25 +1,31 @@ from aiogram import Bot, F, Router, types from aiogram.filters import Command from aiogram.types import CallbackQuery +from dishka import FromDishka from bot.keyboards.settings import SettingsMenuGenerator +from bot.modules.settings import UserSettings router = Router() @router.callback_query(F.data == "settings:main") -async def on_main_settings(callback: CallbackQuery, bot: Bot): +async def on_main_settings( + callback: CallbackQuery, bot: Bot, user_settings: FromDishka[UserSettings] +): await callback.answer() await bot.edit_message_text( "⚙️ Settings", chat_id=callback.from_user.id, message_id=callback.message.message_id, - reply_markup=SettingsMenuGenerator.get_main_menu(router), + reply_markup=SettingsMenuGenerator.get_main_menu(user_settings, router), ) @router.message(Command("settings")) -async def on_settings_command(message: types.Message): +async def on_settings_command( + message: types.Message, user_settings: FromDishka[UserSettings] +): await message.answer( - "⚙️ Settings", reply_markup=SettingsMenuGenerator.get_main_menu() + "⚙️ Settings", reply_markup=SettingsMenuGenerator.get_main_menu(user_settings) ) diff --git a/src/bot/callbacks/settings/section.py b/src/bot/callbacks/settings/section.py index 68e8818..b34886f 100644 --- a/src/bot/callbacks/settings/section.py +++ b/src/bot/callbacks/settings/section.py @@ -12,14 +12,17 @@ router = Router() async def on_section_settings( callback: CallbackQuery, bot: Bot, user_settings: FromDishka[UserSettings] ): - section = callback.data.split(":")[-1] + path = callback.data.split(":", 2)[-1] + + section_title = path.split(".")[-1].capitalize() + await callback.answer() await bot.edit_message_text( - f"⚙️ {section.capitalize()} settings", + f"⚙️ {section_title} settings", chat_id=callback.from_user.id, message_id=callback.message.message_id, reply_markup=SettingsMenuGenerator.get_section_menu( - section, user_settings, router + path, user_settings, router ), ) @@ -30,11 +33,19 @@ async def on_section_settings( async def on_select_setting( callback: CallbackQuery, bot: Bot, user_settings: FromDishka[UserSettings] ): - _, mode, section, field = callback.data.split(":") + _, mode, path, field = callback.data.split(":") - section_obj = getattr(user_settings, section) + if path == "root": + section = user_settings + info = section.get_info(field) + else: + try: + section = user_settings.get_setting(path) + info = section.get_info(field) + except (AttributeError, KeyError): + await callback.answer("Setting not found") + return - info = section_obj.get_info(field) if not info: await callback.answer("Setting info not found") return @@ -45,6 +56,6 @@ async def on_select_setting( chat_id=callback.from_user.id, message_id=callback.message.message_id, reply_markup=getattr(SettingsMenuGenerator, f"get_{mode}_menu")( - section, field, user_settings, router + path, field, user_settings, router ), ) diff --git a/src/bot/callbacks/settings/selectors.py b/src/bot/callbacks/settings/selectors.py index 634ed5a..220c182 100644 --- a/src/bot/callbacks/settings/selectors.py +++ b/src/bot/callbacks/settings/selectors.py @@ -13,23 +13,37 @@ router = Router() async def on_toggle_setting( callback: CallbackQuery, bot: Bot, user_settings: FromDishka[UserSettings] ): - _, _, section, field = callback.data.split(":") + _, _, path, field = callback.data.split(":") - section_obj = getattr(user_settings, section) + if path == "root": + section = user_settings + current_value = getattr(section, field) + new_value = not current_value + setattr(section, field, new_value) - current_value = getattr(section_obj, field) - setattr(section_obj, field, not current_value) + await UserSettingsDocument.update_setting( + callback.from_user.id, field, new_value + ) + else: + try: + section = user_settings.get_setting(path) + current_value = getattr(section, field) + new_value = not current_value + setattr(section, field, new_value) - await UserSettingsDocument.update_field( - callback.from_user.id, section, field, not current_value - ) + await UserSettingsDocument.update_setting( + callback.from_user.id, f"{path}.{field}", new_value + ) + except (AttributeError, KeyError): + await callback.answer("Setting not found") + return await callback.answer("Setting updated") await bot.edit_message_reply_markup( chat_id=callback.from_user.id, message_id=callback.message.message_id, reply_markup=SettingsMenuGenerator.get_section_menu( - section, user_settings, router + path, user_settings, router ), ) @@ -38,21 +52,31 @@ async def on_toggle_setting( async def on_set_value( callback: CallbackQuery, bot: Bot, user_settings: FromDishka[UserSettings] ): - _, _, section, field, value = callback.data.split(":") + _, _, path, field, value = callback.data.split(":") - section_obj = getattr(user_settings, section) + if path == "root": + section = user_settings + setattr(section, field, value) - setattr(section_obj, field, value) - await UserSettingsDocument.update_field( - callback.from_user.id, section, field, value - ) + await UserSettingsDocument.update_setting(callback.from_user.id, field, value) + else: + try: + section = user_settings.get_setting(path) + setattr(section, field, value) + + await UserSettingsDocument.update_setting( + callback.from_user.id, f"{path}.{field}", value + ) + except (AttributeError, KeyError): + await callback.answer("Setting not found") + return await callback.answer("Setting updated") await bot.edit_message_reply_markup( chat_id=callback.from_user.id, message_id=callback.message.message_id, reply_markup=SettingsMenuGenerator.get_select_menu( - section, field, user_settings, router + path, field, user_settings, router ), ) @@ -61,11 +85,19 @@ async def on_set_value( async def on_move_item( callback: CallbackQuery, bot: Bot, user_settings: FromDishka[UserSettings] ): - _, _, section, field, index, direction = callback.data.split(":") + _, _, path, field, index, direction = callback.data.split(":") index = int(index) - section_obj = getattr(user_settings, section) - current_list = getattr(section_obj, field) + if path == "root": + section = user_settings + current_list = getattr(section, field) + else: + try: + section = user_settings.get_setting(path) + current_list = getattr(section, field) + except (AttributeError, KeyError): + await callback.answer("Setting not found") + return if direction == "up" and index > 0: current_list[index], current_list[index - 1] = ( @@ -78,17 +110,22 @@ async def on_move_item( current_list[index], ) - setattr(section_obj, field, current_list) + setattr(section, field, current_list) - await UserSettingsDocument.update_field( - callback.from_user.id, section, field, current_list - ) + if path == "root": + await UserSettingsDocument.update_setting( + callback.from_user.id, field, current_list + ) + else: + await UserSettingsDocument.update_setting( + callback.from_user.id, f"{path}.{field}", current_list + ) await callback.answer("Order updated") await bot.edit_message_reply_markup( chat_id=callback.from_user.id, message_id=callback.message.message_id, reply_markup=SettingsMenuGenerator.get_list_menu( - section, field, user_settings, router + path, field, user_settings, router ), ) diff --git a/src/bot/keyboards/settings/generator.py b/src/bot/keyboards/settings/generator.py index 4db5ebb..4614802 100644 --- a/src/bot/keyboards/settings/generator.py +++ b/src/bot/keyboards/settings/generator.py @@ -4,7 +4,6 @@ from aiogram import Router from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from bot.keyboards.utils import Paginator -from bot.modules.settings import UserSettings from bot.modules.settings.model import BaseSettings, SettingInfo @@ -13,26 +12,73 @@ class SettingsMenuGenerator: ITEMS_PER_PAGE = 5 @classmethod - def get_main_menu(cls, router: Optional[Router] = None) -> InlineKeyboardMarkup: + def get_main_menu( + cls, settings: BaseSettings, router: Optional[Router] = None + ) -> InlineKeyboardMarkup: keyboard = [] - fields = get_type_hints(UserSettings) + fields = get_type_hints(settings.__class__) - for field_name, field_type in fields.items(): - if ( - not isinstance(field_type, type) - or not issubclass(field_type, BaseSettings) - or field_name.startswith("_") - ): + sorted_fields = sorted(fields.items(), key=lambda x: x[0]) + + for field_name, field_type in sorted_fields: + if field_name.startswith("_"): continue - keyboard.append( - [ - InlineKeyboardButton( - text=field_name.capitalize(), - callback_data=f"settings:section:{field_name}", + if hasattr(settings, field_name): + field_value = getattr(settings, field_name) + + if isinstance(field_value, BaseSettings): + keyboard.append( + [ + InlineKeyboardButton( + text=field_name.capitalize(), + callback_data=f"settings:section:{field_name}", + ) + ] ) - ] - ) + + elif not field_name.endswith("_info"): + info = settings.get_info(field_name) + if info: + value = field_value + if isinstance(value, bool): + value_text = "✅ Enabled" if value else "❌ Disabled" + keyboard.append( + [ + InlineKeyboardButton( + text=f"{info.title}: {value_text}", + callback_data=f"settings:toggle:root:{field_name}", + ) + ] + ) + elif field_type == str and info.options: + value_text = info.options.get(value, value) + keyboard.append( + [ + InlineKeyboardButton( + text=f"{info.title}: {value_text}", + callback_data=f"settings:select:root:{field_name}", + ) + ] + ) + elif field_type == List[str]: + keyboard.append( + [ + InlineKeyboardButton( + text=f"{info.title}", + callback_data=f"settings:list:root:{field_name}", + ) + ] + ) + else: + keyboard.append( + [ + InlineKeyboardButton( + text=f"{info.title}: {value}", + callback_data=f"settings:input:root:{field_name}", + ) + ] + ) paginator = Paginator( data=InlineKeyboardMarkup(inline_keyboard=keyboard), @@ -46,13 +92,17 @@ class SettingsMenuGenerator: @classmethod def get_section_menu( cls, - section_name: str, - settings: UserSettings, + path: str, + settings: BaseSettings, router: Optional[Router] = None, page: int = 0, ) -> InlineKeyboardMarkup: - section = getattr(settings, section_name) - if not section: + if path == "root": + return cls.get_main_menu(settings, router) + + try: + section = settings.get_setting(path) + except (AttributeError, KeyError): return InlineKeyboardMarkup( inline_keyboard=[ [ @@ -63,9 +113,23 @@ class SettingsMenuGenerator: ] ) - field_info = section.get_all_info() + if not isinstance(section, BaseSettings): + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=cls.BACK_BUTTON_TEXT, callback_data="settings:main" + ) + ] + ] + ) + + keyboard = [] + fields = get_type_hints(section.__class__) + field_info = section.get_all_info() + field_names = [ f for f in fields.keys() @@ -76,40 +140,53 @@ class SettingsMenuGenerator: key=lambda f: field_info.get(f, SettingInfo("", "", order=999)).order ) - keyboard = [] for field_name in field_names: value = getattr(section, field_name) + field_type = fields.get(field_name) + info = section.get_info(field_name) if info is None: - continue + title = field_name.replace("_", " ").capitalize() + info = SettingInfo(title=title, description=f"Configure {title}") - field_type = fields[field_name] - if field_type == bool: + if isinstance(value, BaseSettings): + nested_title = ( + info.title if info and info.title else field_name.capitalize() + ) + keyboard.append( + [ + InlineKeyboardButton( + text=f"{nested_title} ➡️", + callback_data=f"settings:section:{path}.{field_name}", + ) + ] + ) + elif field_type == bool or isinstance(value, bool): value_text = "✅ Enabled" if value else "❌ Disabled" keyboard.append( [ InlineKeyboardButton( text=f"{info.title}: {value_text}", - callback_data=f"settings:toggle:{section_name}:{field_name}", + callback_data=f"settings:toggle:{path}:{field_name}", ) ] ) - elif field_type == str and info.options: + elif (field_type == str or isinstance(value, str)) and info.options: value_text = info.options.get(value, value) keyboard.append( [ InlineKeyboardButton( text=f"{info.title}: {value_text}", - callback_data=f"settings:select:{section_name}:{field_name}", + callback_data=f"settings:select:{path}:{field_name}", ) ] ) - elif field_type == List[str]: + elif field_type == List[str] or isinstance(value, list): keyboard.append( [ InlineKeyboardButton( text=f"{info.title}", - callback_data=f"settings:list:{section_name}:{field_name}", + callback_data=f"settings:list:{path}:{field_name}", ) ] ) @@ -118,131 +195,109 @@ class SettingsMenuGenerator: [ InlineKeyboardButton( text=f"{info.title}: {value}", - callback_data=f"settings:input:{section_name}:{field_name}", + callback_data=f"settings:input:{path}:{field_name}", ) ] ) + back_target = "settings:main" + if "." in path: + parent_path = path.rsplit(".", 1)[0] + back_target = f"settings:section:{parent_path}" + after_data = [ - [ - InlineKeyboardButton( - text=cls.BACK_BUTTON_TEXT, callback_data="settings:main" - ) - ] + [InlineKeyboardButton(text=cls.BACK_BUTTON_TEXT, callback_data=back_target)] ] paginator = Paginator( data=InlineKeyboardMarkup(inline_keyboard=keyboard), after_data=after_data, - callback_startswith=f"settings_section_{section_name}_page_", + callback_startswith=f"settings_section_{path.replace('.', '_')}_page_", size=cls.ITEMS_PER_PAGE, dp=router, ) - return paginator(current_page=page) @classmethod def get_select_menu( cls, - section_name: str, - field_name: str, - settings: UserSettings, + path: str, + field: str, + settings: BaseSettings, router: Optional[Router] = None, ) -> InlineKeyboardMarkup: - section = getattr(settings, section_name) + if path == "root": + section = settings + else: + try: + section = settings.get_setting(path) + except (AttributeError, KeyError): + return cls._get_default_back_menu("settings:main") - if not section: - return InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=cls.BACK_BUTTON_TEXT, callback_data="settings:main" - ) - ] - ] - ) - - info = section.get_info(field_name) + info = section.get_info(field) if not info or not info.options: - return InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=cls.BACK_BUTTON_TEXT, - callback_data=f"settings:section:{section_name}", - ) - ] - ] - ) + return cls._get_default_back_menu(f"settings:section:{path}") + + current_value = getattr(section, field) keyboard = [] for option_key, option_text in info.options.items(): - prefix = "✅ " if option_key == getattr(section, field_name) else "" + prefix = "✅ " if option_key == current_value else "" keyboard.append( [ InlineKeyboardButton( text=f"{prefix}{option_text}", - callback_data=f"settings:set:{section_name}:{field_name}:{option_key}", + callback_data=f"settings:set:{path}:{field}:{option_key}", ) ] ) + after_data = [ + [ + InlineKeyboardButton( + text=cls.BACK_BUTTON_TEXT, + callback_data=f"settings:section:{path}", + ) + ] + ] + + callback_prefix = path.replace(".", "_") if path != "root" else "root" paginator = Paginator( data=InlineKeyboardMarkup(inline_keyboard=keyboard), - after_data=[ - [ - InlineKeyboardButton( - text=cls.BACK_BUTTON_TEXT, - callback_data=f"settings:section:{section_name}", - ) - ] - ], - callback_startswith=f"settings_select_{section_name}_{field_name}_page_", + after_data=after_data, + callback_startswith=f"settings_select_{callback_prefix}_{field}_page_", size=cls.ITEMS_PER_PAGE, dp=router, ) - return paginator() @classmethod def get_list_menu( cls, - section_name: str, - field_name: str, - settings: UserSettings, + path: str, + field: str, + settings: BaseSettings, router: Optional[Router] = None, ) -> InlineKeyboardMarkup: - section = getattr(settings, section_name) - if not section: - return InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=cls.BACK_BUTTON_TEXT, callback_data="settings:main" - ) - ] - ] - ) + if path == "root": + section = settings + else: + try: + section = settings.get_setting(path) + except (AttributeError, KeyError): + return cls._get_default_back_menu("settings:main") - info = section.get_info(field_name) + info = section.get_info(field) if not info: - return InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=cls.BACK_BUTTON_TEXT, - callback_data=f"settings:section:{section_name}", - ) - ] - ] - ) + return cls._get_default_back_menu(f"settings:section:{path}") - current_list = getattr(section, field_name) + current_list = getattr(section, field) + if not isinstance(current_list, list): + return cls._get_default_back_menu(f"settings:section:{path}") keyboard = [] for i, item in enumerate(current_list): item_text = info.options.get(item, item) if info.options else item - row = [ InlineKeyboardButton(text=f"{i + 1}. {item_text}", callback_data="_") ] @@ -252,14 +307,14 @@ class SettingsMenuGenerator: move_buttons.append( InlineKeyboardButton( text="⬆️", - callback_data=f"settings:move:{section_name}:{field_name}:{i}:up", + callback_data=f"settings:move:{path}:{field}:{i}:up", ) ) if i < len(current_list) - 1: move_buttons.append( InlineKeyboardButton( text="⬇️", - callback_data=f"settings:move:{section_name}:{field_name}:{i}:down", + callback_data=f"settings:move:{path}:{field}:{i}:down", ) ) @@ -269,31 +324,45 @@ class SettingsMenuGenerator: [ InlineKeyboardButton( text=cls.BACK_BUTTON_TEXT, - callback_data=f"settings:section:{section_name}", + callback_data=f"settings:section:{path}", ) ] ] + callback_prefix = path.replace(".", "_") if path != "root" else "root" paginator = Paginator( data=InlineKeyboardMarkup(inline_keyboard=keyboard), after_data=after_data, - callback_startswith=f"settings_list_{section_name}_{field_name}_page_", + callback_startswith=f"settings_list_{callback_prefix}_{field}_page_", size=cls.ITEMS_PER_PAGE, dp=router, ) - return paginator() @classmethod - def get_input_prompt( - cls, section_name: str, field_name: str, settings: UserSettings - ) -> str: - section = getattr(settings, section_name) - if not section: - return "Enter your value:" + def get_input_prompt(cls, path: str, field: str, settings: BaseSettings) -> str: + if path == "root": + section = settings + else: + try: + section = settings.get_setting(path) + except (AttributeError, KeyError): + return "Enter your value:" - info = section.get_info(field_name) + info = section.get_info(field) if not info: return "Enter your value:" return f"{info.title}\n{info.description}\n\nPlease enter your value:" + + @classmethod + def _get_default_back_menu(cls, back_target: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=cls.BACK_BUTTON_TEXT, callback_data=back_target + ) + ] + ] + ) diff --git a/src/bot/modules/settings/model.py b/src/bot/modules/settings/model.py index dc286f2..ffe43b8 100644 --- a/src/bot/modules/settings/model.py +++ b/src/bot/modules/settings/model.py @@ -1,7 +1,9 @@ -from typing import ClassVar, Dict, Optional, Unpack +from typing import Any, ClassVar, Dict, Generic, Optional, TypeVar, Unpack from pydantic import BaseModel, ConfigDict +T = TypeVar("T") + class SettingInfo: def __init__( @@ -35,6 +37,103 @@ class BaseSettings(BaseModel): def get_all_info(self) -> Dict[str, SettingInfo]: return self.__class__._settings_info + @staticmethod + def _get_nested_value(obj: Any, path: str): + if not path or path == "root": + return obj + + parts = path.split(".", 1) + current = parts[0] + + if not hasattr(obj, current): + raise AttributeError(f"Object has no attribute '{current}'") + + if len(parts) == 1: + return getattr(obj, current) + + return BaseSettings._get_nested_value(getattr(obj, current), parts[1]) + + @staticmethod + def _set_nested_value(obj: Any, path: str, value: Any): + if not path or path == "root": + return + + parts = path.split(".", 1) + current = parts[0] + + if not hasattr(obj, current): + raise AttributeError(f"Object has no attribute '{current}'") + + if len(parts) == 1: + setattr(obj, current, value) + return + + BaseSettings._set_nested_value(getattr(obj, current), parts[1], value) + + def get_setting(self, path: str) -> Any: + return self._get_nested_value(self, path) + + def set_setting(self, path: str, value: Any) -> None: + self._set_nested_value(self, path, value) + + def get_nested_info(self, path: str) -> Optional[SettingInfo]: + if not path or path == "root": + return None + + parts = path.split(".") + if len(parts) == 1: + return self.get_info(parts[0]) + + section_name = parts[0] + if not hasattr(self, section_name): + return None + + section = getattr(self, section_name) + if not isinstance(section, BaseSettings): + return None + + return section.get_nested_info(".".join(parts[1:])) + class Config: populate_by_name = True arbitrary_types_allowed = True + + +class SettingsAccessor(Generic[T]): + def __init__(self, root_settings: BaseSettings, path: str = ""): + self._root = root_settings + self._path = path + + def __getattr__(self, name: str) -> Any: + full_path = f"{self._path}.{name}" if self._path else name + + value = self._root.get_setting(full_path) + + if isinstance(value, BaseSettings): + return SettingsAccessor(self._root, full_path) + + return value + + def __setattr__(self, name: str, value: Any) -> None: + if name.startswith("_"): + super().__setattr__(name, value) + return + + full_path = f"{self._path}.{name}" if self._path else name + self._root.set_setting(full_path, value) + + +class AccessibleSettings(BaseSettings): + def __init__(self, **data): + super().__init__(**data) + self._accessor = SettingsAccessor(self) + + def __getattr__(self, name: str) -> Any: + try: + return super().__getattr__(name) + except AttributeError: + if name.startswith("_"): + raise + if hasattr(self._accessor, name): + return getattr(self._accessor, name) + raise diff --git a/src/bot/modules/settings/settings.py b/src/bot/modules/settings/settings.py index e126fb9..ee7ace5 100644 --- a/src/bot/modules/settings/settings.py +++ b/src/bot/modules/settings/settings.py @@ -2,7 +2,68 @@ from typing import ClassVar, List from pydantic import Field -from bot.modules.settings.model import BaseSettings, SettingInfo +from .model import AccessibleSettings, BaseSettings, SettingInfo + + +class FormatSettings(BaseSettings): + bitrate: str = Field(default=320) + bitrate_info: ClassVar[SettingInfo] = SettingInfo( + title="Bitrate", + description="Audio quality in kbps", + options={ + "128": "Low (128kbps)", + "256": "Medium (256kbps)", + "320": "High (320kbps)", + }, + order=10, + ) + + codec: str = Field(default="mp3") + codec_info: ClassVar[SettingInfo] = SettingInfo( + title="Audio Codec", + description="Format to encode audio files", + options={"mp3": "MP3", "ogg": "OGG Vorbis", "m4a": "AAC"}, + order=20, + ) + + +class MusicSettings(BaseSettings): + format: FormatSettings = Field(default_factory=FormatSettings) + format_info: ClassVar[SettingInfo] = SettingInfo( + title="Format Settings", + description="Audio format configuration", + order=10, + ) + + normalize_volume: bool = Field(default=True) + normalize_volume_info: ClassVar[SettingInfo] = SettingInfo( + title="Normalize Volume", + description="Automatically adjust volume to a standard level", + order=30, + ) + + add_metadata: bool = Field(default=True) + add_metadata_info: ClassVar[SettingInfo] = SettingInfo( + title="Add Metadata", + description="Include artist, album and track information in files", + order=40, + ) + + +class NotificationSettings(BaseSettings): + downloads_complete: bool = Field(default=True) + downloads_complete_info: ClassVar[SettingInfo] = SettingInfo( + title="Download Notifications", + description="Notify when downloads are complete", + order=10, + ) + + new_features: bool = Field(default=True) + new_features_info: ClassVar[SettingInfo] = SettingInfo( + title="Feature Announcements", + description="Receive notifications about new bot features", + order=20, + ) class SearchSettings(BaseSettings): @@ -21,24 +82,6 @@ class SearchSettings(BaseSettings): order=20, ) - -class DownloadSettings(BaseSettings): - recode_youtube: bool = Field(default=True) - recode_youtube_info: ClassVar[SettingInfo] = SettingInfo( - title="Recode YouTube", - description="Recode when downloading from YouTube to more compatible format (may take some time)", - order=10, - ) - - exact_spotify_match: bool = Field(default=True) - exact_spotify_match_info: ClassVar[SettingInfo] = SettingInfo( - title="Exact Spotify matches", - description="When searching on YouTube from Spotify, show only exact matches", - order=20, - ) - - -class ProviderSettings(BaseSettings): order: List[str] = Field(default=["youtube", "deezer", "soundcloud", "spotify"]) order_info: ClassVar[SettingInfo] = SettingInfo( title="Provider order", @@ -49,22 +92,36 @@ class ProviderSettings(BaseSettings): "soundcloud": "SoundCloud", "spotify": "Spotify", }, - order=10, + order=30, ) -class AppearanceSettings(BaseSettings): - theme: str = Field(default="default") - theme_info: ClassVar[SettingInfo] = SettingInfo( - title="Theme", - description="Visual appearance of the bot", - options={"default": "Default", "compact": "Compact", "emoji": "Emoji Rich"}, - order=10, - ) - - -class UserSettings(BaseSettings): +class UserSettings(AccessibleSettings): search: SearchSettings = Field(default_factory=SearchSettings) - download: DownloadSettings = Field(default_factory=DownloadSettings) - providers: ProviderSettings = Field(default_factory=ProviderSettings) - appearance: AppearanceSettings = Field(default_factory=AppearanceSettings) + search_info: ClassVar[SettingInfo] = SettingInfo( + title="Search Settings", + description="Configure search behavior", + order=10, + ) + + music: MusicSettings = Field(default_factory=MusicSettings) + music_info: ClassVar[SettingInfo] = SettingInfo( + title="Music Settings", + description="Configure music playback and downloads", + order=20, + ) + + notifications: NotificationSettings = Field(default_factory=NotificationSettings) + notifications_info: ClassVar[SettingInfo] = SettingInfo( + title="Notifications", + description="Configure notification preferences", + order=30, + ) + + language: str = Field(default="en") + language_info: ClassVar[SettingInfo] = SettingInfo( + title="Language", + description="Bot interface language", + options={"en": "English", "es": "Spanish", "fr": "French", "de": "German"}, + order=40, + ) diff --git a/src/utils/db/models/settings.py b/src/utils/db/models/settings.py index b6386e0..53617de 100644 --- a/src/utils/db/models/settings.py +++ b/src/utils/db/models/settings.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Optional from beanie import Document from pydantic import Field @@ -27,27 +27,24 @@ class UserSettingsDocument(Document): await self.save() @classmethod - async def update_section(cls, user_id: int, section: str, value: Any) -> None: + async def update_setting(cls, user_id: int, path: str, value: Any) -> None: doc = await cls.find_one(cls.user_id == user_id) if not doc: doc = cls(user_id=user_id) - setattr(doc.settings, section, value) await doc.insert() - else: - setattr(doc.settings, section, value) - await doc.save() + + doc.settings.set_setting(path, value) + await doc.save() @classmethod - async def update_field( - cls, user_id: int, section: str, field: str, value: Any - ) -> None: + async def get_setting(cls, user_id: int, path: str) -> Any: doc = await cls.find_one(cls.user_id == user_id) if not doc: - doc = cls(user_id=user_id) - section_obj = getattr(doc.settings, section) - setattr(section_obj, field, value) - await doc.insert() - else: - section_obj = getattr(doc.settings, section) - setattr(section_obj, field, value) - await doc.save() + return UserSettings().get_setting(path) + + return doc.settings.get_setting(path) + + @classmethod + async def get_setting_info(cls, path: str) -> Optional[Any]: + settings = UserSettings() + return settings.get_nested_info(path)