[upd] nested settings

This commit is contained in:
h
2025-03-29 12:13:17 +02:00
parent 0f17c1f314
commit 8acbbb286c
8 changed files with 497 additions and 211 deletions

View File

@@ -23,15 +23,15 @@ async def on_input_setting(
state: FSMContext, state: FSMContext,
user_settings: FromDishka[UserSettings], 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 state.set_state(SettingsStates.waiting_for_input)
await callback.answer() await callback.answer()
await bot.send_message( await bot.send_message(
chat_id=callback.from_user.id, 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", parse_mode="HTML",
) )
@@ -43,22 +43,32 @@ async def on_input_value(
user_settings: FromDishka[UserSettings], user_settings: FromDishka[UserSettings],
): ):
data = await state.get_data() data = await state.get_data()
section = data["section"] path = data["path"]
field = data["field"] field = data["field"]
await state.clear() 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( await UserSettingsDocument.update_setting(
message.from_user.id, section, field, message.text message.from_user.id, f"{path}.{field}", message.text
) )
except (AttributeError, KeyError):
await message.answer("Setting not found")
return
await message.answer( await message.answer(
"✅ Setting updated!", "✅ Setting updated!",
reply_markup=SettingsMenuGenerator.get_section_menu( reply_markup=SettingsMenuGenerator.get_section_menu(
section, user_settings, router path, user_settings, router
), ),
) )

View File

@@ -1,25 +1,31 @@
from aiogram import Bot, F, Router, types from aiogram import Bot, F, Router, types
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from dishka import FromDishka
from bot.keyboards.settings import SettingsMenuGenerator from bot.keyboards.settings import SettingsMenuGenerator
from bot.modules.settings import UserSettings
router = Router() router = Router()
@router.callback_query(F.data == "settings:main") @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 callback.answer()
await bot.edit_message_text( await bot.edit_message_text(
"⚙️ Settings", "⚙️ Settings",
chat_id=callback.from_user.id, chat_id=callback.from_user.id,
message_id=callback.message.message_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")) @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( await message.answer(
"⚙️ Settings", reply_markup=SettingsMenuGenerator.get_main_menu() "⚙️ Settings", reply_markup=SettingsMenuGenerator.get_main_menu(user_settings)
) )

View File

@@ -12,14 +12,17 @@ router = Router()
async def on_section_settings( async def on_section_settings(
callback: CallbackQuery, bot: Bot, user_settings: FromDishka[UserSettings] 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 callback.answer()
await bot.edit_message_text( await bot.edit_message_text(
f"⚙️ {section.capitalize()} settings", f"⚙️ {section_title} settings",
chat_id=callback.from_user.id, chat_id=callback.from_user.id,
message_id=callback.message.message_id, message_id=callback.message.message_id,
reply_markup=SettingsMenuGenerator.get_section_menu( 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( async def on_select_setting(
callback: CallbackQuery, bot: Bot, user_settings: FromDishka[UserSettings] 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: if not info:
await callback.answer("Setting info not found") await callback.answer("Setting info not found")
return return
@@ -45,6 +56,6 @@ async def on_select_setting(
chat_id=callback.from_user.id, chat_id=callback.from_user.id,
message_id=callback.message.message_id, message_id=callback.message.message_id,
reply_markup=getattr(SettingsMenuGenerator, f"get_{mode}_menu")( reply_markup=getattr(SettingsMenuGenerator, f"get_{mode}_menu")(
section, field, user_settings, router path, field, user_settings, router
), ),
) )

View File

@@ -13,23 +13,37 @@ router = Router()
async def on_toggle_setting( async def on_toggle_setting(
callback: CallbackQuery, bot: Bot, user_settings: FromDishka[UserSettings] 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) await UserSettingsDocument.update_setting(
setattr(section_obj, field, not current_value) 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( await UserSettingsDocument.update_setting(
callback.from_user.id, section, field, not current_value 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 callback.answer("Setting updated")
await bot.edit_message_reply_markup( await bot.edit_message_reply_markup(
chat_id=callback.from_user.id, chat_id=callback.from_user.id,
message_id=callback.message.message_id, message_id=callback.message.message_id,
reply_markup=SettingsMenuGenerator.get_section_menu( 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( async def on_set_value(
callback: CallbackQuery, bot: Bot, user_settings: FromDishka[UserSettings] 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_setting(callback.from_user.id, field, value)
await UserSettingsDocument.update_field( else:
callback.from_user.id, section, field, value 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 callback.answer("Setting updated")
await bot.edit_message_reply_markup( await bot.edit_message_reply_markup(
chat_id=callback.from_user.id, chat_id=callback.from_user.id,
message_id=callback.message.message_id, message_id=callback.message.message_id,
reply_markup=SettingsMenuGenerator.get_select_menu( 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( async def on_move_item(
callback: CallbackQuery, bot: Bot, user_settings: FromDishka[UserSettings] 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) index = int(index)
section_obj = getattr(user_settings, section) if path == "root":
current_list = getattr(section_obj, field) 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: if direction == "up" and index > 0:
current_list[index], current_list[index - 1] = ( current_list[index], current_list[index - 1] = (
@@ -78,17 +110,22 @@ async def on_move_item(
current_list[index], current_list[index],
) )
setattr(section_obj, field, current_list) setattr(section, field, current_list)
await UserSettingsDocument.update_field( if path == "root":
callback.from_user.id, section, field, current_list 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 callback.answer("Order updated")
await bot.edit_message_reply_markup( await bot.edit_message_reply_markup(
chat_id=callback.from_user.id, chat_id=callback.from_user.id,
message_id=callback.message.message_id, message_id=callback.message.message_id,
reply_markup=SettingsMenuGenerator.get_list_menu( reply_markup=SettingsMenuGenerator.get_list_menu(
section, field, user_settings, router path, field, user_settings, router
), ),
) )

View File

@@ -4,7 +4,6 @@ from aiogram import Router
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from bot.keyboards.utils import Paginator from bot.keyboards.utils import Paginator
from bot.modules.settings import UserSettings
from bot.modules.settings.model import BaseSettings, SettingInfo from bot.modules.settings.model import BaseSettings, SettingInfo
@@ -13,26 +12,73 @@ class SettingsMenuGenerator:
ITEMS_PER_PAGE = 5 ITEMS_PER_PAGE = 5
@classmethod @classmethod
def get_main_menu(cls, router: Optional[Router] = None) -> InlineKeyboardMarkup: def get_main_menu(
cls, settings: BaseSettings, router: Optional[Router] = None
) -> InlineKeyboardMarkup:
keyboard = [] keyboard = []
fields = get_type_hints(UserSettings) fields = get_type_hints(settings.__class__)
for field_name, field_type in fields.items(): sorted_fields = sorted(fields.items(), key=lambda x: x[0])
if (
not isinstance(field_type, type) for field_name, field_type in sorted_fields:
or not issubclass(field_type, BaseSettings) if field_name.startswith("_"):
or field_name.startswith("_")
):
continue continue
keyboard.append( if hasattr(settings, field_name):
[ field_value = getattr(settings, field_name)
InlineKeyboardButton(
text=field_name.capitalize(), if isinstance(field_value, BaseSettings):
callback_data=f"settings:section:{field_name}", 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( paginator = Paginator(
data=InlineKeyboardMarkup(inline_keyboard=keyboard), data=InlineKeyboardMarkup(inline_keyboard=keyboard),
@@ -46,13 +92,17 @@ class SettingsMenuGenerator:
@classmethod @classmethod
def get_section_menu( def get_section_menu(
cls, cls,
section_name: str, path: str,
settings: UserSettings, settings: BaseSettings,
router: Optional[Router] = None, router: Optional[Router] = None,
page: int = 0, page: int = 0,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
section = getattr(settings, section_name) if path == "root":
if not section: return cls.get_main_menu(settings, router)
try:
section = settings.get_setting(path)
except (AttributeError, KeyError):
return InlineKeyboardMarkup( return InlineKeyboardMarkup(
inline_keyboard=[ 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__) fields = get_type_hints(section.__class__)
field_info = section.get_all_info()
field_names = [ field_names = [
f f
for f in fields.keys() for f in fields.keys()
@@ -76,40 +140,53 @@ class SettingsMenuGenerator:
key=lambda f: field_info.get(f, SettingInfo("", "", order=999)).order key=lambda f: field_info.get(f, SettingInfo("", "", order=999)).order
) )
keyboard = []
for field_name in field_names: for field_name in field_names:
value = getattr(section, field_name) value = getattr(section, field_name)
field_type = fields.get(field_name)
info = section.get_info(field_name) info = section.get_info(field_name)
if info is None: if info is None:
continue title = field_name.replace("_", " ").capitalize()
info = SettingInfo(title=title, description=f"Configure {title}")
field_type = fields[field_name] if isinstance(value, BaseSettings):
if field_type == bool: 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" value_text = "✅ Enabled" if value else "❌ Disabled"
keyboard.append( keyboard.append(
[ [
InlineKeyboardButton( InlineKeyboardButton(
text=f"{info.title}: {value_text}", 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) value_text = info.options.get(value, value)
keyboard.append( keyboard.append(
[ [
InlineKeyboardButton( InlineKeyboardButton(
text=f"{info.title}: {value_text}", 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( keyboard.append(
[ [
InlineKeyboardButton( InlineKeyboardButton(
text=f"{info.title}", 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( InlineKeyboardButton(
text=f"{info.title}: {value}", 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 = [ after_data = [
[ [InlineKeyboardButton(text=cls.BACK_BUTTON_TEXT, callback_data=back_target)]
InlineKeyboardButton(
text=cls.BACK_BUTTON_TEXT, callback_data="settings:main"
)
]
] ]
paginator = Paginator( paginator = Paginator(
data=InlineKeyboardMarkup(inline_keyboard=keyboard), data=InlineKeyboardMarkup(inline_keyboard=keyboard),
after_data=after_data, 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, size=cls.ITEMS_PER_PAGE,
dp=router, dp=router,
) )
return paginator(current_page=page) return paginator(current_page=page)
@classmethod @classmethod
def get_select_menu( def get_select_menu(
cls, cls,
section_name: str, path: str,
field_name: str, field: str,
settings: UserSettings, settings: BaseSettings,
router: Optional[Router] = None, router: Optional[Router] = None,
) -> InlineKeyboardMarkup: ) -> 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: info = section.get_info(field)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=cls.BACK_BUTTON_TEXT, callback_data="settings:main"
)
]
]
)
info = section.get_info(field_name)
if not info or not info.options: if not info or not info.options:
return InlineKeyboardMarkup( return cls._get_default_back_menu(f"settings:section:{path}")
inline_keyboard=[
[ current_value = getattr(section, field)
InlineKeyboardButton(
text=cls.BACK_BUTTON_TEXT,
callback_data=f"settings:section:{section_name}",
)
]
]
)
keyboard = [] keyboard = []
for option_key, option_text in info.options.items(): 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( keyboard.append(
[ [
InlineKeyboardButton( InlineKeyboardButton(
text=f"{prefix}{option_text}", 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( paginator = Paginator(
data=InlineKeyboardMarkup(inline_keyboard=keyboard), data=InlineKeyboardMarkup(inline_keyboard=keyboard),
after_data=[ after_data=after_data,
[ callback_startswith=f"settings_select_{callback_prefix}_{field}_page_",
InlineKeyboardButton(
text=cls.BACK_BUTTON_TEXT,
callback_data=f"settings:section:{section_name}",
)
]
],
callback_startswith=f"settings_select_{section_name}_{field_name}_page_",
size=cls.ITEMS_PER_PAGE, size=cls.ITEMS_PER_PAGE,
dp=router, dp=router,
) )
return paginator() return paginator()
@classmethod @classmethod
def get_list_menu( def get_list_menu(
cls, cls,
section_name: str, path: str,
field_name: str, field: str,
settings: UserSettings, settings: BaseSettings,
router: Optional[Router] = None, router: Optional[Router] = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
section = getattr(settings, section_name) if path == "root":
if not section: section = settings
return InlineKeyboardMarkup( else:
inline_keyboard=[ try:
[ section = settings.get_setting(path)
InlineKeyboardButton( except (AttributeError, KeyError):
text=cls.BACK_BUTTON_TEXT, callback_data="settings:main" return cls._get_default_back_menu("settings:main")
)
]
]
)
info = section.get_info(field_name) info = section.get_info(field)
if not info: if not info:
return InlineKeyboardMarkup( return cls._get_default_back_menu(f"settings:section:{path}")
inline_keyboard=[
[
InlineKeyboardButton(
text=cls.BACK_BUTTON_TEXT,
callback_data=f"settings:section:{section_name}",
)
]
]
)
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 = [] keyboard = []
for i, item in enumerate(current_list): for i, item in enumerate(current_list):
item_text = info.options.get(item, item) if info.options else item item_text = info.options.get(item, item) if info.options else item
row = [ row = [
InlineKeyboardButton(text=f"{i + 1}. {item_text}", callback_data="_") InlineKeyboardButton(text=f"{i + 1}. {item_text}", callback_data="_")
] ]
@@ -252,14 +307,14 @@ class SettingsMenuGenerator:
move_buttons.append( move_buttons.append(
InlineKeyboardButton( InlineKeyboardButton(
text="⬆️", 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: if i < len(current_list) - 1:
move_buttons.append( move_buttons.append(
InlineKeyboardButton( InlineKeyboardButton(
text="⬇️", 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( InlineKeyboardButton(
text=cls.BACK_BUTTON_TEXT, 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( paginator = Paginator(
data=InlineKeyboardMarkup(inline_keyboard=keyboard), data=InlineKeyboardMarkup(inline_keyboard=keyboard),
after_data=after_data, 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, size=cls.ITEMS_PER_PAGE,
dp=router, dp=router,
) )
return paginator() return paginator()
@classmethod @classmethod
def get_input_prompt( def get_input_prompt(cls, path: str, field: str, settings: BaseSettings) -> str:
cls, section_name: str, field_name: str, settings: UserSettings if path == "root":
) -> str: section = settings
section = getattr(settings, section_name) else:
if not section: try:
return "Enter your value:" 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: if not info:
return "Enter your value:" return "Enter your value:"
return f"<b>{info.title}</b>\n{info.description}\n\nPlease enter your value:" return f"<b>{info.title}</b>\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
)
]
]
)

View File

@@ -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 from pydantic import BaseModel, ConfigDict
T = TypeVar("T")
class SettingInfo: class SettingInfo:
def __init__( def __init__(
@@ -35,6 +37,103 @@ class BaseSettings(BaseModel):
def get_all_info(self) -> Dict[str, SettingInfo]: def get_all_info(self) -> Dict[str, SettingInfo]:
return self.__class__._settings_info 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: class Config:
populate_by_name = True populate_by_name = True
arbitrary_types_allowed = 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

View File

@@ -2,7 +2,68 @@ from typing import ClassVar, List
from pydantic import Field 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): class SearchSettings(BaseSettings):
@@ -21,24 +82,6 @@ class SearchSettings(BaseSettings):
order=20, 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: List[str] = Field(default=["youtube", "deezer", "soundcloud", "spotify"])
order_info: ClassVar[SettingInfo] = SettingInfo( order_info: ClassVar[SettingInfo] = SettingInfo(
title="Provider order", title="Provider order",
@@ -49,22 +92,36 @@ class ProviderSettings(BaseSettings):
"soundcloud": "SoundCloud", "soundcloud": "SoundCloud",
"spotify": "Spotify", "spotify": "Spotify",
}, },
order=10, order=30,
) )
class AppearanceSettings(BaseSettings): class UserSettings(AccessibleSettings):
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):
search: SearchSettings = Field(default_factory=SearchSettings) search: SearchSettings = Field(default_factory=SearchSettings)
download: DownloadSettings = Field(default_factory=DownloadSettings) search_info: ClassVar[SettingInfo] = SettingInfo(
providers: ProviderSettings = Field(default_factory=ProviderSettings) title="Search Settings",
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings) 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,
)

View File

@@ -1,4 +1,4 @@
from typing import Any from typing import Any, Optional
from beanie import Document from beanie import Document
from pydantic import Field from pydantic import Field
@@ -27,27 +27,24 @@ class UserSettingsDocument(Document):
await self.save() await self.save()
@classmethod @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) doc = await cls.find_one(cls.user_id == user_id)
if not doc: if not doc:
doc = cls(user_id=user_id) doc = cls(user_id=user_id)
setattr(doc.settings, section, value)
await doc.insert() await doc.insert()
else:
setattr(doc.settings, section, value) doc.settings.set_setting(path, value)
await doc.save() await doc.save()
@classmethod @classmethod
async def update_field( async def get_setting(cls, user_id: int, path: str) -> Any:
cls, user_id: int, section: str, field: str, value: Any
) -> None:
doc = await cls.find_one(cls.user_id == user_id) doc = await cls.find_one(cls.user_id == user_id)
if not doc: if not doc:
doc = cls(user_id=user_id) return UserSettings().get_setting(path)
section_obj = getattr(doc.settings, section)
setattr(section_obj, field, value) return doc.settings.get_setting(path)
await doc.insert()
else: @classmethod
section_obj = getattr(doc.settings, section) async def get_setting_info(cls, path: str) -> Optional[Any]:
setattr(section_obj, field, value) settings = UserSettings()
await doc.save() return settings.get_nested_info(path)