add options functionality for input editors
All checks were successful
Build Docs / changes (push) Successful in 25s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped

This commit is contained in:
Alexander Kalinovsky
2025-06-10 23:31:31 +03:00
parent 923b0e6cc9
commit 4ac80e0105
12 changed files with 178 additions and 80 deletions

View File

@@ -119,6 +119,7 @@ async def show_editor(message: Message | CallbackQuery, **kwargs):
) )
).format(name=caption_str) ).format(name=caption_str)
kwargs["entity_data"] = entity_data
kwargs["edit_prompt"] = edit_prompt kwargs["edit_prompt"] = edit_prompt
if value_type not in [int, float, Decimal, str]: if value_type not in [int, float, Decimal, str]:

View File

@@ -103,6 +103,8 @@ async def render_entity_picker(
message=message, message=message,
) )
entity = None
if issubclass(type_, BotEnum): if issubclass(type_, BotEnum):
items_count = len(type_.all_members) items_count = len(type_.all_members)
total_pages = calc_total_pages(items_count, page_size) total_pages = calc_total_pages(items_count, page_size)
@@ -112,7 +114,6 @@ async def render_entity_picker(
page_size * (page - 1) : page_size * page page_size * (page - 1) : page_size * page
] ]
elif callable(field_descriptor.options): elif callable(field_descriptor.options):
entity = None
if callback_data.entity_id: if callback_data.entity_id:
entity = await field_descriptor.entity_descriptor.type_.get( entity = await field_descriptor.entity_descriptor.type_.get(
session=db_session, id=callback_data.entity_id session=db_session, id=callback_data.entity_id
@@ -126,13 +127,25 @@ async def render_entity_picker(
enum_items = list(type_.all_members.values())[ enum_items = list(type_.all_members.values())[
page_size * (page - 1) : page_size * page page_size * (page - 1) : page_size * page
] ]
items = [ items = []
{ for it_row in enum_items:
"text": f"{'' if not is_list else '【✔︎】 ' if item in (current_value or []) else '【 】 '}{item.localized(user.lang)}", if not isinstance(it_row, list):
"value": item.value, it_row = [it_row]
} item_row = []
for item in enum_items for item in it_row:
] if isinstance(item, tuple):
item_value, item_text = item
else:
item_value = item
item_text = item.localized(user.lang)
item_row.append(
{
"text": f"{'' if not is_list else '[✓] ' if item_value in (current_value or []) else '[ ] '}{item_text}",
"value": item_value.value,
}
)
items.append(item_row)
elif issubclass(type_, BotEntity): elif issubclass(type_, BotEntity):
ep_form = "default" ep_form = "default"
ep_form_params = [] ep_form_params = []
@@ -224,55 +237,60 @@ async def render_entity_picker(
entity_items = list[BotEntity]() entity_items = list[BotEntity]()
items = [ items = [
{ [
"text": f"{ {
'' "text": f"{
if not is_list ''
else '【✔︎】 ' if not is_list
if item in (current_value or []) else '[✓] '
else '【 】 ' if item in (current_value or [])
}{ else '[ ] '
await get_callable_str( }{
callable_str=type_.bot_entity_descriptor.item_repr, await get_callable_str(
context=context, callable_str=type_.bot_entity_descriptor.item_repr,
entity=item, context=context,
) entity=item,
if type_.bot_entity_descriptor.item_repr )
else await get_callable_str( if type_.bot_entity_descriptor.item_repr
callable_str=type_.bot_entity_descriptor.full_name, else await get_callable_str(
context=context, callable_str=type_.bot_entity_descriptor.full_name,
descriptor=type_.bot_entity_descriptor, context=context,
) descriptor=type_.bot_entity_descriptor,
if type_.bot_entity_descriptor.full_name )
else f'{type_.bot_entity_descriptor.name}: {str(item.id)}' if type_.bot_entity_descriptor.full_name
}", else f'{type_.bot_entity_descriptor.name}: {str(item.id)}'
"value": str(item.id), }",
} "value": str(item.id),
}
]
for item in entity_items for item in entity_items
] ]
keyboard_builder = InlineKeyboardBuilder() keyboard_builder = InlineKeyboardBuilder()
for item in items: for item_row in items:
keyboard_builder.row( btn_row = []
InlineKeyboardButton( for item in item_row:
text=item["text"], btn_row.append(
callback_data=ContextData( InlineKeyboardButton(
command=( text=item["text"],
CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM callback_data=ContextData(
if is_list command=(
else CallbackCommand.FIELD_EDITOR_CALLBACK CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM
), if is_list
context=callback_data.context, else CallbackCommand.FIELD_EDITOR_CALLBACK
entity_name=callback_data.entity_name, ),
entity_id=callback_data.entity_id, context=callback_data.context,
field_name=callback_data.field_name, entity_name=callback_data.entity_name,
form_params=callback_data.form_params, entity_id=callback_data.entity_id,
user_command=callback_data.user_command, field_name=callback_data.field_name,
data=f"{page}&{item['value']}" if is_list else item["value"], form_params=callback_data.form_params,
).pack(), user_command=callback_data.user_command,
data=f"{page}&{item['value']}" if is_list else item["value"],
).pack(),
)
) )
) keyboard_builder.row(*btn_row)
if form_list and form_list.pagination: if form_list and form_list.pagination:
add_pagination_controls( add_pagination_controls(
@@ -321,6 +339,7 @@ async def render_entity_picker(
state_data=state_data, state_data=state_data,
user=user, user=user,
context=context, context=context,
entity=entity,
) )
await state.set_data(state_data) await state.set_data(state_data)

View File

@@ -261,7 +261,9 @@ async def field_editor(message: Message | CallbackQuery, **kwargs):
async def delete_message_callback(query: CallbackQuery, **kwargs): async def delete_message_callback(query: CallbackQuery, **kwargs):
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = await state.get_data() state_data = await state.get_data()
clear_state(state_data=state_data) context_data: ContextData = kwargs["callback_data"]
clear_nav = context_data.data == "clear_nav"
clear_state(state_data=state_data, clear_nav=clear_nav)
await state.set_data(state_data) await state.set_data(state_data)
await query.message.delete() await query.message.delete()

View File

@@ -67,18 +67,23 @@ async def _validate_value(
) )
async def field_editor_callback(message: Message | CallbackQuery, **kwargs): async def field_editor_callback(message: Message | CallbackQuery, **kwargs):
app: "QBotApp" = kwargs["app"] app: "QBotApp" = kwargs["app"]
state: FSMContext = kwargs["state"] callback_data: ContextData = kwargs.get("callback_data", None)
state: FSMContext = kwargs["state"]
state_data = await state.get_data() state_data = await state.get_data()
kwargs["state_data"] = state_data kwargs["state_data"] = state_data
if isinstance(message, Message): if isinstance(message, Message):
callback_data: ContextData = kwargs.get("callback_data", None)
context_data = state_data.get("context_data") context_data = state_data.get("context_data")
if context_data: if context_data:
callback_data = ContextData.unpack(context_data) callback_data = ContextData.unpack(context_data)
value = message.text
field_descriptor = get_field_descriptor(app, callback_data) field_descriptor = get_field_descriptor(app, callback_data)
if not field_descriptor.options_custom_value:
return
value = message.text
type_base = field_descriptor.type_base type_base = field_descriptor.type_base
if type_base in [int, float, Decimal]: if type_base in [int, float, Decimal]:
@@ -132,21 +137,23 @@ async def field_editor_callback(message: Message | CallbackQuery, **kwargs):
current_value = state_data.get("current_value") current_value = state_data.get("current_value")
state_data.update({"value": value}) state_data.update({"value": value})
entity_descriptor = get_entity_descriptor(app, callback_data) # entity_descriptor = field_descriptor.entity_descriptor
# entity_descriptor = get_entity_descriptor(app, callback_data)
kwargs.update({"callback_data": callback_data}) kwargs.update({"callback_data": callback_data})
return await show_editor( return await show_editor(
message=message, message=message,
locale_index=locale_index + 1, locale_index=locale_index + 1,
field_descriptor=field_descriptor, field_descriptor=field_descriptor,
entity_descriptor=entity_descriptor, # entity_descriptor=entity_descriptor,
current_value=current_value, current_value=current_value,
value=value, value=value,
**kwargs, **kwargs,
) )
else: else:
callback_data: ContextData = kwargs["callback_data"] field_descriptor = get_field_descriptor(app, callback_data)
if callback_data.data: if callback_data.data:
if callback_data.data == "skip": if callback_data.data == "skip":
value = None value = None
@@ -154,7 +161,6 @@ async def field_editor_callback(message: Message | CallbackQuery, **kwargs):
value = callback_data.data value = callback_data.data
else: else:
value = state_data.get("value") value = state_data.get("value")
field_descriptor = get_field_descriptor(app, callback_data)
kwargs.update( kwargs.update(
{ {

View File

@@ -74,7 +74,11 @@ async def string_editor(
state_data.update({"context_data": context_data.pack()}) state_data.update({"context_data": context_data.pack()})
if _current_value: if (
_current_value
and field_descriptor.show_current_value_button
and field_descriptor.options_custom_value
):
_current_value_caption = ( _current_value_caption = (
f"{_current_value[:30]}..." if len(_current_value) > 30 else _current_value f"{_current_value[:30]}..." if len(_current_value) > 30 else _current_value
) )
@@ -102,6 +106,7 @@ async def string_editor(
state_data=state_data, state_data=state_data,
user=user, user=user,
context=context, context=context,
entity=kwargs.get("entity_data"),
) )
await state.set_data(state_data) await state.set_data(state_data)

View File

@@ -1,12 +1,17 @@
from aiogram.types import InlineKeyboardButton from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from inspect import iscoroutinefunction
from typing import Any
from ....model.bot_entity import BotEntity
from ....model.bot_enum import BotEnum
from ....model.settings import Settings from ....model.settings import Settings
from ....model.descriptors import BotContext, EntityForm, FieldDescriptor from ....model.descriptors import BotContext, EntityForm, FieldDescriptor
from ....model.user import UserBase from ....model.user import UserBase
from ..context import ContextData, CallbackCommand, CommandContext from ..context import ContextData, CallbackCommand, CommandContext
from ....utils.navigation import get_navigation_context, pop_navigation_context from ....utils.navigation import get_navigation_context, pop_navigation_context
from ....utils.main import build_field_sequence from ....utils.main import build_field_sequence, get_value_repr
from ....utils.serialization import serialize
async def wrap_editor( async def wrap_editor(
@@ -16,6 +21,7 @@ async def wrap_editor(
state_data: dict, state_data: dict,
user: UserBase, user: UserBase,
context: BotContext, context: BotContext,
entity: BotEntity | Any = None,
): ):
if callback_data.context in [ if callback_data.context in [
CommandContext.ENTITY_CREATE, CommandContext.ENTITY_CREATE,
@@ -23,9 +29,9 @@ async def wrap_editor(
CommandContext.ENTITY_FIELD_EDIT, CommandContext.ENTITY_FIELD_EDIT,
CommandContext.COMMAND_FORM, CommandContext.COMMAND_FORM,
]: ]:
btns = []
show_back = True show_back = True
show_cancel = True show_cancel = True
if callback_data.context == CommandContext.COMMAND_FORM: if callback_data.context == CommandContext.COMMAND_FORM:
field_sequence = list(field_descriptor.command.param_form.keys()) field_sequence = list(field_descriptor.command.param_form.keys())
field_index = field_sequence.index(callback_data.field_name) field_index = field_sequence.index(callback_data.field_name)
@@ -57,8 +63,55 @@ async def wrap_editor(
else 0 else 0
) )
stack, context = get_navigation_context(state_data=state_data) stack, navigation_context = get_navigation_context(state_data=state_data)
context = pop_navigation_context(stack) navigation_context = pop_navigation_context(stack)
if not issubclass(field_descriptor.type_base, BotEnum):
options = []
if field_descriptor.options:
if isinstance(field_descriptor.options, list):
options = field_descriptor.options
elif callable(field_descriptor.options):
if iscoroutinefunction(field_descriptor.options):
options = await field_descriptor.options(entity, context)
else:
options = field_descriptor.options(entity, context)
for option_row in options:
btns_row = []
for option in option_row:
if isinstance(option, tuple):
value = option[0]
caption = option[1]
else:
value = option
caption = await get_value_repr(
value=value,
field_descriptor=field_descriptor,
context=context,
)
btns_row.append(
InlineKeyboardButton(
text=caption,
callback_data=ContextData(
command=CallbackCommand.FIELD_EDITOR_CALLBACK,
context=callback_data.context,
entity_name=callback_data.entity_name,
entity_id=callback_data.entity_id,
field_name=callback_data.field_name,
form_params=callback_data.form_params,
user_command=callback_data.user_command,
data=serialize(
value=value,
field_descriptor=field_descriptor,
),
).pack(),
)
)
keyboard_builder.row(*btns_row)
btns = []
if field_index > 0 and show_back: if field_index > 0 and show_back:
btns.append( btns.append(
@@ -102,9 +155,11 @@ async def wrap_editor(
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_CANCEL_BTN)), text=(await Settings.get(Settings.APP_STRINGS_CANCEL_BTN)),
callback_data=context.pack() callback_data=navigation_context.pack()
if context if navigation_context
else ContextData(command=CallbackCommand.DELETE_MESSAGE).pack(), else ContextData(
command=CallbackCommand.DELETE_MESSAGE, data="clear_nav"
).pack(),
) )
) )

View File

@@ -144,7 +144,7 @@ async def entity_item(
) )
else: else:
if field_descriptor.type_base is bool: if field_descriptor.type_base is bool:
btn_text = f"{'【✔︎】 ' if field_value else ' '}{ btn_text = f"{'[✓] ' if field_value else '[ ] '}{
await get_callable_str( await get_callable_str(
callable_str=field_descriptor.caption, callable_str=field_descriptor.caption,
context=context, context=context,

View File

@@ -240,12 +240,12 @@ async def entity_list(
filtering_fields=form_list.filtering_fields, filtering_fields=form_list.filtering_fields,
) )
context = pop_navigation_context(navigation_stack) navigation_context = pop_navigation_context(navigation_stack)
if context: if navigation_context:
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)), text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data=context.pack(), callback_data=navigation_context.pack(),
) )
) )

View File

@@ -81,7 +81,7 @@ async def parameters_menu(
caption = key.name caption = key.name
if key.type_ is bool: if key.type_ is bool:
caption = f"{'【✔︎】' if value else ' '} {caption}" caption = f"{'[✓]' if value else '[ ]'} {caption}"
else: else:
caption = f"{caption}: {await get_value_repr(value=value, field_descriptor=key, context=context, locale=user.lang)}" caption = f"{caption}: {await get_value_repr(value=value, field_descriptor=key, context=context, locale=user.lang)}"

View File

@@ -103,7 +103,9 @@ class QBotApp(Generic[UserType, ConfigType], FastAPI):
self.bot = Bot( self.bot = Bot(
token=self.config.TELEGRAM_BOT_TOKEN, token=self.config.TELEGRAM_BOT_TOKEN,
session=session, session=session,
default=DefaultBotProperties(parse_mode="HTML"), default=DefaultBotProperties(
parse_mode="HTML", link_preview_is_disabled=True
),
) )
dp = Dispatcher(storage=DbStorage()) dp = Dispatcher(storage=DbStorage())

View File

@@ -125,7 +125,13 @@ class _BaseFieldDescriptor[T: "BotEntity"]:
ep_parent_field: str | None = None ep_parent_field: str | None = None
ep_child_field: str | None = None ep_child_field: str | None = None
dt_type: Literal["date", "datetime"] = "date" dt_type: Literal["date", "datetime"] = "date"
options: list[Any] | Callable[[T, "BotContext"], list[Any]] | None = None options: (
list[list[Union[Any, tuple[Any, str]]]]
| Callable[[T, "BotContext"], list[list[Union[Any, tuple[Any, str]]]]]
| None
) = None
options_custom_value: bool = True
show_current_value_button: bool = True
show_skip_in_editor: Literal[False, "Auto"] = "Auto" show_skip_in_editor: Literal[False, "Auto"] = "Auto"
default: Any = None default: Any = None
default_factory: Callable[[], Any] | None = None default_factory: Callable[[], Any] | None = None

View File

@@ -19,6 +19,7 @@ from ..model.descriptors import (
EntityDescriptor, EntityDescriptor,
EntityPermission, EntityPermission,
_BaseFieldDescriptor, _BaseFieldDescriptor,
_BaseEntityDescriptor,
Filter, Filter,
) )
@@ -76,11 +77,11 @@ def check_entity_permission(
if perm_mapping[permission] in permissions: if perm_mapping[permission] in permissions:
return True return True
ownership_filds = entity_descriptor.ownership_fields ownership_fields = entity_descriptor.ownership_fields
for role in user.roles: for role in user.roles:
if role in ownership_filds: if role in ownership_fields:
if getattr(entity, ownership_filds[role]) == user.id: if getattr(entity, ownership_fields[role]) == user.id:
return True return True
else: else:
if permission in permissions: if permission in permissions:
@@ -148,7 +149,7 @@ async def get_value_repr(
type_ = field_descriptor.type_base type_ = field_descriptor.type_base
if isinstance(value, bool): if isinstance(value, bool):
return "【✔︎】" if value else "【 】" return "[✓]" if value else "[ ]"
elif field_descriptor.is_list: elif field_descriptor.is_list:
if issubclass(type_, BotEntity): if issubclass(type_, BotEntity):
return f"[{ return f"[{
@@ -204,8 +205,9 @@ async def get_callable_str(
return await callable_str(descriptor, entity, context) return await callable_str(descriptor, entity, context)
else: else:
param = args[next(iter(args))] param = args[next(iter(args))]
if not isinstance(param.annotation, str) and issubclass( if not isinstance(param.annotation, str) and (
param.annotation, _BaseFieldDescriptor issubclass(param.annotation, _BaseFieldDescriptor)
or issubclass(param.annotation, _BaseEntityDescriptor)
): ):
return await callable_str(descriptor, context) return await callable_str(descriptor, context)
else: else: