diff --git a/src/quickbot/api_route/telegram.py b/src/quickbot/api_route/telegram.py index 27bb806..4e46b39 100644 --- a/src/quickbot/api_route/telegram.py +++ b/src/quickbot/api_route/telegram.py @@ -1,5 +1,5 @@ from typing import Annotated -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, Request, Response, BackgroundTasks from sqlmodel.ext.asyncio.session import AsyncSession from ..main import QBotApp @@ -15,7 +15,9 @@ router = APIRouter() @router.post("/webhook") async def telegram_webhook( - db_session: Annotated[AsyncSession, Depends(get_db)], request: Request + db_session: Annotated[AsyncSession, Depends(get_db)], + request: Request, + background_tasks: BackgroundTasks, ): logger.debug("Webhook request %s", await request.json()) app: QBotApp = request.app @@ -29,14 +31,12 @@ async def telegram_webhook( except Exception: logger.error("Invalid request", exc_info=True) return Response(status_code=400) - try: - await app.dp.feed_webhook_update( - app.bot, - update, - db_session=db_session, - app=app, - app_state=request.state, - ) - except Exception: - logger.error("Error processing update", exc_info=True) + background_tasks.add_task( + app.dp.feed_webhook_update, + bot=app.bot, + update=update, + db_session=db_session, + app=app, + app_state=request.state, + ) return Response(status_code=200) diff --git a/src/quickbot/bot/handlers/editors/entity.py b/src/quickbot/bot/handlers/editors/entity.py index e970fbc..ba50db1 100644 --- a/src/quickbot/bot/handlers/editors/entity.py +++ b/src/quickbot/bot/handlers/editors/entity.py @@ -95,13 +95,39 @@ async def render_entity_picker( page_size = await Settings.get(Settings.PAGE_SIZE) form_list = None + context = BotContext( + db_session=db_session, + app=kwargs["app"], + app_state=kwargs["app_state"], + user=user, + message=message, + ) + if issubclass(type_, BotEnum): items_count = len(type_.all_members) total_pages = calc_total_pages(items_count, page_size) page = min(page, total_pages) - enum_items = list(type_.all_members.values())[ - page_size * (page - 1) : page_size * page - ] + if isinstance(field_descriptor.options, list): + enum_items = field_descriptor.options[ + page_size * (page - 1) : page_size * page + ] + elif callable(field_descriptor.options): + entity = None + if callback_data.entity_id: + entity = await field_descriptor.entity_descriptor.type_.get( + session=db_session, id=callback_data.entity_id + ) + if iscoroutinefunction(field_descriptor.options): + enum_items = (await field_descriptor.options(entity, context)) or [] + else: + enum_items = field_descriptor.options(entity, context) or [] + enum_items = enum_items[ + page_size * (page - 1) : page_size * page + ] + else: + enum_items = list(type_.all_members.values())[ + page_size * (page - 1) : page_size * page + ] items = [ { "text": f"{'' if not is_list else '【✔︎】 ' if item in (current_value or []) else '【 】 '}{item.localized(user.lang)}", @@ -115,14 +141,6 @@ async def render_entity_picker( if field_descriptor.ep_form: if callable(field_descriptor.ep_form): - context = BotContext( - db_session=db_session, - app=kwargs["app"], - app_state=kwargs["app_state"], - user=user, - message=message, - ) - if iscoroutinefunction(field_descriptor.ep_form): ep_form = await field_descriptor.ep_form(context) else: @@ -207,14 +225,6 @@ async def render_entity_picker( page = 1 entity_items = list[BotEntity]() - context = BotContext( - db_session=db_session, - app=kwargs["app"], - app_state=kwargs["app_state"], - user=user, - message=message, - ) - items = [ { "text": f"{ @@ -306,14 +316,6 @@ async def render_entity_picker( state_data = kwargs["state_data"] - context = BotContext( - db_session=db_session, - app=kwargs["app"], - app_state=kwargs["app_state"], - user=user, - message=message, - ) - await wrap_editor( keyboard_builder=keyboard_builder, field_descriptor=field_descriptor, diff --git a/src/quickbot/bot/handlers/editors/wrapper.py b/src/quickbot/bot/handlers/editors/wrapper.py index 70ea479..dc7631c 100644 --- a/src/quickbot/bot/handlers/editors/wrapper.py +++ b/src/quickbot/bot/handlers/editors/wrapper.py @@ -76,7 +76,7 @@ async def wrap_editor( ) ) - if field_descriptor.is_optional: + if field_descriptor.is_optional and field_descriptor.show_skip_in_editor == "Auto": btns.append( InlineKeyboardButton( text=(await Settings.get(Settings.APP_STRINGS_SKIP_BTN)), diff --git a/src/quickbot/bot/handlers/forms/entity_form.py b/src/quickbot/bot/handlers/forms/entity_form.py index 7576174..81dbb20 100644 --- a/src/quickbot/bot/handlers/forms/entity_form.py +++ b/src/quickbot/bot/handlers/forms/entity_form.py @@ -14,6 +14,7 @@ from ....model.descriptors import ( InlineButton, BotContext, ) +from ....model.bot_entity import BotEntity from ....model.settings import Settings from ....model.user import UserBase from ....model import EntityPermission @@ -103,8 +104,15 @@ async def entity_item( app_state=kwargs["app_state"], user=user, message=query, + default_handler=item_repr ) + if form.before_open: + if iscoroutinefunction(form.before_open): + await form.before_open(entity_item, context) + else: + form.before_open(entity_item, context) + if form.form_buttons: for edit_buttons_row in form.form_buttons: btn_row = [] @@ -113,7 +121,14 @@ async def entity_item( continue if isinstance(button, FieldEditButton) and can_edit: - field_name = button.field_name + if isinstance(button.field, str): + field_name = button.field + else: + field_name = button.field(entity_descriptor.type_).key + for fd in entity_descriptor.fields_descriptors.values(): + if fd.field_name == field_name: + field_name = fd.name + break btn_caption = button.caption if field_name in entity_descriptor.fields_descriptors: field_descriptor = entity_descriptor.fields_descriptors[ @@ -261,90 +276,7 @@ async def entity_item( entity=entity_item, ) else: - entity_caption = ( - await get_callable_str( - callable_str=entity_descriptor.full_name, - context=context, - descriptor=entity_descriptor, - ) - if entity_descriptor.full_name - else entity_descriptor.name - ) - - entity_item_repr = ( - await get_callable_str( - callable_str=entity_descriptor.item_repr, - context=context, - entity=entity_item, - ) - if entity_descriptor.item_repr - else str(entity_item.id) - ) - - item_text = f"{entity_caption or entity_descriptor.name}: {entity_item_repr}" - - user_permissions = get_user_permissions(user, entity_descriptor) - - for field_descriptor in entity_descriptor.fields_descriptors.values(): - if ( - isinstance(field_descriptor.is_visible, bool) - and not field_descriptor.is_visible - ): - continue - - if callable(field_descriptor.is_visible): - if iscoroutinefunction(field_descriptor.is_visible): - field_visible = await field_descriptor.is_visible( - field_descriptor, entity_item, context - ) - else: - field_visible = field_descriptor.is_visible( - field_descriptor, entity_item, context - ) - if not field_visible: - continue - - skip = False - - for own_field in entity_descriptor.ownership_fields.items(): - if ( - own_field[1].rstrip("_id") - == field_descriptor.field_name.rstrip("_id") - and own_field[0] in user.roles - and EntityPermission.READ_ALL not in user_permissions - ): - skip = True - break - - if skip: - continue - - if field_descriptor.caption_value: - item_text += f"\n{ - await get_callable_str( - callable_str=field_descriptor.caption_value, - context=context, - descriptor=field_descriptor, - entity=entity_item, - ) - }" - else: - field_caption = ( - await get_callable_str( - callable_str=field_descriptor.caption, - context=context, - descriptor=field_descriptor, - ) - if field_descriptor.caption - else field_descriptor.field_name - ) - value = await get_value_repr( - value=getattr(entity_item, field_descriptor.field_name), - field_descriptor=field_descriptor, - context=context, - locale=user.lang, - ) - item_text += f"\n{field_caption or field_descriptor.name}:{f' {value}' if value else ''}" + item_text = await item_repr(entity_item=entity_item, context=context) context = pop_navigation_context(navigation_stack) if context: @@ -368,3 +300,93 @@ async def entity_item( text=item_text, reply_markup=keyboard_builder.as_markup(), ) + + +async def item_repr(entity_item: BotEntity, context: BotContext[UserBase]): + entity_descriptor = entity_item.bot_entity_descriptor + user = context.user + entity_caption = ( + await get_callable_str( + callable_str=entity_descriptor.full_name, + context=context, + descriptor=entity_descriptor, + ) + if entity_descriptor.full_name + else entity_descriptor.name + ) + + entity_item_repr = ( + await get_callable_str( + callable_str=entity_descriptor.item_repr, + context=context, + entity=entity_item, + ) + if entity_descriptor.item_repr + else str(entity_item.id) + ) + + item_text = f"{entity_caption or entity_descriptor.name}: {entity_item_repr}" + + user_permissions = get_user_permissions(user, entity_descriptor) + + for field_descriptor in entity_descriptor.fields_descriptors.values(): + if ( + isinstance(field_descriptor.is_visible, bool) + and not field_descriptor.is_visible + ): + continue + + if callable(field_descriptor.is_visible): + if iscoroutinefunction(field_descriptor.is_visible): + field_visible = await field_descriptor.is_visible( + field_descriptor, entity_item, context + ) + else: + field_visible = field_descriptor.is_visible( + field_descriptor, entity_item, context + ) + if not field_visible: + continue + + skip = False + + for own_field in entity_descriptor.ownership_fields.items(): + if ( + own_field[1].rstrip("_id") + == field_descriptor.field_name.rstrip("_id") + and own_field[0] in user.roles + and EntityPermission.READ_ALL not in user_permissions + ): + skip = True + break + + if skip: + continue + + if field_descriptor.caption_value: + item_text += f"\n{ + await get_callable_str( + callable_str=field_descriptor.caption_value, + context=context, + descriptor=field_descriptor, + entity=entity_item, + ) + }" + else: + field_caption = ( + await get_callable_str( + callable_str=field_descriptor.caption, + context=context, + descriptor=field_descriptor, + ) + if field_descriptor.caption + else field_descriptor.field_name + ) + value = await get_value_repr( + value=getattr(entity_item, field_descriptor.field_name), + field_descriptor=field_descriptor, + context=context, + locale=user.lang, + ) + item_text += f"\n{field_caption or field_descriptor.name}:{f' {value}' if value else ''}" + return item_text diff --git a/src/quickbot/bot/handlers/start.py b/src/quickbot/bot/handlers/start.py index 88bec2a..876dfcf 100644 --- a/src/quickbot/bot/handlers/start.py +++ b/src/quickbot/bot/handlers/start.py @@ -19,12 +19,18 @@ router = Router() @router.message(CommandStart()) async def start(message: Message, **kwargs): app: QBotApp = kwargs["app"] + state: FSMContext = kwargs["state"] + + state_data = await state.get_data() + clear_state(state_data=state_data, clear_nav=True) if app.start_handler: await app.start_handler(default_start_handler, message, **kwargs) else: await default_start_handler(message, **kwargs) + await state.set_data(state_data) + async def default_start_handler[UserType: UserBase]( message: Message, @@ -33,8 +39,6 @@ async def default_start_handler[UserType: UserBase]( state: FSMContext, **kwargs, ) -> tuple[UserType, bool]: - state_data = await state.get_data() - clear_state(state_data=state_data, clear_nav=True) User = app.user_class diff --git a/src/quickbot/bot/handlers/user_handlers/command_handler.py b/src/quickbot/bot/handlers/user_handlers/command_handler.py index bca1097..d151af1 100644 --- a/src/quickbot/bot/handlers/user_handlers/command_handler.py +++ b/src/quickbot/bot/handlers/user_handlers/command_handler.py @@ -94,16 +94,13 @@ async def command_handler(message: Message | CallbackQuery, cmd: BotCommand, **k if message: send_message = get_send_message(message) - if isinstance(message, CallbackQuery): - message = message.message - if callback_context.message_text: await send_message( text=callback_context.message_text, reply_markup=callback_context.keyboard_builder.as_markup(), ) - else: - await message.edit_reply_markup( + elif isinstance(message, CallbackQuery): + await message.message.edit_reply_markup( reply_markup=callback_context.keyboard_builder.as_markup() ) else: diff --git a/src/quickbot/model/bot_entity.py b/src/quickbot/model/bot_entity.py index b57c4df..0307e3f 100644 --- a/src/quickbot/model/bot_entity.py +++ b/src/quickbot/model/bot_entity.py @@ -93,6 +93,8 @@ class BotEntityMetaclass(SQLModelMetaclass): attribute_value.default_factory ) + descriptor_kwargs.pop("__orig_class__", None) + if sm_descriptor: namespace[annotation] = sm_descriptor else: @@ -167,6 +169,7 @@ class BotEntityMetaclass(SQLModelMetaclass): entity_descriptor = namespace.pop("bot_entity_descriptor") descriptor_kwargs: dict = entity_descriptor.__dict__.copy() descriptor_name = descriptor_kwargs.pop("name", None) + descriptor_kwargs.pop("__orig_class__", None) descriptor_name = descriptor_name or name.lower() namespace["bot_entity_descriptor"] = EntityDescriptor( name=descriptor_name, diff --git a/src/quickbot/model/bot_enum.py b/src/quickbot/model/bot_enum.py index 9575340..80e5d4f 100644 --- a/src/quickbot/model/bot_enum.py +++ b/src/quickbot/model/bot_enum.py @@ -93,9 +93,10 @@ class EnumMember(object): if args.__len__() == 0: return list(cls.all_members.values())[0] if args.__len__() == 1 and isinstance(args[0], str): - return {member.value: member for key, member in cls.all_members.items()}[ - args[0] - ] + for key, member in cls.all_members.items(): + if member.value == args[0]: + return member + return None elif args.__len__() == 1: return {member.value: member for key, member in cls.all_members.items()}[ args[0].value diff --git a/src/quickbot/model/descriptors.py b/src/quickbot/model/descriptors.py index 0788962..5ac6158 100644 --- a/src/quickbot/model/descriptors.py +++ b/src/quickbot/model/descriptors.py @@ -7,6 +7,7 @@ from babel.support import LazyProxy from dataclasses import dataclass, field from fastapi.datastructures import State from sqlmodel.ext.asyncio.session import AsyncSession +from sqlalchemy.orm import InstrumentedAttribute from .role import RoleBase from . import EntityPermission @@ -23,26 +24,25 @@ if TYPE_CHECKING: @dataclass -class FieldEditButton: - field_name: str - caption: str | LazyProxy | Callable[["BotEntity", "BotContext"], str] | None = None - visibility: Callable[["BotEntity", "BotContext"], bool] | None = None +class FieldEditButton[T: "BotEntity"]: + field: str | Callable[[type[T]], InstrumentedAttribute] + caption: str | LazyProxy | Callable[[T, "BotContext"], str] | None = None + visibility: Callable[[T, "BotContext"], bool] | None = None @dataclass -class CommandButton: - command: ContextData | Callable[["BotEntity", "BotContext"], ContextData] | str - caption: str | LazyProxy | Callable[["BotEntity", "BotContext"], str] | None = None - visibility: Callable[["BotEntity", "BotContext"], bool] | None = None +class CommandButton[T: "BotEntity"]: + command: ContextData | Callable[[T, "BotContext"], ContextData] | str + caption: str | LazyProxy | Callable[[T, "BotContext"], str] | None = None + visibility: Callable[[T, "BotContext"], bool] | None = None @dataclass -class InlineButton: +class InlineButton[T: "BotEntity"]: inline_button: ( - InlineKeyboardButton - | Callable[["BotEntity", "BotContext"], InlineKeyboardButton] + InlineKeyboardButton | Callable[[T, "BotContext"], InlineKeyboardButton] ) - visibility: Callable[["BotEntity", "BotContext"], bool] | None = None + visibility: Callable[[T, "BotContext"], bool] | None = None @dataclass @@ -69,11 +69,11 @@ class Filter: @dataclass -class EntityList: +class EntityList[T: "BotEntity"]: caption: ( str | LazyProxy | Callable[["EntityDescriptor", "BotContext"], str] | None ) = None - item_repr: Callable[["BotEntity", "BotContext"], str] | None = None + item_repr: Callable[[T, "BotContext"], str] | None = None show_add_new_button: bool = True item_form: str | None = None pagination: bool = True @@ -84,16 +84,17 @@ class EntityList: @dataclass -class EntityForm: - item_repr: Callable[["BotEntity", "BotContext"], str] | None = None +class EntityForm[T: "BotEntity"]: + item_repr: Callable[[T, "BotContext"], str] | None = None edit_field_sequence: list[str] = None form_buttons: list[list[FieldEditButton | CommandButton | InlineButton]] = None show_edit_button: bool = True show_delete_button: bool = True + before_open: Callable[[T, "BotContext"], None] | None = None @dataclass(kw_only=True) -class _BaseFieldDescriptor: +class _BaseFieldDescriptor[T: "BotEntity"]: icon: str = None caption: ( str | LazyProxy | Callable[["FieldDescriptor", "BotContext"], str] | None @@ -104,19 +105,17 @@ class _BaseFieldDescriptor: edit_prompt: ( str | LazyProxy - | Callable[["FieldDescriptor", Union["BotEntity", Any], "BotContext"], str] + | Callable[["FieldDescriptor", Union[T, Any], "BotContext"], str] | None ) = None caption_value: ( - Callable[["FieldDescriptor", Union["BotEntity", Any], "BotContext"], str] | None - ) = None - is_visible: ( - bool | Callable[["FieldDescriptor", "BotEntity", "BotContext"], bool] | None + Callable[["FieldDescriptor", Union[T, Any], "BotContext"], str] | None ) = None + is_visible: bool | Callable[["FieldDescriptor", T, "BotContext"], bool] | None = ( + None + ) is_visible_in_edit_form: ( - bool - | Callable[["FieldDescriptor", Union["BotEntity", Any], "BotContext"], bool] - | None + bool | Callable[["FieldDescriptor", Union[T, Any], "BotContext"], bool] | None ) = None validator: Callable[[Any, "BotContext"], Union[bool, str]] | None = None localizable: bool = False @@ -126,12 +125,14 @@ class _BaseFieldDescriptor: ep_parent_field: str | None = None ep_child_field: str | None = None dt_type: Literal["date", "datetime"] = "date" + options: list[Any] | Callable[[T, "BotContext"], list[Any]] | None = None + show_skip_in_editor: Literal[False, "Auto"] = "Auto" default: Any = None default_factory: Callable[[], Any] | None = None @dataclass(kw_only=True) -class EntityField(_BaseFieldDescriptor): +class EntityField[T: "BotEntity"](_BaseFieldDescriptor[T]): name: str | None = None sm_descriptor: Any = None @@ -142,7 +143,7 @@ class Setting(_BaseFieldDescriptor): @dataclass(kw_only=True) -class FormField(_BaseFieldDescriptor): +class FormField[T: "BotEntity"](_BaseFieldDescriptor[T]): name: str | None = None type_: type @@ -163,7 +164,7 @@ class FieldDescriptor(_BaseFieldDescriptor): @dataclass(kw_only=True) -class _BaseEntityDescriptor: +class _BaseEntityDescriptor[T: "BotEntity"]: icon: str = "📘" full_name: ( str | LazyProxy | Callable[["EntityDescriptor", "BotContext"], str] | None @@ -174,7 +175,7 @@ class _BaseEntityDescriptor: description: ( str | LazyProxy | Callable[["EntityDescriptor", "BotContext"], str] | None ) = None - item_repr: Callable[["BotEntity", "BotContext"], str] | None = None + item_repr: Callable[[T, "BotContext"], str] | None = None default_list: EntityList = field(default_factory=EntityList) default_form: EntityForm = field(default_factory=EntityForm) lists: dict[str, EntityList] = field(default_factory=dict[str, EntityList]) @@ -196,23 +197,19 @@ class _BaseEntityDescriptor: } ) before_create: Callable[["BotContext"], Union[bool, str]] | None = None - before_create_save: ( - Callable[["BotEntity", "BotContext"], Union[bool, str]] | None - ) = None + before_create_save: Callable[[T, "BotContext"], Union[bool, str]] | None = None before_update_save: ( Callable[[dict[str, Any], dict[str, Any], "BotContext"], Union[bool, str]] | None ) = None - before_delete: Callable[["BotEntity", "BotContext"], Union[bool, str]] | None = None - on_created: Callable[["BotEntity", "BotContext"], None] | None = None - on_deleted: Callable[["BotEntity", "BotContext"], None] | None = None - on_updated: Callable[[dict[str, Any], "BotEntity", "BotContext"], None] | None = ( - None - ) + before_delete: Callable[[T, "BotContext"], Union[bool, str]] | None = None + on_created: Callable[[T, "BotContext"], None] | None = None + on_deleted: Callable[[T, "BotContext"], None] | None = None + on_updated: Callable[[dict[str, Any], T, "BotContext"], None] | None = None @dataclass(kw_only=True) -class Entity(_BaseEntityDescriptor): +class Entity[T: "BotEntity"](_BaseEntityDescriptor[T]): name: str | None = None @@ -252,6 +249,7 @@ class BotContext[UT: UserBase]: app_state: State user: UT message: Message | CallbackQuery | None = None + default_handler: Callable[["BotEntity", "BotContext"], None] | None = None @dataclass(kw_only=True)