From 9dd0708a5b2fa39d5a0373ad51372cb54c54de28 Mon Sep 17 00:00:00 2001 From: Alexander Kalinovsky Date: Tue, 21 Jan 2025 23:50:19 +0100 Subject: [PATCH] add ruff format, ruff check, time_picker, project structure and imports reorganized --- __init__.py | 14 +- api_route/telegram.py | 30 +- auth/__init__.py | 14 + bot/command_context_filter.py | 7 +- bot/handlers/common/__init__.py | 345 -------------- bot/handlers/common/filtering.py | 30 ++ bot/handlers/common/filtering_callbacks.py | 168 +++++++ bot/handlers/common/pagination.py | 114 +++++ bot/handlers/common/routing.py | 48 ++ bot/handlers/context.py | 11 +- bot/handlers/editors/__init__.py | 416 ----------------- bot/handlers/editors/boolean.py | 80 ++-- bot/handlers/editors/common.py | 126 ++--- bot/handlers/editors/date.py | 488 ++++++++++++++------ bot/handlers/editors/entity.py | 403 ++++++++++------ bot/handlers/editors/main.py | 156 +++++++ bot/handlers/editors/main_callbacks.py | 284 ++++++++++++ bot/handlers/editors/string.py | 129 +++--- bot/handlers/editors/wrapper.py | 93 ++++ bot/handlers/forms/entity_form.py | 344 +++++++------- bot/handlers/forms/entity_form_callbacks.py | 96 ++++ bot/handlers/forms/entity_list.py | 312 ++++++++----- bot/handlers/menu/entities.py | 67 +-- bot/handlers/menu/language.py | 72 +-- bot/handlers/menu/main.py | 121 +++-- bot/handlers/menu/parameters.py | 96 ++-- bot/handlers/menu/settings.py | 72 +-- bot/handlers/navigation.py | 89 +--- bot/handlers/start.py | 64 ++- bot/handlers/user_handlers/__init__.py | 109 ++--- config/__init__.py | 27 +- db/__init__.py | 15 +- fsm/db_storage.py | 62 +-- lifespan.py | 61 ++- main.py | 109 +++-- middleware/telegram/__init__.py | 5 +- middleware/telegram/auth.py | 37 +- middleware/telegram/i18n.py | 8 +- middleware/telegram/reset_state.py | 46 +- model/__init__.py | 37 +- model/_singleton.py | 3 +- model/bot_entity.py | 389 +++++++++++----- model/bot_enum.py | 121 ++--- model/default_user.py | 2 +- model/descriptors.py | 167 +++++-- model/entity_metadata.py | 3 +- model/field_types.py | 45 -- model/fsm_storage.py | 7 +- model/language.py | 3 +- model/menu.py | 26 -- model/owned_bot_entity.py | 49 -- model/role.py | 3 +- model/settings.py | 290 +++++++----- model/user.py | 11 +- model/view_setting.py | 50 +- router.py | 32 ++ utils/main.py | 199 ++++++++ utils/{__init__.py => serialization.py} | 68 ++- 58 files changed, 3690 insertions(+), 2583 deletions(-) create mode 100644 auth/__init__.py delete mode 100644 bot/handlers/common/__init__.py create mode 100644 bot/handlers/common/filtering.py create mode 100644 bot/handlers/common/filtering_callbacks.py create mode 100644 bot/handlers/common/pagination.py create mode 100644 bot/handlers/common/routing.py delete mode 100644 bot/handlers/editors/__init__.py create mode 100644 bot/handlers/editors/main.py create mode 100644 bot/handlers/editors/main_callbacks.py create mode 100644 bot/handlers/editors/wrapper.py create mode 100644 bot/handlers/forms/entity_form_callbacks.py delete mode 100644 model/field_types.py delete mode 100644 model/menu.py delete mode 100644 model/owned_bot_entity.py create mode 100644 router.py create mode 100644 utils/main.py rename utils/{__init__.py => serialization.py} (63%) diff --git a/__init__.py b/__init__.py index f885b72..bc69867 100644 --- a/__init__.py +++ b/__init__.py @@ -1 +1,13 @@ -from .main import QBotApp as QBotApp, Config as Config \ No newline at end of file +from .main import QBotApp as QBotApp, Config as Config +from .router import Router as Router +from .model.bot_entity import BotEntity as BotEntity +from .model.bot_enum import BotEnum as BotEnum, EnumMember as EnumMember +from .model.descriptors import ( + Entity as Entity, + EntityField as EntityField, + EntityForm as EntityForm, + EntityList as EntityList, + EntityPermission as EntityPermission, + Command as Command, + CommandCallbackContext as CommandCallbackContext, +) diff --git a/api_route/telegram.py b/api_route/telegram.py index 1fd0d42..80f5884 100644 --- a/api_route/telegram.py +++ b/api_route/telegram.py @@ -1,8 +1,9 @@ from typing import Annotated from fastapi import APIRouter, Depends, Request, Response from sqlmodel.ext.asyncio.session import AsyncSession - from ..main import QBotApp + + from ..db import get_db from aiogram.types import Update from logging import getLogger @@ -14,24 +15,25 @@ router = APIRouter() @router.post("/webhook") -async def telegram_webhook(db_session: Annotated[AsyncSession, Depends(get_db)], - request: Request): - +async def telegram_webhook( + db_session: Annotated[AsyncSession, Depends(get_db)], request: Request +): logger.debug("Webhook request %s", await request.json()) - app = request.app #type: QBotApp + app: QBotApp = request.app request_token = request.headers.get("X-Telegram-Bot-Api-Secret-Token") if request_token != app.bot_auth_token: logger.warning("Unauthorized request %s", request) - return Response(status_code = 403) + return Response(status_code=403) try: update = Update(**await request.json()) - except: - logger.error("Invalid request", exc_info = True) - return Response(status_code = 400) + 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) - except: - logger.error("Error processing update", exc_info = True) - return Response(status_code = 200) - \ No newline at end of file + await app.dp.feed_webhook_update( + app.bot, update, db_session=db_session, app=app + ) + except Exception: + logger.error("Error processing update", exc_info=True) + return Response(status_code=200) diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 0000000..2691a00 --- /dev/null +++ b/auth/__init__.py @@ -0,0 +1,14 @@ +from ..model.settings import Settings +from ..model.user import UserBase +from ..bot.handlers.context import ContextData, CallbackCommand, CommandContext + + +async def authorize_command(user: UserBase, callback_data: ContextData): + if ( + callback_data.command == CallbackCommand.MENU_ENTRY_PARAMETERS + or callback_data.context == CommandContext.SETTING_EDIT + ): + allowed_roles = await Settings.get(Settings.SECURITY_PARAMETERS_ROLES) + return any(role in user.roles for role in allowed_roles) + + return False diff --git a/bot/command_context_filter.py b/bot/command_context_filter.py index c08dd53..277b796 100644 --- a/bot/command_context_filter.py +++ b/bot/command_context_filter.py @@ -8,7 +8,6 @@ logger = getLogger(__name__) class CallbackCommandFilter(Filter): - def __init__(self, command: CallbackCommand): self.command = command @@ -19,11 +18,9 @@ class CallbackCommandFilter(Filter): if context_data: try: context_data = ContextData.unpack(context_data) - except Exception as e: - logger.error(f"Error unpacking context data", exc_info = True) + except Exception: + logger.error("Error unpacking context data", exc_info=True) return False else: return context_data.command == self.command return False - - \ No newline at end of file diff --git a/bot/handlers/common/__init__.py b/bot/handlers/common/__init__.py deleted file mode 100644 index 65f3fa1..0000000 --- a/bot/handlers/common/__init__.py +++ /dev/null @@ -1,345 +0,0 @@ -from types import NoneType, UnionType -from aiogram import Router, F -from aiogram.fsm.context import FSMContext -from aiogram.types import Message, CallbackQuery, InlineKeyboardButton -from aiogram.utils.keyboard import InlineKeyboardBuilder -from babel.support import LazyProxy -from inspect import signature -from sqlmodel.ext.asyncio.session import AsyncSession -from typing import Any, get_args, get_origin, TYPE_CHECKING -import ujson as json - - -from ..context import ContextData, CallbackCommand, CommandContext -from ...command_context_filter import CallbackCommandFilter -from ....model.user import UserBase -from ....model.settings import Settings -from ....model.bot_entity import BotEntity -from ....model.bot_enum import BotEnum -from ....model.view_setting import ViewSetting -from ....utils import get_local_text, deserialize -from ....model.descriptors import (EntityFieldDescriptor, - EntityDescriptor, - EntityCaptionCallable, - EntityItemCaptionCallable, - EntityFieldCaptionCallable) - -if TYPE_CHECKING: - from ....main import QBotApp - - -router = Router() - - -def get_send_message(message: Message | CallbackQuery): - if isinstance(message, Message): - return message.answer - else: - return message.message.edit_text - - -# def get_local_text(text: str, lang: str): -# try: -# text_obj = json.loads(text) #@IgnoreException -# return text_obj.get(lang, text_obj[list(text_obj.keys())[0]]) -# except: -# return text - - -def get_value_repr(value: Any, field_descriptor: EntityFieldDescriptor, locale: str | None = None) -> str: - - type_ = field_descriptor.type_base - if value is None: - return "" - - if isinstance(value, bool): - return "【✔︎】" if value else "【 】" - elif field_descriptor.is_list: - if issubclass(type_, BotEntity): - if locale and type_.bot_entity_descriptor.fields_descriptors["name"].localizable: - return "[" + ", ".join([get_local_text(text = item.name, locale = locale) for item in value]) + "]" - else: - return "[" + ", ".join([str(item.name) for item in value]) + "]" - elif issubclass(type_, BotEnum): - return "[" + ", ".join(item.localized(locale) for item in value) + "]" - elif type_ == str: - return "[" + ", ".join([f"\"{item}\"" for item in value]) + "]" - else: - return "[" + ", ".join([str(item) for item in value]) + "]" - elif issubclass(type_, BotEntity): - if type_.bot_entity_descriptor.fields_descriptors["name"].localizable: - return get_local_text(text = value.name, locale = locale) - return value.name - elif issubclass(type_, BotEnum): - return value.localized(locale) - elif isinstance(value, str): - if field_descriptor and field_descriptor.localizable: - return get_local_text(text = value, locale = locale) - return value - elif isinstance(value, int): - return str(value) - elif isinstance(value, float): - return str(value) - else: - return str(value) - - -def get_callable_str(callable_str: str | LazyProxy | EntityCaptionCallable | EntityItemCaptionCallable | EntityFieldCaptionCallable, - descriptor: EntityFieldDescriptor | EntityDescriptor, - entity: Any = None, - value: Any = None) -> str: - - if isinstance(callable_str, str): - return callable_str - elif isinstance(callable_str, LazyProxy): - return callable_str.value - elif callable(callable_str): - args = signature(callable_str).parameters - if len(args) == 1: - return callable_str(descriptor) - elif len(args) == 2: - return callable_str(descriptor, entity) - elif len(args) == 3: - return callable_str(descriptor, entity, value) - - -async def authorize_command(user: UserBase, - callback_data: ContextData): - - if (callback_data.command == CallbackCommand.MENU_ENTRY_PARAMETERS or - callback_data.context == CommandContext.SETTING_EDIT): - allowed_roles = (await Settings.get(Settings.SECURITY_PARAMETERS_ROLES)) - return any(role in user.roles for role in allowed_roles) - - return False - - -def get_entity_descriptor(app: "QBotApp", callback_data: ContextData) -> EntityDescriptor | None: - - if callback_data.entity_name: - return app.entity_metadata.entity_descriptors[callback_data.entity_name] - return None - - -def get_field_descriptor(app: "QBotApp", callback_data: ContextData) -> EntityFieldDescriptor | None: - - if callback_data.context == CommandContext.SETTING_EDIT: - return Settings.list_params()[callback_data.field_name] - elif callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT, CommandContext.ENTITY_FIELD_EDIT]: - entity_descriptor = get_entity_descriptor(app, callback_data) - if entity_descriptor: - return entity_descriptor.fields_descriptors.get(callback_data.field_name) - return None - - -def add_pagination_controls(keyboard_builder: InlineKeyboardBuilder, - callback_data: ContextData, - total_pages: int, - command: CallbackCommand, - page: int): - - if total_pages > 1: - navigation_buttons = [] - ContextData(**callback_data.model_dump()).__setattr__ - if total_pages > 10: - navigation_buttons.append(InlineKeyboardButton(text = "⏮️", - callback_data = ContextData( - command = command, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = "1" if page != 1 else "skip", - save_state = True).pack())) - navigation_buttons.append(InlineKeyboardButton(text = "⏪️", - callback_data = ContextData( - command = command, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = str(max(page - 10, 1)) if page > 1 else "skip", - save_state = True).pack())) - - navigation_buttons.append(InlineKeyboardButton(text = f"◀️", - callback_data = ContextData( - command = command, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = str(max(page - 1, 1)) if page > 1 else "skip", - save_state = True).pack())) - navigation_buttons.append(InlineKeyboardButton(text = f"▶️", - callback_data = ContextData( - command = command, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = str(min(page + 1, total_pages)) if page < total_pages else "skip", - save_state = True).pack())) - - if total_pages > 10: - navigation_buttons.append(InlineKeyboardButton(text = "⏩️", - callback_data = ContextData( - command = command, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = str(min(page + 10, total_pages)) if page < total_pages else "skip", - save_state = True).pack())) - navigation_buttons.append(InlineKeyboardButton(text = "⏭️", - callback_data = ContextData( - command = command, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = str(total_pages) if page != total_pages else "skip", - save_state = True).pack())) - - keyboard_builder.row(*navigation_buttons) - - -def add_filter_controls(keyboard_builder: InlineKeyboardBuilder, - entity_descriptor: EntityDescriptor, - filter: str = None, - page: int = 1): - - field_name_descriptor = entity_descriptor.fields_descriptors["name"] - if field_name_descriptor.caption: - caption = get_callable_str(field_name_descriptor.caption, field_name_descriptor) - else: - caption = field_name_descriptor.name - - keyboard_builder.row( - InlineKeyboardButton( - text = f"🔎 {caption}{f": \"{filter}\"" if filter else ""}", - callback_data = ContextData( - command = CallbackCommand.VIEW_FILTER_EDIT, - entity_name = entity_descriptor.name, - data = str(page)).pack())) - - -@router.callback_query(ContextData.filter(F.command == CallbackCommand.VIEW_FILTER_EDIT)) -async def view_filter_edit(query: CallbackQuery, **kwargs): - - callback_data: ContextData = kwargs["callback_data"] - state: FSMContext = kwargs["state"] - state_data = await state.get_data() - kwargs["state_data"] = state_data - - args = callback_data.data.split("&") - page = int(args[0]) - cmd = None - if len(args) > 1: - cmd = args[1] - - db_session: AsyncSession = kwargs["db_session"] - app: "QBotApp" = kwargs["app"] - user: UserBase = kwargs["user"] - entity_descriptor = get_entity_descriptor(app = app, callback_data = callback_data) - - if cmd in ["cancel", "clear"]: - - if cmd == "clear": - await ViewSetting.set_filter(session = db_session, user_id = user.id, entity_name = entity_descriptor.class_name, filter = None) - - context_data_bak = state_data.pop("context_data_bak", None) - if context_data_bak: - - state_data["context_data"] = context_data_bak - context_data = ContextData.unpack(context_data_bak) - - field_descriptor = get_field_descriptor(app, context_data) - edit_prompt = state_data["edit_prompt"] - current_value = await deserialize(session = db_session, - type_ = field_descriptor.type_, - value = state_data["value"]) - page = int(state_data.pop("page")) - kwargs.pop("callback_data") - - return await render_entity_picker(field_descriptor = field_descriptor, - message = query, - callback_data = context_data, - current_value = current_value, - edit_prompt = edit_prompt, - page = page, - **kwargs) - - else: - - state_data.pop("context_data", None) - return await route_callback(message = query, back = False, **kwargs) - - - #await save_navigation_context(callback_data = callback_data, state = state) - old_context_data = state_data.get("context_data") - await state.update_data({"context_data": callback_data.pack(), - "context_data_bak": old_context_data, - "page": page}) - - send_message = get_send_message(query) - - await send_message(text = await Settings.get(Settings.APP_STRINGS_VIEW_FILTER_EDIT_PROMPT), - reply_markup = InlineKeyboardBuilder().row( - InlineKeyboardButton( - text = await Settings.get(Settings.APP_STRINGS_CANCEL_BTN), - callback_data = ContextData( - command = CallbackCommand.VIEW_FILTER_EDIT, - entity_name = entity_descriptor.name, - data = f"{page}&cancel").pack()), - InlineKeyboardButton( - text = await Settings.get(Settings.APP_STRINGS_CLEAR_BTN), - callback_data = ContextData( - command = CallbackCommand.VIEW_FILTER_EDIT, - entity_name = entity_descriptor.name, - data = f"{page}&clear").pack())).as_markup()) - - -@router.message(CallbackCommandFilter(command = CallbackCommand.VIEW_FILTER_EDIT)) -async def view_filter_edit_input(message: Message, **kwargs): - - state: FSMContext = kwargs["state"] - state_data = await state.get_data() - kwargs["state_data"] = state_data - callback_data = ContextData.unpack(state_data["context_data"]) - db_session: AsyncSession = kwargs["db_session"] - user: UserBase = kwargs["user"] - app: "QBotApp" = kwargs["app"] - entity_descriptor = get_entity_descriptor(app = app, callback_data = callback_data) - filter = message.text - await ViewSetting.set_filter(session = db_session, user_id = user.id, entity_name = entity_descriptor.class_name, filter = filter) - - #state_data.pop("context_data") - #return await route_callback(message = message, back = False, **kwargs) - - context_data_bak = state_data.pop("context_data_bak", None) - if context_data_bak: - state_data["context_data"] = context_data_bak - context_data = ContextData.unpack(context_data_bak) - field_descriptor = get_field_descriptor(app, context_data) - edit_prompt = state_data["edit_prompt"] - current_value = await deserialize(session = db_session, - type_ = field_descriptor.type_, - value = state_data["value"]) - page = int(state_data.pop("page")) - - return await render_entity_picker(field_descriptor = field_descriptor, - message = message, - callback_data = context_data, - current_value = current_value, - edit_prompt = edit_prompt, - page = page, - **kwargs) - - else: - - state_data.pop("context_data", None) - return await route_callback(message = message, back = False, **kwargs) - - -from ..navigation import route_callback, save_navigation_context, clear_state, get_navigation_context -from ..editors.entity import render_entity_picker \ No newline at end of file diff --git a/bot/handlers/common/filtering.py b/bot/handlers/common/filtering.py new file mode 100644 index 0000000..f19dc5b --- /dev/null +++ b/bot/handlers/common/filtering.py @@ -0,0 +1,30 @@ +from aiogram.types import InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from ....model.descriptors import EntityDescriptor +from ....utils.main import get_callable_str +from ..context import ContextData, CallbackCommand + + +def add_filter_controls( + keyboard_builder: InlineKeyboardBuilder, + entity_descriptor: EntityDescriptor, + filter: str = None, + page: int = 1, +): + field_name_descriptor = entity_descriptor.fields_descriptors["name"] + if field_name_descriptor.caption: + caption = get_callable_str(field_name_descriptor.caption, field_name_descriptor) + else: + caption = field_name_descriptor.name + + keyboard_builder.row( + InlineKeyboardButton( + text=f"🔎 {caption}{f': "{filter}"' if filter else ''}", + callback_data=ContextData( + command=CallbackCommand.VIEW_FILTER_EDIT, + entity_name=entity_descriptor.name, + data=str(page), + ).pack(), + ) + ) diff --git a/bot/handlers/common/filtering_callbacks.py b/bot/handlers/common/filtering_callbacks.py new file mode 100644 index 0000000..3ebfe42 --- /dev/null +++ b/bot/handlers/common/filtering_callbacks.py @@ -0,0 +1,168 @@ +from aiogram import Router, F +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, CallbackQuery, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder +from sqlmodel.ext.asyncio.session import AsyncSession +from typing import TYPE_CHECKING + +from ..context import ContextData, CallbackCommand +from ...command_context_filter import CallbackCommandFilter +from ....model.user import UserBase +from ....model.settings import Settings +from ....model.view_setting import ViewSetting +from ....utils.main import ( + get_send_message, + get_entity_descriptor, + get_field_descriptor, +) +from ....utils.serialization import deserialize +from ..editors.entity import render_entity_picker +from .routing import route_callback + +if TYPE_CHECKING: + from ....main import QBotApp + + +router = Router() + + +@router.callback_query( + ContextData.filter(F.command == CallbackCommand.VIEW_FILTER_EDIT) +) +async def view_filter_edit(query: CallbackQuery, **kwargs): + callback_data: ContextData = kwargs["callback_data"] + state: FSMContext = kwargs["state"] + state_data = await state.get_data() + kwargs["state_data"] = state_data + + args = callback_data.data.split("&") + page = int(args[0]) + cmd = None + if len(args) > 1: + cmd = args[1] + + db_session: AsyncSession = kwargs["db_session"] + app: "QBotApp" = kwargs["app"] + user: UserBase = kwargs["user"] + entity_descriptor = get_entity_descriptor(app=app, callback_data=callback_data) + + if cmd in ["cancel", "clear"]: + if cmd == "clear": + await ViewSetting.set_filter( + session=db_session, + user_id=user.id, + entity_name=entity_descriptor.class_name, + filter=None, + ) + + context_data_bak = state_data.pop("context_data_bak", None) + if context_data_bak: + state_data["context_data"] = context_data_bak + context_data = ContextData.unpack(context_data_bak) + + field_descriptor = get_field_descriptor(app, context_data) + edit_prompt = state_data["edit_prompt"] + current_value = await deserialize( + session=db_session, + type_=field_descriptor.type_, + value=state_data["value"], + ) + page = int(state_data.pop("page")) + kwargs.pop("callback_data") + + return await render_entity_picker( + field_descriptor=field_descriptor, + message=query, + callback_data=context_data, + current_value=current_value, + edit_prompt=edit_prompt, + page=page, + **kwargs, + ) + + else: + state_data.pop("context_data", None) + return await route_callback(message=query, back=False, **kwargs) + + # await save_navigation_context(callback_data = callback_data, state = state) + old_context_data = state_data.get("context_data") + await state.update_data( + { + "context_data": callback_data.pack(), + "context_data_bak": old_context_data, + "page": page, + } + ) + + send_message = get_send_message(query) + + await send_message( + text=await Settings.get(Settings.APP_STRINGS_VIEW_FILTER_EDIT_PROMPT), + reply_markup=InlineKeyboardBuilder() + .row( + InlineKeyboardButton( + text=await Settings.get(Settings.APP_STRINGS_CANCEL_BTN), + callback_data=ContextData( + command=CallbackCommand.VIEW_FILTER_EDIT, + entity_name=entity_descriptor.name, + data=f"{page}&cancel", + ).pack(), + ), + InlineKeyboardButton( + text=await Settings.get(Settings.APP_STRINGS_CLEAR_BTN), + callback_data=ContextData( + command=CallbackCommand.VIEW_FILTER_EDIT, + entity_name=entity_descriptor.name, + data=f"{page}&clear", + ).pack(), + ), + ) + .as_markup(), + ) + + +@router.message(CallbackCommandFilter(command=CallbackCommand.VIEW_FILTER_EDIT)) +async def view_filter_edit_input(message: Message, **kwargs): + state: FSMContext = kwargs["state"] + state_data = await state.get_data() + kwargs["state_data"] = state_data + callback_data = ContextData.unpack(state_data["context_data"]) + db_session: AsyncSession = kwargs["db_session"] + user: UserBase = kwargs["user"] + app: "QBotApp" = kwargs["app"] + entity_descriptor = get_entity_descriptor(app=app, callback_data=callback_data) + filter = message.text + await ViewSetting.set_filter( + session=db_session, + user_id=user.id, + entity_name=entity_descriptor.class_name, + filter=filter, + ) + + # state_data.pop("context_data") + # return await route_callback(message = message, back = False, **kwargs) + + context_data_bak = state_data.pop("context_data_bak", None) + if context_data_bak: + state_data["context_data"] = context_data_bak + context_data = ContextData.unpack(context_data_bak) + field_descriptor = get_field_descriptor(app, context_data) + edit_prompt = state_data["edit_prompt"] + current_value = await deserialize( + session=db_session, type_=field_descriptor.type_, value=state_data["value"] + ) + page = int(state_data.pop("page")) + + return await render_entity_picker( + field_descriptor=field_descriptor, + message=message, + callback_data=context_data, + current_value=current_value, + edit_prompt=edit_prompt, + page=page, + **kwargs, + ) + + else: + state_data.pop("context_data", None) + return await route_callback(message=message, back=False, **kwargs) diff --git a/bot/handlers/common/pagination.py b/bot/handlers/common/pagination.py new file mode 100644 index 0000000..120b970 --- /dev/null +++ b/bot/handlers/common/pagination.py @@ -0,0 +1,114 @@ +from aiogram.types import InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from ..context import ContextData, CallbackCommand + + +def add_pagination_controls( + keyboard_builder: InlineKeyboardBuilder, + callback_data: ContextData, + total_pages: int, + command: CallbackCommand, + page: int, +): + if total_pages > 1: + navigation_buttons = [] + ContextData(**callback_data.model_dump()).__setattr__ + if total_pages > 10: + navigation_buttons.append( + InlineKeyboardButton( + text="⏮️", + callback_data=ContextData( + command=command, + 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, + data="1" if page != 1 else "skip", + ).pack(), + ) + ) + navigation_buttons.append( + InlineKeyboardButton( + text="⏪️", + callback_data=ContextData( + command=command, + 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, + data=str(max(page - 10, 1)) if page > 1 else "skip", + ).pack(), + ) + ) + + navigation_buttons.append( + InlineKeyboardButton( + text="◀️", + callback_data=ContextData( + command=command, + 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, + data=str(max(page - 1, 1)) if page > 1 else "skip", + ).pack(), + ) + ) + navigation_buttons.append( + InlineKeyboardButton( + text="▶️", + callback_data=ContextData( + command=command, + 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, + data=( + str(min(page + 1, total_pages)) + if page < total_pages + else "skip" + ), + ).pack(), + ) + ) + + if total_pages > 10: + navigation_buttons.append( + InlineKeyboardButton( + text="⏩️", + callback_data=ContextData( + command=command, + 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, + data=( + str(min(page + 10, total_pages)) + if page < total_pages + else "skip" + ), + ).pack(), + ) + ) + navigation_buttons.append( + InlineKeyboardButton( + text="⏭️", + callback_data=ContextData( + command=command, + 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, + data=str(total_pages) if page != total_pages else "skip", + ).pack(), + ) + ) + + keyboard_builder.row(*navigation_buttons) diff --git a/bot/handlers/common/routing.py b/bot/handlers/common/routing.py new file mode 100644 index 0000000..804df67 --- /dev/null +++ b/bot/handlers/common/routing.py @@ -0,0 +1,48 @@ +from aiogram.types import Message, CallbackQuery +from ..context import CallbackCommand + +from ..navigation import ( + get_navigation_context, + save_navigation_context, + pop_navigation_context, +) + +import qbot.bot.handlers.menu.main as menu_main +import qbot.bot.handlers.menu.settings as menu_settings +import qbot.bot.handlers.menu.parameters as menu_parameters +import qbot.bot.handlers.menu.language as menu_language +import qbot.bot.handlers.menu.entities as menu_entities +import qbot.bot.handlers.forms.entity_list as form_list +import qbot.bot.handlers.forms.entity_form as form_item +import qbot.bot.handlers.editors.main as editor + + +async def route_callback(message: Message | CallbackQuery, back: bool = True, **kwargs): + state_data = kwargs["state_data"] + stack, context = get_navigation_context(state_data) + if back: + context = pop_navigation_context(stack) + stack = save_navigation_context(callback_data=context, state_data=state_data) + kwargs.update({"callback_data": context, "navigation_stack": stack}) + if context: + if context.command == CallbackCommand.MENU_ENTRY_MAIN: + await menu_main.main_menu(message, **kwargs) + elif context.command == CallbackCommand.MENU_ENTRY_SETTINGS: + await menu_settings.settings_menu(message, **kwargs) + elif context.command == CallbackCommand.MENU_ENTRY_PARAMETERS: + await menu_parameters.parameters_menu(message, **kwargs) + elif context.command == CallbackCommand.MENU_ENTRY_LANGUAGE: + await menu_language.language_menu(message, **kwargs) + elif context.command == CallbackCommand.MENU_ENTRY_ENTITIES: + await menu_entities.entities_menu(message, **kwargs) + elif context.command == CallbackCommand.ENTITY_LIST: + await form_list.entity_list(message, **kwargs) + elif context.command == CallbackCommand.ENTITY_ITEM: + await form_item.entity_item(message, **kwargs) + elif context.command == CallbackCommand.FIELD_EDITOR: + await editor.field_editor(message, **kwargs) + + else: + raise ValueError(f"Unknown command {context.command}") + else: + raise ValueError("No navigation context") diff --git a/bot/handlers/context.py b/bot/handlers/context.py index 1a20275..4c46298 100644 --- a/bot/handlers/context.py +++ b/bot/handlers/context.py @@ -1,8 +1,8 @@ from aiogram.filters.callback_data import CallbackData as BaseCallbackData from enum import StrEnum -class CallbackCommand(StrEnum): +class CallbackCommand(StrEnum): FIELD_EDITOR = "fe" FIELD_EDITOR_CALLBACK = "fc" ENTITY_LIST = "el" @@ -16,25 +16,28 @@ class CallbackCommand(StrEnum): SET_LANGUAGE = "ls" DATE_PICKER_MONTH = "dm" DATE_PICKER_YEAR = "dy" - #STRING_EDITOR_LOCALE = "sl" + TIME_PICKER = "tp" + # STRING_EDITOR_LOCALE = "sl" ENTITY_PICKER_PAGE = "ep" ENTITY_PICKER_TOGGLE_ITEM = "et" VIEW_FILTER_EDIT = "vf" USER_COMMAND = "uc" -class CommandContext(StrEnum): +class CommandContext(StrEnum): SETTING_EDIT = "se" ENTITY_CREATE = "ec" ENTITY_EDIT = "ee" ENTITY_FIELD_EDIT = "ef" -class ContextData(BaseCallbackData, prefix = "cd"): + +class ContextData(BaseCallbackData, prefix="cd"): command: CallbackCommand context: CommandContext | None = None entity_name: str | None = None entity_id: int | None = None field_name: str | None = None + form_params: str | None = None user_command: str | None = None data: str | None = None back: bool = False diff --git a/bot/handlers/editors/__init__.py b/bot/handlers/editors/__init__.py deleted file mode 100644 index 5ee2768..0000000 --- a/bot/handlers/editors/__init__.py +++ /dev/null @@ -1,416 +0,0 @@ -from datetime import datetime -from decimal import Decimal -from types import NoneType, UnionType -from typing import Union, get_args, get_origin, TYPE_CHECKING -from aiogram import Router, F -from aiogram.fsm.context import FSMContext -from aiogram.types import Message, CallbackQuery -from logging import getLogger -from sqlmodel.ext.asyncio.session import AsyncSession -import ujson as json - -from ....model import EntityPermission -from ....model.bot_entity import BotEntity -from ....model.owned_bot_entity import OwnedBotEntity -from ....model.bot_enum import BotEnum -from ....model.language import LanguageBase -from ....model.settings import Settings -from ....model.user import UserBase -from ....model.descriptors import EntityFieldDescriptor -from ....utils import deserialize, get_user_permissions, serialize -from ...command_context_filter import CallbackCommandFilter -from ..context import ContextData, CallbackCommand, CommandContext - -from ..menu.parameters import parameters_menu -from .string import string_editor, router as string_editor_router -from .date import date_picker, router as date_picker_router -from .boolean import bool_editor, router as bool_editor_router -from .entity import entity_picker, router as entity_picker_router - -if TYPE_CHECKING: - from ....main import QBotApp - - -logger = getLogger(__name__) -router = Router() - -router.include_routers( - string_editor_router, - date_picker_router, - bool_editor_router, - entity_picker_router, -) - - -@router.callback_query(ContextData.filter(F.command == CallbackCommand.FIELD_EDITOR)) -async def field_editor(message: Message | CallbackQuery, **kwargs): - - callback_data: ContextData = kwargs.get("callback_data", None) - db_session: AsyncSession = kwargs["db_session"] - user: UserBase = kwargs["user"] - app: "QBotApp" = kwargs["app"] - state: FSMContext = kwargs["state"] - - state_data = await state.get_data() - entity_data = state_data.get("entity_data") - - for key in ["current_value", "value", "locale_index"]: - if key in state_data: - state_data.pop(key) - - kwargs["state_data"] = state_data - - entity_descriptor = None - - if callback_data.context == CommandContext.SETTING_EDIT: - field_descriptor = get_field_descriptor(app, callback_data) - - if field_descriptor.type_ == bool: - if await authorize_command(user = user, callback_data = callback_data): - await Settings.set_param(field_descriptor, not await Settings.get(field_descriptor)) - else: - return await message.answer(text = (await Settings.get(Settings.APP_STRINGS_FORBIDDEN))) - - stack, context = get_navigation_context(state_data = state_data) - - return await parameters_menu(message = message, - navigation_stack = stack, - **kwargs) - - current_value = await Settings.get(field_descriptor, all_locales = True) - else: - field_descriptor = get_field_descriptor(app, callback_data) - entity_descriptor = field_descriptor.entity_descriptor - - current_value = None - user_permissions = get_user_permissions(user, entity_descriptor) - - if field_descriptor.type_base == bool and callback_data.context == CommandContext.ENTITY_FIELD_EDIT: - - entity = await entity_descriptor.type_.get(session = db_session, id = int(callback_data.entity_id)) - - if (EntityPermission.UPDATE_ALL in user_permissions or - (EntityPermission.UPDATE in user_permissions and - not isinstance(entity, OwnedBotEntity)) or - (EntityPermission.UPDATE in user_permissions and - isinstance(entity, OwnedBotEntity) and - entity.user_id == user.id)): - - current_value: bool = getattr(entity, field_descriptor.field_name) or False - setattr(entity, field_descriptor.field_name, not current_value) - - await db_session.commit() - - stack, context = get_navigation_context(state_data = state_data) - - return await entity_item(query = message, navigation_stack = stack, **kwargs) - - - if not entity_data and callback_data.context in [CommandContext.ENTITY_EDIT, CommandContext.ENTITY_FIELD_EDIT]: - - entity = await entity_descriptor.type_.get(session = kwargs["db_session"], id = int(callback_data.entity_id)) - - if (EntityPermission.READ_ALL in user_permissions or - (EntityPermission.READ in user_permissions and - not isinstance(entity, OwnedBotEntity)) or - (EntityPermission.READ in user_permissions and - isinstance(entity, OwnedBotEntity) and - entity.user_id == user.id)): - - - if entity: - entity_data = {key: serialize(getattr(entity, key), entity_descriptor.fields_descriptors[key]) - for key in (entity_descriptor.field_sequence if callback_data.context == CommandContext.ENTITY_EDIT - else [callback_data.field_name])} - state_data.update({"entity_data": entity_data}) - - if entity_data: - current_value = await deserialize(session = db_session, - type_= field_descriptor.type_, - value = entity_data.get(callback_data.field_name)) - - kwargs.update({"field_descriptor": field_descriptor}) - - save_navigation_context(state_data = state_data, callback_data = callback_data) - - await show_editor(message = message, - current_value = current_value, - **kwargs) - - -async def show_editor(message: Message | CallbackQuery, - **kwargs): - - field_descriptor: EntityFieldDescriptor = kwargs["field_descriptor"] - current_value = kwargs["current_value"] - user: UserBase = kwargs["user"] - callback_data: ContextData = kwargs.get("callback_data", None) - state: FSMContext = kwargs["state"] - state_data: dict = kwargs["state_data"] - - value_type = field_descriptor.type_base - - if field_descriptor.edit_prompt: - edit_prompt = get_callable_str(field_descriptor.edit_prompt, field_descriptor, None, current_value) - else: - if field_descriptor.caption: - caption_str = get_callable_str(field_descriptor.caption, field_descriptor, None, current_value) - else: - caption_str = field_descriptor.name - if callback_data.context == CommandContext.ENTITY_EDIT: - edit_prompt = (await Settings.get(Settings.APP_STRINGS_FIELD_EDIT_PROMPT_TEMPLATE_P_NAME_VALUE)).format( - name = caption_str, value = get_value_repr(current_value, field_descriptor, user.lang)) - else: - edit_prompt = (await Settings.get(Settings.APP_STRINGS_FIELD_CREATE_PROMPT_TEMPLATE_P_NAME)).format( - name = caption_str) - - kwargs["edit_prompt"] = edit_prompt - - # type_origin = get_origin(value_type) - - # if type_origin in [UnionType, Union]: - # args = get_args(value_type) - # if args[1] == NoneType: - # value_type = args[0] - - if value_type not in [int, float, Decimal, str]: - state_data.update({"context_data": callback_data.pack()}) - - if value_type == str: - await string_editor(message = message, **kwargs) - - elif value_type == bool: - await bool_editor(message = message, **kwargs) - - elif value_type in [int, float, Decimal, str]: - await string_editor(message = message, **kwargs) - - elif value_type == datetime: - await date_picker(message = message, **kwargs) - - # elif type_origin == list: - # type_args = get_args(value_type) - # if type_args and issubclass(type_args[0], BotEntity) or issubclass(type_args[0], BotEnum): - # await entity_picker(message = message, **kwargs) - # else: - # await string_editor(message = message, **kwargs) - - elif issubclass(value_type, BotEntity) or issubclass(value_type, BotEnum): - await entity_picker(message = message, **kwargs) - - else: - raise ValueError(f"Unsupported field type: {value_type}") - - -@router.message(CallbackCommandFilter(CallbackCommand.FIELD_EDITOR_CALLBACK)) -@router.callback_query(ContextData.filter(F.command == CallbackCommand.FIELD_EDITOR_CALLBACK)) -async def field_editor_callback(message: Message | CallbackQuery, **kwargs): - - app: "QBotApp" = kwargs["app"] - state: FSMContext = kwargs["state"] - - state_data = await state.get_data() - kwargs["state_data"] = state_data - - if isinstance(message, Message): - callback_data: ContextData = kwargs.get("callback_data", None) - context_data = state_data.get("context_data") - if context_data: - callback_data = ContextData.unpack(context_data) - value = message.text - field_descriptor = get_field_descriptor(app, callback_data) - type_base = field_descriptor.type_base - - if type_base == str and field_descriptor.localizable: - locale_index = int(state_data.get("locale_index")) - - if locale_index < len(LanguageBase.all_members.values()) - 1: - - #entity_data = state_data.get("entity_data", {}) - #current_value = entity_data.get(field_descriptor.field_name) - current_value = state_data.get("current_value") - value = state_data.get("value") - if value: - value = json.loads(value) - else: - value = {} - - value[list(LanguageBase.all_members.values())[locale_index]] = message.text - value = json.dumps(value, ensure_ascii = False) - - state_data.update({"value": value}) - - entity_descriptor = get_entity_descriptor(app, callback_data) - - kwargs.update({"callback_data": callback_data}) - - return await show_editor(message = message, - locale_index = locale_index + 1, - field_descriptor = field_descriptor, - entity_descriptor = entity_descriptor, - current_value = current_value, - value = value, - **kwargs) - else: - value = state_data.get("value") - if value: - value = json.loads(value) - else: - value = {} - value[list(LanguageBase.all_members.values())[locale_index]] = message.text - value = json.dumps(value, ensure_ascii = False) - - elif (type_base in [int, float, Decimal]): - try: - _ = type_base(value) #@IgnoreException - except: - return await message.answer(text = (await Settings.get(Settings.APP_STRINGS_INVALID_INPUT))) - else: - callback_data: ContextData = kwargs["callback_data"] - if callback_data.data: - if callback_data.data == "skip": - value = None - else: - value = callback_data.data - else: - value = state_data.get("value") - field_descriptor = get_field_descriptor(app, callback_data) - - kwargs.update({"callback_data": callback_data,}) - - await process_field_edit_callback(message = message, - value = value, - field_descriptor = field_descriptor, - **kwargs) - - -async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs): - - user: UserBase = kwargs["user"] - db_session: AsyncSession = kwargs["db_session"] - callback_data: ContextData = kwargs.get("callback_data", None) - # state: FSMContext = kwargs["state"] - state_data: dict = kwargs["state_data"] - value = kwargs["value"] - field_descriptor: EntityFieldDescriptor = kwargs["field_descriptor"] - - if callback_data.context == CommandContext.SETTING_EDIT: - - # clear_state(state_data = state_data) - - if callback_data.data != "cancel": - if await authorize_command(user = user, callback_data = callback_data): - value = await deserialize(session = db_session, type_ = field_descriptor.type_, value = value) - await Settings.set_param(field_descriptor, value) - else: - return await message.answer(text = (await Settings.get(Settings.APP_STRINGS_FORBIDDEN))) - - # stack, context = get_navigation_context(state_data = state_data) - - return await route_callback(message = message, back = True, **kwargs) - - # return await parameters_menu(message = message, - # navigation_stack = stack, - # **kwargs) - - elif callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT, CommandContext.ENTITY_FIELD_EDIT]: - - app: "QBotApp" = kwargs["app"] - - entity_descriptor = get_entity_descriptor(app, callback_data) - - field_sequence = entity_descriptor.field_sequence - current_index = (field_sequence.index(callback_data.field_name) - if callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT] else 0) - - entity_data = state_data.get("entity_data", {}) - - if (callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT] and - current_index < len(field_sequence) - 1): - - entity_data[field_descriptor.field_name] = value - state_data.update({"entity_data": entity_data}) - - next_field_name = field_sequence[current_index + 1] - next_field_descriptor = entity_descriptor.fields_descriptors[next_field_name] - kwargs.update({"field_descriptor": next_field_descriptor}) - callback_data.field_name = next_field_name - - state_entity_val = entity_data.get(next_field_descriptor.field_name) - - current_value = await deserialize(session = db_session, type_ = next_field_descriptor.type_, - value = state_entity_val) if state_entity_val else None - - await show_editor(message = message, - entity_descriptor = entity_descriptor, - current_value = current_value, - **kwargs) - - else: - - entity_type: BotEntity = entity_descriptor.type_ - user_permissions = get_user_permissions(user, entity_descriptor) - - if ((callback_data.context == CommandContext.ENTITY_CREATE and - EntityPermission.CREATE not in user_permissions and - EntityPermission.CREATE_ALL not in user_permissions) or - (callback_data.context in [CommandContext.ENTITY_EDIT, CommandContext.ENTITY_FIELD_EDIT] and - EntityPermission.UPDATE not in user_permissions and - EntityPermission.UPDATE_ALL not in user_permissions)): - return await message.answer(text = (await Settings.get(Settings.APP_STRINGS_FORBIDDEN))) - - is_owned = issubclass(entity_type, OwnedBotEntity) - - entity_data[field_descriptor.field_name] = value - if is_owned and EntityPermission.CREATE_ALL not in user_permissions: - entity_data["user_id"] = user.id - - deser_entity_data = {key: await deserialize( - session = db_session, - type_ = entity_descriptor.fields_descriptors[key].type_, - value = value) for key, value in entity_data.items()} - - if callback_data.context == CommandContext.ENTITY_CREATE: - - new_entity = await entity_type.create(session = db_session, - obj_in = entity_type(**deser_entity_data), - commit = True) - - state_data["navigation_context"] = ContextData( - command = CallbackCommand.ENTITY_ITEM, - entity_name = entity_descriptor.name, - entity_id = str(new_entity.id)).pack() - - state_data.update(state_data) - - # await save_navigation_context(state = state, callback_data = ContextData( - # command = CallbackCommand.ENTITY_ITEM, - # entity_name = entity_descriptor.name, - # entity_id = str(new_entity.id) - # )) - - elif callback_data.context in [CommandContext.ENTITY_EDIT, CommandContext.ENTITY_FIELD_EDIT]: - - entity_id = int(callback_data.entity_id) - entity = await entity_type.get(session = db_session, id = entity_id) - if not entity: - return await message.answer(text = (await Settings.get(Settings.APP_STRINGS_NOT_FOUND))) - - if (is_owned and entity.user_id != user.id and - EntityPermission.UPDATE_ALL not in user_permissions): - return await message.answer(text = (await Settings.get(Settings.APP_STRINGS_FORBIDDEN))) - - for key, value in deser_entity_data.items(): - setattr(entity, key, value) - - await db_session.commit() - - clear_state(state_data = state_data) - - await route_callback(message = message, back = True, **kwargs) - - -from ..common import (get_value_repr, authorize_command, get_callable_str, - get_entity_descriptor, get_field_descriptor) -from ..navigation import get_navigation_context, route_callback, clear_state, save_navigation_context, pop_navigation_context -from ..forms.entity_form import entity_item \ No newline at end of file diff --git a/bot/handlers/editors/boolean.py b/bot/handlers/editors/boolean.py index 9dd70c8..f435d8b 100644 --- a/bot/handlers/editors/boolean.py +++ b/bot/handlers/editors/boolean.py @@ -5,22 +5,23 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from babel.support import LazyProxy from logging import getLogger -from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor +from ....model.descriptors import EntityFieldDescriptor from ..context import ContextData, CallbackCommand -from ..common import get_send_message -from .common import wrap_editor +from ....utils.main import get_send_message +from .wrapper import wrap_editor logger = getLogger(__name__) router = Router() -async def bool_editor(message: Message | CallbackQuery, - edit_prompt: str, - field_descriptor: EntityFieldDescriptor, - callback_data: ContextData, - **kwargs): - +async def bool_editor( + message: Message | CallbackQuery, + edit_prompt: str, + field_descriptor: EntityFieldDescriptor, + callback_data: ContextData, + **kwargs, +): keyboard_builder = InlineKeyboardBuilder() if isinstance(field_descriptor.bool_true_value, LazyProxy): @@ -34,41 +35,44 @@ async def bool_editor(message: Message | CallbackQuery, false_caption = field_descriptor.bool_false_value keyboard_builder.row( - InlineKeyboardButton(text = true_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, - data = str(True), - save_state = True).pack()), - InlineKeyboardButton(text = false_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, - data = str(False), - save_state = True).pack()) + InlineKeyboardButton( + text=true_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, + data=str(True), + ).pack(), + ), + InlineKeyboardButton( + text=false_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, + data=str(False), + ).pack(), + ), ) state_data = kwargs["state_data"] - await wrap_editor(keyboard_builder = keyboard_builder, - field_descriptor = field_descriptor, - callback_data = callback_data, - state_data = state_data) + await wrap_editor( + keyboard_builder=keyboard_builder, + field_descriptor=field_descriptor, + callback_data=callback_data, + state_data=state_data, + ) state: FSMContext = kwargs["state"] - await state.set_data(state_data) + await state.set_data(state_data) send_message = get_send_message(message) - await send_message(text = edit_prompt, reply_markup = keyboard_builder.as_markup()) - - - - - \ No newline at end of file + await send_message(text=edit_prompt, reply_markup=keyboard_builder.as_markup()) diff --git a/bot/handlers/editors/common.py b/bot/handlers/editors/common.py index 3f405a2..5d3c47a 100644 --- a/bot/handlers/editors/common.py +++ b/bot/handlers/editors/common.py @@ -1,67 +1,75 @@ -from types import NoneType, UnionType -from typing import get_args, get_origin -from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardButton -from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import Message, CallbackQuery +from decimal import Decimal +from datetime import datetime, time -from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor +from ....model.bot_entity import BotEntity +from ....model.bot_enum import BotEnum +from ....model.descriptors import EntityFieldDescriptor from ....model.settings import Settings -from ..context import ContextData, CallbackCommand, CommandContext -from ..navigation import get_navigation_context, pop_navigation_context +from ....model.user import UserBase +from ....utils.main import get_callable_str, get_value_repr +from ..context import ContextData, CommandContext +from .boolean import bool_editor +from .date import date_picker, time_picker +from .entity import entity_picker +from .string import string_editor -async def wrap_editor(keyboard_builder: InlineKeyboardBuilder, - field_descriptor: EntityFieldDescriptor, - callback_data: ContextData, - state_data: dict): - - if callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT, CommandContext.ENTITY_FIELD_EDIT]: - - btns = [] - entity_descriptor = field_descriptor.entity_descriptor - field_index = (entity_descriptor.field_sequence.index(field_descriptor.name) - if callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT] - else 0) - - stack, context = get_navigation_context(state_data = state_data) - context = pop_navigation_context(stack) +async def show_editor(message: Message | CallbackQuery, **kwargs): + field_descriptor: EntityFieldDescriptor = kwargs["field_descriptor"] + current_value = kwargs["current_value"] + user: UserBase = kwargs["user"] + callback_data: ContextData = kwargs.get("callback_data", None) + state_data: dict = kwargs["state_data"] - if field_index > 0: - btns.append(InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), - callback_data = ContextData( - command = CallbackCommand.FIELD_EDITOR, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = entity_descriptor.field_sequence[field_index - 1]).pack())) - - if field_descriptor.is_optional: - btns.append(InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_SKIP_BTN)), - 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, - data = "skip").pack())) - - keyboard_builder.row(*btns) + value_type = field_descriptor.type_base - - keyboard_builder.row(InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_CANCEL_BTN)), - callback_data = context.pack())) + if field_descriptor.edit_prompt: + edit_prompt = get_callable_str( + field_descriptor.edit_prompt, field_descriptor, None, current_value + ) + else: + if field_descriptor.caption: + caption_str = get_callable_str( + field_descriptor.caption, field_descriptor, None, current_value + ) + else: + caption_str = field_descriptor.name + if callback_data.context == CommandContext.ENTITY_EDIT: + edit_prompt = ( + await Settings.get( + Settings.APP_STRINGS_FIELD_EDIT_PROMPT_TEMPLATE_P_NAME_VALUE + ) + ).format( + name=caption_str, + value=get_value_repr(current_value, field_descriptor, user.lang), + ) + else: + edit_prompt = ( + await Settings.get( + Settings.APP_STRINGS_FIELD_CREATE_PROMPT_TEMPLATE_P_NAME + ) + ).format(name=caption_str) - elif callback_data.context == CommandContext.SETTING_EDIT: + kwargs["edit_prompt"] = edit_prompt - keyboard_builder.row( - InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_CANCEL_BTN)), - callback_data = ContextData( - command = CallbackCommand.FIELD_EDITOR_CALLBACK, - context = callback_data.context, - field_name = callback_data.field_name, - data = "cancel").pack())) - + if value_type not in [int, float, Decimal, str]: + state_data.update({"context_data": callback_data.pack()}) + + if value_type is bool: + await bool_editor(message=message, **kwargs) + + elif value_type in [int, float, Decimal, str]: + await string_editor(message=message, **kwargs) + + elif value_type is datetime: + await date_picker(message=message, **kwargs) + + elif value_type is time: + await time_picker(message=message, **kwargs) + + elif issubclass(value_type, BotEntity) or issubclass(value_type, BotEnum): + await entity_picker(message=message, **kwargs) + + else: + raise ValueError(f"Unsupported field type: {value_type}") diff --git a/bot/handlers/editors/date.py b/bot/handlers/editors/date.py index f43c323..375490d 100644 --- a/bot/handlers/editors/date.py +++ b/bot/handlers/editors/date.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, time, timedelta from aiogram import Router, F from aiogram.fsm.context import FSMContext from aiogram.types import Message, CallbackQuery, InlineKeyboardButton @@ -6,10 +6,11 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from logging import getLogger from typing import TYPE_CHECKING -from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor +from ....model.descriptors import EntityFieldDescriptor +from ....model.settings import Settings from ..context import ContextData, CallbackCommand -from ..common import get_send_message, get_field_descriptor, get_entity_descriptor -from .common import wrap_editor +from ....utils.main import get_send_message, get_field_descriptor +from .wrapper import wrap_editor if TYPE_CHECKING: from ....main import QBotApp @@ -19,154 +20,343 @@ logger = getLogger(__name__) router = Router() -async def date_picker(message: Message | CallbackQuery, - field_descriptor: EntityFieldDescriptor, - callback_data: ContextData, - current_value: datetime, - state: FSMContext, - edit_prompt: str | None = None, - **kwargs): - - if not current_value: - start_date = datetime.now() - else: - start_date = current_value - - start_date = start_date.replace(day = 1) - - previous_month = start_date - timedelta(days = 1) - next_month = start_date.replace(day = 28) + timedelta(days = 4) - - keyboard_builder = InlineKeyboardBuilder() - keyboard_builder.row(InlineKeyboardButton(text = "◀️", - callback_data = ContextData( - command = CallbackCommand.DATE_PICKER_MONTH, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = previous_month.strftime("%Y-%m-%d"), - save_state = True).pack()), - InlineKeyboardButton(text = start_date.strftime("%b %Y"), - callback_data = ContextData( - command = CallbackCommand.DATE_PICKER_YEAR, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = start_date.strftime("%Y-%m-%d"), - save_state = True).pack()), - InlineKeyboardButton(text = "▶️", - callback_data = ContextData( - command = CallbackCommand.DATE_PICKER_MONTH, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = next_month.strftime("%Y-%m-%d"), - save_state = True).pack())) - - first_day = start_date - timedelta(days = start_date.weekday()) - weeks = (((start_date.replace(day = 28) + timedelta(days = 4)).replace(day = 1) - first_day).days - 1) // 7 + 1 - for week in range(weeks): - buttons = [] - for day in range(7): - current_day = first_day + timedelta(days = week * 7 + day) - buttons.append(InlineKeyboardButton(text = current_day.strftime("%d"), - 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, - data = current_day.strftime("%Y-%m-%d"), - save_state = True).pack())) - - keyboard_builder.row(*buttons) - - state_data = kwargs["state_data"] - - await wrap_editor(keyboard_builder = keyboard_builder, - field_descriptor = field_descriptor, - callback_data = callback_data, - state_data = state_data) - - await state.set_data(state_data) - - if edit_prompt: - send_message = get_send_message(message) - await send_message(text = edit_prompt, reply_markup = keyboard_builder.as_markup()) - else: - await message.edit_reply_markup(reply_markup = keyboard_builder.as_markup()) - - -@router.callback_query(ContextData.filter(F.command == CallbackCommand.DATE_PICKER_YEAR)) -async def date_picker_year(query: CallbackQuery, - callback_data: ContextData, - app: "QBotApp", - state: FSMContext, - **kwargs): - - start_date = datetime.strptime(callback_data.data, "%Y-%m-%d") - - state_data = await state.get_data() - kwargs["state_data"] = state_data - - keyboard_builder = InlineKeyboardBuilder() - keyboard_builder.row(InlineKeyboardButton(text = "🔼", - callback_data = ContextData( - command = CallbackCommand.DATE_PICKER_YEAR, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = start_date.replace(year = start_date.year - 20).strftime("%Y-%m-%d")).pack())) - - for r in range(4): - buttons = [] - for c in range(5): - current_date = start_date.replace(year = start_date.year + r * 5 + c - 10) - buttons.append(InlineKeyboardButton(text = current_date.strftime("%Y"), - callback_data = ContextData( - command = CallbackCommand.DATE_PICKER_MONTH, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = current_date.strftime("%Y-%m-%d"), - save_state = True).pack())) - - keyboard_builder.row(*buttons) - - keyboard_builder.row(InlineKeyboardButton(text = "🔽", - callback_data = ContextData( - command = CallbackCommand.DATE_PICKER_YEAR, - context = callback_data.context, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - field_name = callback_data.field_name, - data = start_date.replace(year = start_date.year + 20).strftime("%Y-%m-%d")).pack())) - - field_descriptor = get_field_descriptor(app, callback_data) - - await wrap_editor(keyboard_builder = keyboard_builder, - field_descriptor = field_descriptor, - callback_data = callback_data, - state_data = state_data) - - await query.message.edit_reply_markup(reply_markup = keyboard_builder.as_markup()) - - -@router.callback_query(ContextData.filter(F.command == CallbackCommand.DATE_PICKER_MONTH)) -async def date_picker_month(query: CallbackQuery, callback_data: ContextData, app: "QBotApp", **kwargs): +@router.callback_query(ContextData.filter(F.command == CallbackCommand.TIME_PICKER)) +async def time_picker_callback( + query: CallbackQuery, callback_data: ContextData, app: "QBotApp", **kwargs +): + if not callback_data.data: + return field_descriptor = get_field_descriptor(app, callback_data) state: FSMContext = kwargs["state"] state_data = await state.get_data() kwargs["state_data"] = state_data - await date_picker(query.message, - field_descriptor = field_descriptor, - callback_data = callback_data, - current_value = datetime.strptime(callback_data.data, "%Y-%m-%d"), - **kwargs) \ No newline at end of file + await time_picker( + query.message, + field_descriptor=field_descriptor, + callback_data=callback_data, + current_value=datetime.strptime(callback_data.data, "%Y-%m-%d %H-%M") + if len(callback_data.data) > 10 + else time.fromisoformat(callback_data.data.replace("-", ":")), + **kwargs, + ) + + +async def time_picker( + message: Message | CallbackQuery, + field_descriptor: EntityFieldDescriptor, + callback_data: ContextData, + current_value: datetime | time, + state: FSMContext, + edit_prompt: str | None = None, + **kwargs, +): + keyboard_builder = InlineKeyboardBuilder() + + for i in range(12): + keyboard_builder.row( + InlineKeyboardButton( + text=( + "▶︎ {v:02d} ◀︎" if i == (current_value.hour % 12) else "{v:02d}" + ).format(v=i if current_value.hour < 12 else i + 12), + callback_data=ContextData( + command=CallbackCommand.TIME_PICKER, + 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, + data=current_value.replace( + hour=i if current_value.hour < 12 else i + 12 + ).strftime( + "%Y-%m-%d %H-%M" + if isinstance(current_value, datetime) + else "%H-%M" + ) + if i != current_value.hour % 12 + else None, + ).pack(), + ), + InlineKeyboardButton( + text=( + "▶︎ {v:02d} ◀︎" if i == current_value.minute // 5 else "{v:02d}" + ).format(v=i * 5), + callback_data=ContextData( + command=CallbackCommand.TIME_PICKER, + 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, + data=current_value.replace(minute=i * 5).strftime( + "%Y-%m-%d %H-%M" + if isinstance(current_value, datetime) + else "%H-%M" + ) + if i != current_value.minute // 5 + else None, + ).pack(), + ), + ) + keyboard_builder.row( + InlineKeyboardButton( + text="AM/PM", + callback_data=ContextData( + command=CallbackCommand.TIME_PICKER, + 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, + data=current_value.replace( + hour=current_value.hour + 12 + if current_value.hour < 12 + else current_value.hour - 12 + ).strftime( + "%Y-%m-%d %H-%M" if isinstance(current_value, datetime) else "%H-%M" + ), + ).pack(), + ), + InlineKeyboardButton( + text=await Settings.get(Settings.APP_STRINGS_DONE_BTN), + 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, + data=current_value.strftime( + "%Y-%m-%d %H-%M" if isinstance(current_value, datetime) else "%H-%M" + ), + ).pack(), + ), + ) + + state_data = kwargs["state_data"] + + await wrap_editor( + keyboard_builder=keyboard_builder, + field_descriptor=field_descriptor, + callback_data=callback_data, + state_data=state_data, + ) + + await state.set_data(state_data) + + if edit_prompt: + send_message = get_send_message(message) + await send_message(text=edit_prompt, reply_markup=keyboard_builder.as_markup()) + else: + await message.edit_reply_markup(reply_markup=keyboard_builder.as_markup()) + + +async def date_picker( + message: Message | CallbackQuery, + field_descriptor: EntityFieldDescriptor, + callback_data: ContextData, + current_value: datetime, + state: FSMContext, + edit_prompt: str | None = None, + **kwargs, +): + if not current_value: + start_date = datetime.now() + else: + start_date = current_value + + start_date = current_value.replace(day=1) + + previous_month = start_date - timedelta(days=1) + next_month = start_date.replace(day=28) + timedelta(days=4) + + keyboard_builder = InlineKeyboardBuilder() + keyboard_builder.row( + InlineKeyboardButton( + text="◀️", + callback_data=ContextData( + command=CallbackCommand.DATE_PICKER_MONTH, + 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, + data=previous_month.strftime("%Y-%m-%d %H-%M"), + ).pack(), + ), + InlineKeyboardButton( + text=start_date.strftime("%b %Y"), + callback_data=ContextData( + command=CallbackCommand.DATE_PICKER_YEAR, + 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, + data=start_date.strftime("%Y-%m-%d %H-%M"), + ).pack(), + ), + InlineKeyboardButton( + text="▶️", + callback_data=ContextData( + command=CallbackCommand.DATE_PICKER_MONTH, + 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, + data=next_month.strftime("%Y-%m-%d %H-%M"), + ).pack(), + ), + ) + + first_day = start_date - timedelta(days=start_date.weekday()) + weeks = ( + ( + (start_date.replace(day=28) + timedelta(days=4)).replace(day=1) - first_day + ).days + - 1 + ) // 7 + 1 + for week in range(weeks): + buttons = [] + for day in range(7): + current_day = first_day + timedelta(days=week * 7 + day) + buttons.append( + InlineKeyboardButton( + text=current_day.strftime("%d"), + callback_data=ContextData( + command=CallbackCommand.FIELD_EDITOR_CALLBACK + if field_descriptor.dt_type == "date" + else CallbackCommand.TIME_PICKER, + 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, + data=current_day.strftime("%Y-%m-%d %H-%M"), + ).pack(), + ) + ) + + keyboard_builder.row(*buttons) + + state_data = kwargs["state_data"] + + await wrap_editor( + keyboard_builder=keyboard_builder, + field_descriptor=field_descriptor, + callback_data=callback_data, + state_data=state_data, + ) + + await state.set_data(state_data) + + if edit_prompt: + send_message = get_send_message(message) + await send_message(text=edit_prompt, reply_markup=keyboard_builder.as_markup()) + else: + await message.edit_reply_markup(reply_markup=keyboard_builder.as_markup()) + + +@router.callback_query( + ContextData.filter(F.command == CallbackCommand.DATE_PICKER_YEAR) +) +async def date_picker_year( + query: CallbackQuery, + callback_data: ContextData, + app: "QBotApp", + state: FSMContext, + **kwargs, +): + start_date = datetime.strptime(callback_data.data, "%Y-%m-%d %H-%M") + + state_data = await state.get_data() + kwargs["state_data"] = state_data + + keyboard_builder = InlineKeyboardBuilder() + keyboard_builder.row( + InlineKeyboardButton( + text="🔼", + callback_data=ContextData( + command=CallbackCommand.DATE_PICKER_YEAR, + 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, + data=start_date.replace(year=start_date.year - 20).strftime( + "%Y-%m-%d %H-%M" + ), + ).pack(), + ) + ) + + for r in range(4): + buttons = [] + for c in range(5): + current_date = start_date.replace(year=start_date.year + r * 5 + c - 10) + buttons.append( + InlineKeyboardButton( + text=current_date.strftime("%Y"), + callback_data=ContextData( + command=CallbackCommand.DATE_PICKER_MONTH, + 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, + data=current_date.strftime("%Y-%m-%d %H-%M"), + ).pack(), + ) + ) + + keyboard_builder.row(*buttons) + + keyboard_builder.row( + InlineKeyboardButton( + text="🔽", + callback_data=ContextData( + command=CallbackCommand.DATE_PICKER_YEAR, + 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, + data=start_date.replace(year=start_date.year + 20).strftime( + "%Y-%m-%d %H-%M" + ), + ).pack(), + ) + ) + + field_descriptor = get_field_descriptor(app, callback_data) + + await wrap_editor( + keyboard_builder=keyboard_builder, + field_descriptor=field_descriptor, + callback_data=callback_data, + state_data=state_data, + ) + + await query.message.edit_reply_markup(reply_markup=keyboard_builder.as_markup()) + + +@router.callback_query( + ContextData.filter(F.command == CallbackCommand.DATE_PICKER_MONTH) +) +async def date_picker_month( + query: CallbackQuery, callback_data: ContextData, app: "QBotApp", **kwargs +): + field_descriptor = get_field_descriptor(app, callback_data) + state: FSMContext = kwargs["state"] + state_data = await state.get_data() + kwargs["state_data"] = state_data + + await date_picker( + query.message, + field_descriptor=field_descriptor, + callback_data=callback_data, + current_value=datetime.strptime(callback_data.data, "%Y-%m-%d %H-%M"), + **kwargs, + ) diff --git a/bot/handlers/editors/entity.py b/bot/handlers/editors/entity.py index 1e348fc..b9ffa3d 100644 --- a/bot/handlers/editors/entity.py +++ b/bot/handlers/editors/entity.py @@ -1,25 +1,32 @@ -from types import UnionType from aiogram import Router, F from aiogram.fsm.context import FSMContext from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder from logging import getLogger +from sqlmodel import column from sqlmodel.ext.asyncio.session import AsyncSession -from typing import get_args, get_origin, TYPE_CHECKING +from typing import TYPE_CHECKING from ....model.bot_entity import BotEntity -from ....model.owned_bot_entity import OwnedBotEntity + +# from ....model.owned_bot_entity import OwnedBotEntity from ....model.bot_enum import BotEnum from ....model.settings import Settings from ....model.user import UserBase from ....model.view_setting import ViewSetting -from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor +from ....model.descriptors import EntityFieldDescriptor, Filter from ....model import EntityPermission -from ....utils import serialize, deserialize, get_user_permissions +from ....utils.main import ( + get_user_permissions, + get_send_message, + get_field_descriptor, + get_callable_str, +) +from ....utils.serialization import serialize, deserialize from ..context import ContextData, CallbackCommand -from ..common import (get_send_message, get_local_text, get_field_descriptor, - get_entity_descriptor, add_pagination_controls, add_filter_controls) -from .common import wrap_editor +from ..common.pagination import add_pagination_controls +from ..common.filtering import add_filter_controls +from .wrapper import wrap_editor if TYPE_CHECKING: from ....main import QBotApp @@ -28,179 +35,295 @@ logger = getLogger(__name__) router = Router() -async def entity_picker(message: Message | CallbackQuery, - field_descriptor: EntityFieldDescriptor, - edit_prompt: str, - current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum], - **kwargs): - +async def entity_picker( + message: Message | CallbackQuery, + field_descriptor: EntityFieldDescriptor, + edit_prompt: str, + current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum], + **kwargs, +): state_data: dict = kwargs["state_data"] - state_data.update({"current_value": serialize(current_value, field_descriptor), - "value": serialize(current_value, field_descriptor), - "edit_prompt": edit_prompt}) + state_data.update( + { + "current_value": serialize(current_value, field_descriptor), + "value": serialize(current_value, field_descriptor), + "edit_prompt": edit_prompt, + } + ) - await render_entity_picker(field_descriptor = field_descriptor, - message = message, - current_value = current_value, - edit_prompt = edit_prompt, - **kwargs) + await render_entity_picker( + field_descriptor=field_descriptor, + message=message, + current_value=current_value, + edit_prompt=edit_prompt, + **kwargs, + ) def calc_total_pages(items_count: int, page_size: int) -> int: - return max(items_count // page_size + (1 if items_count % page_size else 0), 1) -async def render_entity_picker(*, - field_descriptor: EntityFieldDescriptor, - message: Message | CallbackQuery, - callback_data: ContextData, - user: UserBase, - db_session: AsyncSession, - state: FSMContext, - current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum], - edit_prompt: str, - page: int = 1, - **kwargs): - - - if callback_data.command in [CallbackCommand.ENTITY_PICKER_PAGE, CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM]: +async def render_entity_picker( + *, + field_descriptor: EntityFieldDescriptor, + message: Message | CallbackQuery, + callback_data: ContextData, + user: UserBase, + db_session: AsyncSession, + state: FSMContext, + current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum], + edit_prompt: str, + page: int = 1, + **kwargs, +): + if callback_data.command in [ + CallbackCommand.ENTITY_PICKER_PAGE, + CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM, + ]: page = int(callback_data.data.split("&")[0]) - # is_list = False - - # type_origin = get_origin(field_descriptor.type_) - # if type_origin == UnionType: - # type_ = get_args(field_descriptor.type_)[0] - - # elif type_origin == list: - # type_ = get_args(field_descriptor.type_)[0] - # is_list = True - - # else: - # type_ = field_descriptor.type_ - type_ = field_descriptor.type_base is_list = field_descriptor.is_list - + if not issubclass(type_, BotEntity) and not issubclass(type_, BotEnum): raise ValueError("Unsupported type") - + page_size = await Settings.get(Settings.PAGE_SIZE) + form_list = None 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] - items = [{"text": f"{"" if not is_list else "【✔︎】 " if item in (current_value or []) else "【 】 "}{item.localized(user.lang)}", - "value": item.value} for item in enum_items] - 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)}", + "value": item.value, + } + for item in enum_items + ] + elif issubclass(type_, BotEntity): + form_name = field_descriptor.ep_form or "default" + form_list = type_.bot_entity_descriptor.lists.get( + form_name, type_.bot_entity_descriptor.default_list + ) permissions = get_user_permissions(user, type_.bot_entity_descriptor) - entity_filter = await ViewSetting.get_filter(session = db_session, user_id = user.id, entity_name = type_.bot_entity_descriptor.class_name) - if (EntityPermission.LIST_ALL in permissions or - (EntityPermission.LIST in permissions and - not issubclass(type_, OwnedBotEntity))): - items_count = await type_.get_count(session = db_session, filter = entity_filter) - total_pages = calc_total_pages(items_count, page_size) - page = min(page, total_pages) + if form_list.filtering: + entity_filter = await ViewSetting.get_filter( + session=db_session, + user_id=user.id, + entity_name=type_.bot_entity_descriptor.class_name, + ) + else: + entity_filter = None + list_all = EntityPermission.LIST_ALL in permissions + + if list_all or EntityPermission.LIST in permissions: + if ( + field_descriptor.ep_parent_field + and field_descriptor.ep_parent_field + and callback_data.entity_id + ): + entity = await field_descriptor.entity_descriptor.type_.get( + session=db_session, id=callback_data.entity_id + ) + value = getattr(entity, field_descriptor.ep_parent_field) + ext_filter = column(field_descriptor.ep_child_field).__eq__(value) + + else: + ext_filter = None + + if form_list.pagination: + items_count = await type_.get_count( + session=db_session, + static_filter=( + [ + Filter( + field_name=f.field_name, + operator=f.operator, + value_type="const", + value=f.value, + ) + for f in form_list.static_filters + if f.value_type == "const" + ] + if isinstance(form_list.static_filters, list) + else form_list.static_filters + ), + ext_filter=ext_filter, + filter=entity_filter, + filter_fields=form_list.filtering_fields, + user=user if not list_all else None, + ) + total_pages = calc_total_pages(items_count, page_size) + page = min(page, total_pages) + skip = page_size * (page - 1) + limit = page_size + else: + skip = 0 + limit = None entity_items = await type_.get_multi( - session = db_session, order_by = type_.name, filter = entity_filter, - skip = page_size * (page - 1), limit = page_size) - elif (EntityPermission.LIST in permissions and - issubclass(type_, OwnedBotEntity)): - items_count = await type_.get_count_by_user(session = db_session, user_id = user.id, filter = entity_filter) - total_pages = calc_total_pages(items_count, page_size) - page = min(page, total_pages) - entity_items = await type_.get_multi_by_user( - session = db_session, user_id = user.id, order_by = type_.name, filter = entity_filter, - skip = page_size * (page - 1), limit = page_size) + session=db_session, + order_by=form_list.order_by, + static_filter=( + [ + Filter( + field_name=f.field_name, + operator=f.operator, + value_type="const", + value=f.value, + ) + for f in form_list.static_filters + if f.value_type == "const" + ] + if isinstance(form_list.static_filters, list) + else form_list.static_filters + ), + ext_filter=ext_filter, + filter=entity_filter, + user=user if not list_all else None, + skip=skip, + limit=limit, + ) else: items_count = 0 total_pages = 1 page = 1 entity_items = list[BotEntity]() - - items = [{"text": f"{"" if not is_list else "【✔︎】 " if item in (current_value or []) else "【 】 "}{ - type_.bot_entity_descriptor.item_caption(type_.bot_entity_descriptor, item) if type_.bot_entity_descriptor.item_caption - else get_local_text(item.name, user.lang) if type_.bot_entity_descriptor.fields_descriptors["name"].localizable else item.name}", - "value": str(item.id)} for item in entity_items] - - # total_pages = items_count // page_size + (1 if items_count % page_size else 0) - + + items = [ + { + "text": f"{ + '' + if not is_list + else '【✔︎】 ' + if item in (current_value or []) + else '【 】 ' + }{ + type_.bot_entity_descriptor.item_repr( + type_.bot_entity_descriptor, item + ) + if type_.bot_entity_descriptor.item_repr + else get_callable_str( + type_.bot_entity_descriptor.full_name, + type_.bot_entity_descriptor, + item, + ) + if type_.bot_entity_descriptor.full_name + else f'{type_.bot_entity_descriptor.name}: {str(item.id)}' + }", + "value": str(item.id), + } + for item in entity_items + ] + keyboard_builder = InlineKeyboardBuilder() for item in items: keyboard_builder.row( - InlineKeyboardButton(text = item["text"], - callback_data = ContextData( - command = CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM if is_list else 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, - data = f"{page}&{item['value']}" if is_list else item["value"], - save_state = True).pack())) - - add_pagination_controls(keyboard_builder = keyboard_builder, - callback_data = callback_data, - total_pages = total_pages, - command = CallbackCommand.ENTITY_PICKER_PAGE, - page = page) - - if issubclass(type_, BotEntity): - add_filter_controls(keyboard_builder = keyboard_builder, - entity_descriptor = type_.bot_entity_descriptor, - filter = entity_filter) + InlineKeyboardButton( + text=item["text"], + callback_data=ContextData( + command=( + CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM + if is_list + else 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, + data=f"{page}&{item['value']}" if is_list else item["value"], + ).pack(), + ) + ) + + if form_list and form_list.pagination: + add_pagination_controls( + keyboard_builder=keyboard_builder, + callback_data=callback_data, + total_pages=total_pages, + command=CallbackCommand.ENTITY_PICKER_PAGE, + page=page, + ) + + if ( + issubclass(type_, BotEntity) + and form_list.filtering + and form_list.filtering_fields + ): + add_filter_controls( + keyboard_builder=keyboard_builder, + entity_descriptor=type_.bot_entity_descriptor, + filter=entity_filter, + ) if is_list: keyboard_builder.row( - InlineKeyboardButton(text = await Settings.get(Settings.APP_STRINGS_DONE_BTN), - 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, - save_state = True).pack())) + InlineKeyboardButton( + text=await Settings.get(Settings.APP_STRINGS_DONE_BTN), + 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, + ).pack(), + ) + ) state_data = kwargs["state_data"] - await wrap_editor(keyboard_builder = keyboard_builder, - field_descriptor = field_descriptor, - callback_data = callback_data, - state_data = state_data) + await wrap_editor( + keyboard_builder=keyboard_builder, + field_descriptor=field_descriptor, + callback_data=callback_data, + state_data=state_data, + ) await state.set_data(state_data) send_message = get_send_message(message) - await send_message(text = edit_prompt, reply_markup = keyboard_builder.as_markup()) + await send_message(text=edit_prompt, reply_markup=keyboard_builder.as_markup()) -@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_PAGE)) -@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM)) -async def entity_picker_callback(query: CallbackQuery, - callback_data: ContextData, - db_session: AsyncSession, - app: "QBotApp", - state: FSMContext, - **kwargs): - +@router.callback_query( + ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_PAGE) +) +@router.callback_query( + ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM) +) +async def entity_picker_callback( + query: CallbackQuery, + callback_data: ContextData, + db_session: AsyncSession, + app: "QBotApp", + state: FSMContext, + **kwargs, +): state_data = await state.get_data() kwargs["state_data"] = state_data - field_descriptor = get_field_descriptor(app = app, callback_data = callback_data) + field_descriptor = get_field_descriptor(app=app, callback_data=callback_data) - current_value = await deserialize(session = db_session, type_ = field_descriptor.type_, value = state_data["current_value"]) + # current_value = await deserialize(session = db_session, type_ = field_descriptor.type_, value = state_data["current_value"]) edit_prompt = state_data["edit_prompt"] - value = await deserialize(session = db_session, type_ = field_descriptor.type_, value = state_data["value"]) + value = await deserialize( + session=db_session, type_=field_descriptor.type_, value=state_data["value"] + ) if callback_data.command == CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM: page, id_value = callback_data.data.split("&") page = int(page) - type_ = field_descriptor.type_base + type_ = field_descriptor.type_base if issubclass(type_, BotEnum): item = type_(id_value) if item in value: @@ -208,7 +331,7 @@ async def entity_picker_callback(query: CallbackQuery, else: value.append(item) else: - item = await type_.get(session = db_session, id = int(id_value)) + item = await type_.get(session=db_session, id=int(id_value)) if item in value: value.remove(item) else: @@ -221,16 +344,16 @@ async def entity_picker_callback(query: CallbackQuery, page = int(callback_data.data) else: raise ValueError("Unsupported command") - - await render_entity_picker(field_descriptor = field_descriptor, - message = query, - callback_data = callback_data, - current_value = value, - edit_prompt = edit_prompt, - db_session = db_session, - app = app, - state = state, - page = page, - **kwargs) - + await render_entity_picker( + field_descriptor=field_descriptor, + message=query, + callback_data=callback_data, + current_value=value, + edit_prompt=edit_prompt, + db_session=db_session, + app=app, + state=state, + page=page, + **kwargs, + ) diff --git a/bot/handlers/editors/main.py b/bot/handlers/editors/main.py new file mode 100644 index 0000000..50580ac --- /dev/null +++ b/bot/handlers/editors/main.py @@ -0,0 +1,156 @@ +from typing import TYPE_CHECKING +from aiogram import Router, F +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, CallbackQuery +from logging import getLogger +from sqlmodel.ext.asyncio.session import AsyncSession + +from ....model import EntityPermission +from ....model.settings import Settings +from ....model.user import UserBase +from ....utils.main import ( + check_entity_permission, + get_field_descriptor, +) +from ....utils.serialization import deserialize, serialize +from ..context import ContextData, CallbackCommand, CommandContext +from ....auth import authorize_command +from ..navigation import ( + get_navigation_context, + save_navigation_context, +) +from ..forms.entity_form import entity_item +from .common import show_editor + +from ..menu.parameters import parameters_menu +from .string import router as string_editor_router +from .date import router as date_picker_router +from .boolean import router as bool_editor_router +from .entity import router as entity_picker_router + +if TYPE_CHECKING: + from ....main import QBotApp + + +logger = getLogger(__name__) +router = Router() + + +@router.callback_query(ContextData.filter(F.command == CallbackCommand.FIELD_EDITOR)) +async def field_editor(message: Message | CallbackQuery, **kwargs): + callback_data: ContextData = kwargs.get("callback_data", None) + db_session: AsyncSession = kwargs["db_session"] + user: UserBase = kwargs["user"] + app: "QBotApp" = kwargs["app"] + state: FSMContext = kwargs["state"] + + state_data = await state.get_data() + entity_data = state_data.get("entity_data") + + for key in ["current_value", "value", "locale_index"]: + if key in state_data: + state_data.pop(key) + + kwargs["state_data"] = state_data + + entity_descriptor = None + + if callback_data.context == CommandContext.SETTING_EDIT: + field_descriptor = get_field_descriptor(app, callback_data) + + if field_descriptor.type_ is bool: + if await authorize_command(user=user, callback_data=callback_data): + await Settings.set_param( + field_descriptor, not await Settings.get(field_descriptor) + ) + else: + return await message.answer( + text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) + ) + + stack, context = get_navigation_context(state_data=state_data) + + return await parameters_menu( + message=message, navigation_stack=stack, **kwargs + ) + + current_value = await Settings.get(field_descriptor, all_locales=True) + else: + field_descriptor = get_field_descriptor(app, callback_data) + entity_descriptor = field_descriptor.entity_descriptor + + current_value = None + + if ( + field_descriptor.type_base is bool + and callback_data.context == CommandContext.ENTITY_FIELD_EDIT + ): + entity = await entity_descriptor.type_.get( + session=db_session, id=int(callback_data.entity_id) + ) + if check_entity_permission( + entity=entity, user=user, permission=EntityPermission.UPDATE + ): + current_value: bool = ( + getattr(entity, field_descriptor.field_name) or False + ) + setattr(entity, field_descriptor.field_name, not current_value) + + await db_session.commit() + stack, context = get_navigation_context(state_data=state_data) + + return await entity_item( + query=message, navigation_stack=stack, **kwargs + ) + + if not entity_data and callback_data.context in [ + CommandContext.ENTITY_EDIT, + CommandContext.ENTITY_FIELD_EDIT, + ]: + entity = await entity_descriptor.type_.get( + session=kwargs["db_session"], id=int(callback_data.entity_id) + ) + if check_entity_permission( + entity=entity, user=user, permission=EntityPermission.READ + ): + if entity: + form_name = ( + callback_data.form_params.split("&")[0] + if callback_data.form_params + else "default" + ) + form = entity_descriptor.forms.get( + form_name, entity_descriptor.default_form + ) + entity_data = { + key: serialize( + getattr(entity, key), + entity_descriptor.fields_descriptors[key], + ) + for key in ( + form.edit_field_sequence + if callback_data.context == CommandContext.ENTITY_EDIT + else [callback_data.field_name] + ) + } + state_data.update({"entity_data": entity_data}) + + if entity_data: + current_value = await deserialize( + session=db_session, + type_=field_descriptor.type_, + value=entity_data.get(callback_data.field_name), + ) + + kwargs.update({"field_descriptor": field_descriptor}) + save_navigation_context(state_data=state_data, callback_data=callback_data) + + await show_editor(message=message, current_value=current_value, **kwargs) + + +router.include_routers( + string_editor_router, + date_picker_router, + bool_editor_router, + entity_picker_router, +) diff --git a/bot/handlers/editors/main_callbacks.py b/bot/handlers/editors/main_callbacks.py new file mode 100644 index 0000000..1b18c79 --- /dev/null +++ b/bot/handlers/editors/main_callbacks.py @@ -0,0 +1,284 @@ +from aiogram import Router, F +from aiogram.types import Message, CallbackQuery +from aiogram.fsm.context import FSMContext +from sqlmodel.ext.asyncio.session import AsyncSession +from typing import TYPE_CHECKING +from decimal import Decimal +import json + +from ..context import ContextData, CallbackCommand, CommandContext +from ...command_context_filter import CallbackCommandFilter +from ....model import EntityPermission +from ....model.user import UserBase +from ....model.settings import Settings +from ....model.descriptors import EntityFieldDescriptor +from ....model.language import LanguageBase +from ....auth import authorize_command +from ....utils.main import ( + get_user_permissions, + check_entity_permission, + clear_state, + get_entity_descriptor, + get_field_descriptor, +) +from ....utils.serialization import deserialize +from ..common.routing import route_callback +from .common import show_editor + +if TYPE_CHECKING: + from ....main import QBotApp + +router = Router() + + +@router.message(CallbackCommandFilter(CallbackCommand.FIELD_EDITOR_CALLBACK)) +@router.callback_query( + ContextData.filter(F.command == CallbackCommand.FIELD_EDITOR_CALLBACK) +) +async def field_editor_callback(message: Message | CallbackQuery, **kwargs): + app: "QBotApp" = kwargs["app"] + state: FSMContext = kwargs["state"] + + state_data = await state.get_data() + kwargs["state_data"] = state_data + + if isinstance(message, Message): + callback_data: ContextData = kwargs.get("callback_data", None) + context_data = state_data.get("context_data") + if context_data: + callback_data = ContextData.unpack(context_data) + value = message.text + field_descriptor = get_field_descriptor(app, callback_data) + type_base = field_descriptor.type_base + + if type_base is str and field_descriptor.localizable: + locale_index = int(state_data.get("locale_index")) + + value = state_data.get("value") + if value: + value = json.loads(value) + else: + value = {} + + value[list(LanguageBase.all_members.keys())[locale_index]] = message.text + value = json.dumps(value, ensure_ascii=False) + + if locale_index < len(LanguageBase.all_members.values()) - 1: + current_value = state_data.get("current_value") + + state_data.update({"value": value}) + entity_descriptor = get_entity_descriptor(app, callback_data) + kwargs.update({"callback_data": callback_data}) + + return await show_editor( + message=message, + locale_index=locale_index + 1, + field_descriptor=field_descriptor, + entity_descriptor=entity_descriptor, + current_value=current_value, + value=value, + **kwargs, + ) + # else: + # value = state_data.get("value") + # if value: + # value = json.loads(value) + # else: + # value = {} + # value[list(LanguageBase.all_members.keys())[locale_index]] = ( + # message.text + # ) + # value = json.dumps(value, ensure_ascii=False) + + elif type_base in [int, float, Decimal]: + try: + _ = type_base(value) # @IgnoreException + except Exception: + return await message.answer( + text=(await Settings.get(Settings.APP_STRINGS_INVALID_INPUT)) + ) + else: + callback_data: ContextData = kwargs["callback_data"] + if callback_data.data: + if callback_data.data == "skip": + value = None + else: + value = callback_data.data + else: + value = state_data.get("value") + field_descriptor = get_field_descriptor(app, callback_data) + + kwargs.update( + { + "callback_data": callback_data, + } + ) + + await process_field_edit_callback( + message=message, value=value, field_descriptor=field_descriptor, **kwargs + ) + + +async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs): + user: UserBase = kwargs["user"] + db_session: AsyncSession = kwargs["db_session"] + callback_data: ContextData = kwargs.get("callback_data", None) + state_data: dict = kwargs["state_data"] + value = kwargs["value"] + field_descriptor: EntityFieldDescriptor = kwargs["field_descriptor"] + + if callback_data.context == CommandContext.SETTING_EDIT: + if callback_data.data != "cancel": + if await authorize_command(user=user, callback_data=callback_data): + value = await deserialize( + session=db_session, type_=field_descriptor.type_, value=value + ) + await Settings.set_param(field_descriptor, value) + else: + return await message.answer( + text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) + ) + + return await route_callback(message=message, back=True, **kwargs) + + elif callback_data.context in [ + CommandContext.ENTITY_CREATE, + CommandContext.ENTITY_EDIT, + CommandContext.ENTITY_FIELD_EDIT, + ]: + app: "QBotApp" = kwargs["app"] + + entity_descriptor = get_entity_descriptor(app, callback_data) + + form_name = ( + callback_data.form_params.split("&")[0] + if callback_data.form_params + else "default" + ) + form = entity_descriptor.forms.get(form_name, entity_descriptor.default_form) + + field_sequence = form.edit_field_sequence + current_index = ( + field_sequence.index(callback_data.field_name) + if callback_data.context + in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT] + else 0 + ) + + entity_data = state_data.get("entity_data", {}) + + if ( + callback_data.context + in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT] + and current_index < len(field_sequence) - 1 + ): + entity_data[field_descriptor.field_name] = value + state_data.update({"entity_data": entity_data}) + + next_field_name = field_sequence[current_index + 1] + next_field_descriptor = entity_descriptor.fields_descriptors[ + next_field_name + ] + kwargs.update({"field_descriptor": next_field_descriptor}) + callback_data.field_name = next_field_name + + state_entity_val = entity_data.get(next_field_descriptor.field_name) + + current_value = ( + await deserialize( + session=db_session, + type_=next_field_descriptor.type_, + value=state_entity_val, + ) + if state_entity_val + else None + ) + + await show_editor( + message=message, + entity_descriptor=entity_descriptor, + current_value=current_value, + **kwargs, + ) + + else: + entity_type = entity_descriptor.type_ + + entity_data[field_descriptor.field_name] = value + + # What if user has several roles and each role has its own ownership field? Should we allow creation even + # if user has no CREATE_ALL permission + + # for role in user.roles: + # if role in entity_descriptor.ownership_fields and not EntityPermission.CREATE_ALL in user_permissions: + # entity_data[entity_descriptor.ownership_fields[role]] = user.id + + deser_entity_data = { + key: await deserialize( + session=db_session, + type_=entity_descriptor.fields_descriptors[key].type_, + value=value, + ) + for key, value in entity_data.items() + } + + if callback_data.context == CommandContext.ENTITY_CREATE: + user_permissions = get_user_permissions(user, entity_descriptor) + if ( + EntityPermission.CREATE not in user_permissions + and EntityPermission.CREATE_ALL not in user_permissions + ): + return await message.answer( + text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) + ) + + new_entity = await entity_type.create( + session=db_session, + obj_in=entity_type(**deser_entity_data), + commit=True, + ) + + form_name = ( + callback_data.form_params.split("&")[0] + if callback_data.form_params + else "default" + ) + form_list = entity_descriptor.lists.get( + form_name or "default", entity_descriptor.default_list + ) + + state_data["navigation_context"] = ContextData( + command=CallbackCommand.ENTITY_ITEM, + entity_name=entity_descriptor.name, + form_params=form_list.item_form, + entity_id=str(new_entity.id), + ).pack() + + state_data.update(state_data) + + elif callback_data.context in [ + CommandContext.ENTITY_EDIT, + CommandContext.ENTITY_FIELD_EDIT, + ]: + entity_id = int(callback_data.entity_id) + entity = await entity_type.get(session=db_session, id=entity_id) + if not entity: + return await message.answer( + text=(await Settings.get(Settings.APP_STRINGS_NOT_FOUND)) + ) + + if not check_entity_permission( + entity=entity, user=user, permission=EntityPermission.UPDATE + ): + return await message.answer( + text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) + ) + + for key, value in deser_entity_data.items(): + setattr(entity, key, value) + + await db_session.commit() + + clear_state(state_data=state_data) + + await route_callback(message=message, back=True, **kwargs) diff --git a/bot/handlers/editors/string.py b/bot/handlers/editors/string.py index 5b98dcb..91013a0 100644 --- a/bot/handlers/editors/string.py +++ b/bot/handlers/editors/string.py @@ -1,98 +1,97 @@ -from types import UnionType from aiogram import Router from aiogram.fsm.context import FSMContext from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, CopyTextButton from aiogram.utils.keyboard import InlineKeyboardBuilder from logging import getLogger -from typing import Any, get_args, get_origin +from typing import Any -from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor +from ....model.descriptors import EntityFieldDescriptor from ....model.language import LanguageBase from ....model.settings import Settings -from ....utils import serialize +from ....utils.main import get_send_message, get_local_text +from ....utils.serialization import serialize from ..context import ContextData, CallbackCommand -from ..common import get_send_message, get_local_text -from .common import wrap_editor +from .wrapper import wrap_editor logger = getLogger(__name__) router = Router() -async def string_editor(message: Message | CallbackQuery, - field_descriptor: EntityFieldDescriptor, - callback_data: ContextData, - current_value: Any, - edit_prompt: str, - state: FSMContext, - locale_index: int = 0, - **kwargs): - +async def string_editor( + message: Message | CallbackQuery, + field_descriptor: EntityFieldDescriptor, + callback_data: ContextData, + current_value: Any, + edit_prompt: str, + state: FSMContext, + locale_index: int = 0, + **kwargs, +): keyboard_builder = InlineKeyboardBuilder() state_data: dict = kwargs["state_data"] _edit_prompt = edit_prompt - # type_ = field_descriptor.type_ - # type_origin = get_origin(type_) - # if type_origin == UnionType: - # type_ = get_args(type_)[0] + context_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, + ) - if field_descriptor.type_base == str and field_descriptor.localizable: - + if field_descriptor.type_base is str and field_descriptor.localizable: current_locale = list(LanguageBase.all_members.values())[locale_index] - context_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, - save_state = True) - - _edit_prompt = f"{edit_prompt}\n{(await Settings.get( - Settings.APP_STRINGS_STRING_EDITOR_LOCALE_TEMPLATE_P_NAME)).format(name = current_locale)}" - _current_value = get_local_text(current_value, current_locale) if current_value else None - - state_data.update({ - "context_data": context_data.pack(), - "edit_prompt": edit_prompt, - "locale_index": str(locale_index), - "current_value": current_value}) - - else: - context_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, - save_state = True) + _edit_prompt = f"{edit_prompt}\n{ + ( + await Settings.get( + Settings.APP_STRINGS_STRING_EDITOR_LOCALE_TEMPLATE_P_NAME + ) + ).format(name=current_locale) + }" + _current_value = ( + get_local_text(current_value, current_locale) if current_value else None + ) + + state_data.update( + { + "context_data": context_data.pack(), + "edit_prompt": edit_prompt, + "locale_index": str(locale_index), + "current_value": current_value, + } + ) + + else: _current_value = serialize(current_value, field_descriptor) - state_data.update({ - "context_data": context_data.pack()}) - - if _current_value: + state_data.update({"context_data": context_data.pack()}) - _current_value_caption = f"{_current_value[:30]}..." if len(_current_value) > 30 else _current_value - keyboard_builder.row(InlineKeyboardButton(text = _current_value_caption, - copy_text = CopyTextButton(text = _current_value))) + if _current_value: + _current_value_caption = ( + f"{_current_value[:30]}..." if len(_current_value) > 30 else _current_value + ) + keyboard_builder.row( + InlineKeyboardButton( + text=_current_value_caption, + copy_text=CopyTextButton(text=_current_value), + ) + ) state_data = kwargs["state_data"] - await wrap_editor(keyboard_builder = keyboard_builder, - field_descriptor = field_descriptor, - callback_data = callback_data, - state_data = state_data) + await wrap_editor( + keyboard_builder=keyboard_builder, + field_descriptor=field_descriptor, + callback_data=callback_data, + state_data=state_data, + ) await state.set_data(state_data) - - send_message = get_send_message(message) - await send_message(text = _edit_prompt, reply_markup = keyboard_builder.as_markup()) - -# async def context_command_fiter(*args, **kwargs): -# print(args, kwargs) -# return True + send_message = get_send_message(message) + await send_message(text=_edit_prompt, reply_markup=keyboard_builder.as_markup()) diff --git a/bot/handlers/editors/wrapper.py b/bot/handlers/editors/wrapper.py new file mode 100644 index 0000000..2962395 --- /dev/null +++ b/bot/handlers/editors/wrapper.py @@ -0,0 +1,93 @@ +from aiogram.types import InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from ....model.settings import Settings +from ....model.descriptors import EntityFieldDescriptor +from ..context import ContextData, CallbackCommand, CommandContext +from ..navigation import get_navigation_context, pop_navigation_context + + +async def wrap_editor( + keyboard_builder: InlineKeyboardBuilder, + field_descriptor: EntityFieldDescriptor, + callback_data: ContextData, + state_data: dict, +): + if callback_data.context in [ + CommandContext.ENTITY_CREATE, + CommandContext.ENTITY_EDIT, + CommandContext.ENTITY_FIELD_EDIT, + ]: + form_name = ( + callback_data.form_params.split("&")[0] + if callback_data.form_params + else "default" + ) + form = field_descriptor.entity_descriptor.forms.get( + form_name, field_descriptor.entity_descriptor.default_form + ) + + btns = [] + field_index = ( + form.edit_field_sequence.index(field_descriptor.name) + if callback_data.context + in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT] + else 0 + ) + + stack, context = get_navigation_context(state_data=state_data) + context = pop_navigation_context(stack) + + if field_index > 0: + btns.append( + InlineKeyboardButton( + text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)), + callback_data=ContextData( + command=CallbackCommand.FIELD_EDITOR, + context=callback_data.context, + entity_name=callback_data.entity_name, + entity_id=callback_data.entity_id, + form_params=callback_data.form_params, + field_name=form.edit_field_sequence[field_index - 1], + ).pack(), + ) + ) + + if field_descriptor.is_optional: + btns.append( + InlineKeyboardButton( + text=(await Settings.get(Settings.APP_STRINGS_SKIP_BTN)), + callback_data=ContextData( + command=CallbackCommand.FIELD_EDITOR_CALLBACK, + context=callback_data.context, + entity_name=callback_data.entity_name, + entity_id=callback_data.entity_id, + form_params=callback_data.form_params, + field_name=callback_data.field_name, + data="skip", + ).pack(), + ) + ) + + keyboard_builder.row(*btns) + + keyboard_builder.row( + InlineKeyboardButton( + text=(await Settings.get(Settings.APP_STRINGS_CANCEL_BTN)), + callback_data=context.pack(), + ) + ) + + elif callback_data.context == CommandContext.SETTING_EDIT: + keyboard_builder.row( + InlineKeyboardButton( + text=(await Settings.get(Settings.APP_STRINGS_CANCEL_BTN)), + callback_data=ContextData( + command=CallbackCommand.FIELD_EDITOR_CALLBACK, + context=callback_data.context, + field_name=callback_data.field_name, + form_params=callback_data.form_params, + data="cancel", + ).pack(), + ) + ) diff --git a/bot/handlers/forms/entity_form.py b/bot/handlers/forms/entity_form.py index 8e04148..3916a8b 100644 --- a/bot/handlers/forms/entity_form.py +++ b/bot/handlers/forms/entity_form.py @@ -1,24 +1,28 @@ -from typing import get_args, get_origin, TYPE_CHECKING +from typing import TYPE_CHECKING from aiogram import Router, F -from aiogram.filters import Command from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import StatesGroup, State -from aiogram.types import Message, CallbackQuery, InlineKeyboardButton -from aiogram.utils.i18n import I18n +from aiogram.types import CallbackQuery, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder from logging import getLogger from sqlmodel.ext.asyncio.session import AsyncSession -from ....model.bot_entity import BotEntity -from ....model.bot_enum import BotEnum -from ....model.owned_bot_entity import OwnedBotEntity +from ....model.descriptors import FieldEditButton, CommandButton from ....model.settings import Settings from ....model.user import UserBase -from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor from ....model import EntityPermission -from ....utils import serialize, deserialize, get_user_permissions +from ....utils.main import ( + check_entity_permission, + get_send_message, + clear_state, + get_value_repr, + get_callable_str, + get_entity_descriptor, +) from ..context import ContextData, CallbackCommand, CommandContext -from ..common import get_send_message, get_local_text, get_entity_descriptor, get_callable_str, get_value_repr +from ..navigation import ( + pop_navigation_context, + save_navigation_context, +) if TYPE_CHECKING: from ....main import QBotApp @@ -30,204 +34,212 @@ router = Router() @router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_ITEM)) async def entity_item_callback(query: CallbackQuery, **kwargs): - callback_data: ContextData = kwargs["callback_data"] state: FSMContext = kwargs["state"] state_data = await state.get_data() kwargs["state_data"] = state_data - clear_state(state_data = state_data) - stack = save_navigation_context(callback_data = callback_data, state_data = state_data) - - await entity_item(query = query, navigation_stack = stack, **kwargs) + clear_state(state_data=state_data) + stack = save_navigation_context(callback_data=callback_data, state_data=state_data) + + await entity_item(query=query, navigation_stack=stack, **kwargs) -async def entity_item(query: CallbackQuery, - callback_data: ContextData, - db_session: AsyncSession, - user: UserBase, - app: "QBotApp", - navigation_stack: list[ContextData], - **kwargs): - +async def entity_item( + query: CallbackQuery, + callback_data: ContextData, + db_session: AsyncSession, + user: UserBase, + app: "QBotApp", + navigation_stack: list[ContextData], + **kwargs, +): entity_descriptor = get_entity_descriptor(app, callback_data) - user_permissions = get_user_permissions(user, entity_descriptor) - entity_type: BotEntity = entity_descriptor.type_ + # user_permissions = get_user_permissions(user, entity_descriptor) + entity_type = entity_descriptor.type_ keyboard_builder = InlineKeyboardBuilder() - entity_item = await entity_type.get(session = db_session, id = callback_data.entity_id) + entity_item = await entity_type.get(session=db_session, id=callback_data.entity_id) if not entity_item: - return await query.answer(text = (await Settings.get(Settings.APP_STRINGS_NOT_FOUND))) + return await query.answer( + text=(await Settings.get(Settings.APP_STRINGS_NOT_FOUND)) + ) - is_owned = issubclass(entity_type, OwnedBotEntity) + # is_owned = issubclass(entity_type, OwnedBotEntity) - if (EntityPermission.READ not in user_permissions and - EntityPermission.READ_ALL not in user_permissions): + if not check_entity_permission( + entity=entity_item, user=user, permission=EntityPermission.READ + ): + return await query.answer( + text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) + ) - return await query.answer(text = (await Settings.get(Settings.APP_STRINGS_FORBIDDEN))) + can_edit = check_entity_permission( + entity=entity_item, user=user, permission=EntityPermission.UPDATE + ) - if (is_owned and - EntityPermission.READ_ALL not in user_permissions and - entity_item.user_id != user.id): - - return await query.answer(text = (await Settings.get(Settings.APP_STRINGS_FORBIDDEN))) - - can_edit = (EntityPermission.UPDATE_ALL in user_permissions or - (EntityPermission.UPDATE in user_permissions and not is_owned) or - (EntityPermission.UPDATE in user_permissions and is_owned and - entity_item.user_id == user.id)) - - can_delete = (EntityPermission.DELETE_ALL in user_permissions or - (EntityPermission.DELETE in user_permissions and not is_owned) or - (EntityPermission.DELETE in user_permissions and is_owned and - entity_item.user_id == user.id)) + form = entity_descriptor.forms.get( + callback_data.form_params or "default", entity_descriptor.default_form + ) if can_edit: - for edit_buttons_row in entity_descriptor.edit_buttons: + for edit_buttons_row in form.form_buttons: btn_row = [] - for field_name in edit_buttons_row: - field_name, btn_caption = field_name if isinstance(field_name, tuple) else (field_name, None) - if field_name in entity_descriptor.fields_descriptors: - field_descriptor = entity_descriptor.fields_descriptors[field_name] - # if field_descriptor.is_list and issubclass(field_descriptor.type_base, BotEntity): - # await field_descriptor.type_base. - field_value = getattr(entity_item, field_descriptor.field_name) - if btn_caption: - btn_text = get_callable_str(btn_caption, field_descriptor, entity_item, field_value) - else: - if field_descriptor.type_base == bool: - btn_text = f"{"【✔︎】 " if field_value else "【 】 "}{ - get_callable_str(field_descriptor.caption, field_descriptor, entity_item, field_value) if field_descriptor.caption - else field_name}" + for button in edit_buttons_row: + if isinstance(button, FieldEditButton): + field_name = button.field_name + btn_caption = button.caption + if field_name in entity_descriptor.fields_descriptors: + field_descriptor = entity_descriptor.fields_descriptors[ + field_name + ] + field_value = getattr(entity_item, field_descriptor.field_name) + if btn_caption: + btn_text = get_callable_str( + btn_caption, field_descriptor, entity_item, field_value + ) else: - btn_text = (f"✏️ {get_callable_str(field_descriptor.caption, field_descriptor, entity_item, field_value)}" - if field_descriptor.caption else f"✏️ {field_name}") + if field_descriptor.type_base is bool: + btn_text = f"{'【✔︎】 ' if field_value else '【 】 '}{ + get_callable_str( + field_descriptor.caption, + field_descriptor, + entity_item, + field_value, + ) + if field_descriptor.caption + else field_name + }" + else: + btn_text = ( + f"✏️ {get_callable_str(field_descriptor.caption, field_descriptor, entity_item, field_value)}" + if field_descriptor.caption + else f"✏️ {field_name}" + ) + btn_row.append( + InlineKeyboardButton( + text=btn_text, + callback_data=ContextData( + command=CallbackCommand.FIELD_EDITOR, + context=CommandContext.ENTITY_FIELD_EDIT, + entity_name=entity_descriptor.name, + entity_id=str(entity_item.id), + field_name=field_name, + ).pack(), + ) + ) + + elif isinstance(button, CommandButton): + btn_caption = button.caption + if btn_caption: + btn_text = get_callable_str( + btn_caption, entity_descriptor, entity_item + ) + else: + btn_text = button.command btn_row.append( InlineKeyboardButton( - text = btn_text, - callback_data = ContextData( - command = CallbackCommand.FIELD_EDITOR, - context = CommandContext.ENTITY_FIELD_EDIT, - entity_name = entity_descriptor.name, - entity_id = str(entity_item.id), - field_name = field_name).pack())) + text=btn_text, + callback_data=( + button.context_data.pack() + if button.context_data + else ContextData( + command=CallbackCommand.USER_COMMAND, + user_command=button.command, + data=str(entity_item.id), + ).pack() + ), + ) + ) + if btn_row: keyboard_builder.row(*btn_row) edit_delete_row = [] - if can_edit and entity_descriptor.edit_button_visible: + if can_edit and form.show_edit_button: edit_delete_row.append( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_EDIT_BTN)), - callback_data = ContextData( - command = CallbackCommand.FIELD_EDITOR, - context = CommandContext.ENTITY_EDIT, - entity_name = entity_descriptor.name, - entity_id = str(entity_item.id), - field_name = entity_descriptor.field_sequence[0]).pack())) - - if can_delete: + text=(await Settings.get(Settings.APP_STRINGS_EDIT_BTN)), + callback_data=ContextData( + command=CallbackCommand.FIELD_EDITOR, + context=CommandContext.ENTITY_EDIT, + entity_name=entity_descriptor.name, + entity_id=str(entity_item.id), + form_params=callback_data.form_params, + field_name=form.edit_field_sequence[0], + ).pack(), + ) + ) + + if ( + check_entity_permission( + entity=entity_item, user=user, permission=EntityPermission.DELETE + ) + and form.show_delete_button + ): edit_delete_row.append( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_DELETE_BTN)), - callback_data = ContextData( - command = CallbackCommand.ENTITY_DELETE, - entity_name = entity_descriptor.name, - entity_id = str(entity_item.id)).pack())) - + text=(await Settings.get(Settings.APP_STRINGS_DELETE_BTN)), + callback_data=ContextData( + command=CallbackCommand.ENTITY_DELETE, + entity_name=entity_descriptor.name, + form_params=callback_data.form_params, + entity_id=str(entity_item.id), + ).pack(), + ) + ) + if edit_delete_row: keyboard_builder.row(*edit_delete_row) - entity_caption = get_callable_str(entity_descriptor.caption, entity_descriptor, entity_item) - entity_item_name = get_local_text(entity_item.name, user.lang) if entity_descriptor.fields_descriptors["name"].localizable else entity_item.name + if form.item_repr: + item_text = form.item_repr(entity_descriptor, entity_item) + else: + entity_caption = ( + get_callable_str( + entity_descriptor.full_name, entity_descriptor, entity_item + ) + if entity_descriptor.full_name + else entity_descriptor.name + ) - item_text = f"{entity_caption or entity_descriptor.name}: {entity_item_name}" + entity_item_repr = ( + get_callable_str( + entity_descriptor.item_repr, entity_descriptor, entity_item + ) + if entity_descriptor.item_repr + else str(entity_item.id) + ) + + item_text = f"{entity_caption or entity_descriptor.name}: {entity_item_repr}" + + for field_descriptor in entity_descriptor.fields_descriptors.values(): + if field_descriptor.is_visible: + field_caption = get_callable_str( + field_descriptor.caption, field_descriptor, entity_item + ) + value = get_value_repr( + value=getattr(entity_item, field_descriptor.name), + field_descriptor=field_descriptor, + locale=user.lang, + ) + item_text += f"\n{field_caption or field_descriptor.name}:{f' {value}' if value else ''}" - for field_descriptor in entity_descriptor.fields_descriptors.values(): - if field_descriptor.name == "name" or not field_descriptor.is_visible: - continue - field_caption = get_callable_str(field_descriptor.caption, field_descriptor, entity_item) - value = get_value_repr(value = getattr(entity_item, field_descriptor.name), - field_descriptor = field_descriptor, - locale = user.lang) - item_text += f"\n{field_caption or field_descriptor.name}:{f" {value}" if value else ""}" - context = pop_navigation_context(navigation_stack) if context: keyboard_builder.row( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), - callback_data = context.pack())) - + text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)), + callback_data=context.pack(), + ) + ) + state: FSMContext = kwargs["state"] state_data = kwargs["state_data"] await state.set_data(state_data) send_message = get_send_message(query) - await send_message(text = item_text, reply_markup = keyboard_builder.as_markup()) - - -@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_DELETE)) -async def entity_delete_callback(query: CallbackQuery, **kwargs): - - callback_data: ContextData = kwargs["callback_data"] - user: UserBase = kwargs["user"] - db_session: AsyncSession = kwargs["db_session"] - app: "QBotApp" = kwargs["app"] - - entity_descriptor = get_entity_descriptor(app = app, callback_data = callback_data) - user_permissions = get_user_permissions(user, entity_descriptor) - - entity = await entity_descriptor.type_.get(session = db_session, id = int(callback_data.entity_id)) - - if not (EntityPermission.DELETE_ALL in user_permissions or - (EntityPermission.DELETE in user_permissions and not issubclass(entity_descriptor.type_, OwnedBotEntity)) or - (EntityPermission.DELETE in user_permissions and issubclass(entity_descriptor.type_, OwnedBotEntity) and - entity.user_id == user.id)): - - return await query.answer(text = (await Settings.get(Settings.APP_STRINGS_FORBIDDEN))) - - if callback_data.data == "yes": - - await entity_descriptor.type_.remove( - session = db_session, id = int(callback_data.entity_id), commit = True) - - await route_callback(message = query, **kwargs) - - elif callback_data.data == "no": - await route_callback(message = query, back = False, **kwargs) - - elif not callback_data.data: - - field_descriptor = entity_descriptor.fields_descriptors["name"] - - entity = await entity_descriptor.type_.get(session = db_session, id = int(callback_data.entity_id)) - - return await query.message.edit_text( - text = (await Settings.get(Settings.APP_STRINGS_CONFIRM_DELETE_P_NAME)).format( - name = get_value_repr( - value = getattr(entity, field_descriptor.name), - field_descriptor = field_descriptor, - locale = user.lang)), - reply_markup = InlineKeyboardBuilder().row( - InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_YES_BTN)), - callback_data = ContextData( - command = CallbackCommand.ENTITY_DELETE, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - data = "yes").pack()), - InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_NO_BTN)), - callback_data = ContextData( - command = CallbackCommand.ENTITY_ITEM, - entity_name = callback_data.entity_name, - entity_id = callback_data.entity_id, - data = "no").pack())).as_markup()) - - - -from ..navigation import pop_navigation_context, save_navigation_context, clear_state, route_callback + await send_message(text=item_text, reply_markup=keyboard_builder.as_markup()) diff --git a/bot/handlers/forms/entity_form_callbacks.py b/bot/handlers/forms/entity_form_callbacks.py new file mode 100644 index 0000000..66c32a9 --- /dev/null +++ b/bot/handlers/forms/entity_form_callbacks.py @@ -0,0 +1,96 @@ +from aiogram import Router, F +from aiogram.types import CallbackQuery, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder +from sqlmodel.ext.asyncio.session import AsyncSession +from typing import TYPE_CHECKING + +from ..context import ContextData, CallbackCommand +from ....model.user import UserBase +from ....model.settings import Settings +from ....model import EntityPermission +from ....utils.main import ( + check_entity_permission, + get_value_repr, + get_entity_descriptor, +) +from ..common.routing import route_callback + +if TYPE_CHECKING: + from ....main import QBotApp + + +router = Router() + + +@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_DELETE)) +async def entity_delete_callback(query: CallbackQuery, **kwargs): + callback_data: ContextData = kwargs["callback_data"] + user: UserBase = kwargs["user"] + db_session: AsyncSession = kwargs["db_session"] + app: "QBotApp" = kwargs["app"] + + entity_descriptor = get_entity_descriptor(app=app, callback_data=callback_data) + + entity = await entity_descriptor.type_.get( + session=db_session, id=int(callback_data.entity_id) + ) + + if not check_entity_permission( + entity=entity, user=user, permission=EntityPermission.DELETE + ): + return await query.answer( + text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) + ) + + if callback_data.data == "yes": + await entity_descriptor.type_.remove( + session=db_session, id=int(callback_data.entity_id), commit=True + ) + + await route_callback(message=query, **kwargs) + + elif callback_data.data == "no": + await route_callback(message=query, back=False, **kwargs) + + elif not callback_data.data: + field_descriptor = entity_descriptor.fields_descriptors["name"] + + entity = await entity_descriptor.type_.get( + session=db_session, id=int(callback_data.entity_id) + ) + + return await query.message.edit_text( + text=( + await Settings.get(Settings.APP_STRINGS_CONFIRM_DELETE_P_NAME) + ).format( + name=get_value_repr( + value=getattr(entity, field_descriptor.name), + field_descriptor=field_descriptor, + locale=user.lang, + ) + ), + reply_markup=InlineKeyboardBuilder() + .row( + InlineKeyboardButton( + text=(await Settings.get(Settings.APP_STRINGS_YES_BTN)), + callback_data=ContextData( + command=CallbackCommand.ENTITY_DELETE, + entity_name=callback_data.entity_name, + entity_id=callback_data.entity_id, + form_params=callback_data.form_params, + data="yes", + ).pack(), + ), + InlineKeyboardButton( + text=(await Settings.get(Settings.APP_STRINGS_NO_BTN)), + callback_data=ContextData( + command=CallbackCommand.ENTITY_ITEM, + entity_name=callback_data.entity_name, + entity_id=callback_data.entity_id, + form_params=callback_data.form_params, + data="no", + ).pack(), + ), + ) + .as_markup(), + ) diff --git a/bot/handlers/forms/entity_list.py b/bot/handlers/forms/entity_list.py index 0588011..f4196f1 100644 --- a/bot/handlers/forms/entity_list.py +++ b/bot/handlers/forms/entity_list.py @@ -1,26 +1,29 @@ -from typing import get_args, get_origin, TYPE_CHECKING +from typing import TYPE_CHECKING from aiogram import Router, F -from aiogram.filters import Command from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import StatesGroup, State from aiogram.types import Message, CallbackQuery, InlineKeyboardButton -from aiogram.utils.i18n import I18n from aiogram.utils.keyboard import InlineKeyboardBuilder from logging import getLogger from sqlmodel.ext.asyncio.session import AsyncSession from ....model.bot_entity import BotEntity -from ....model.bot_enum import BotEnum -from ....model.owned_bot_entity import OwnedBotEntity from ....model.settings import Settings from ....model.user import UserBase from ....model.view_setting import ViewSetting -from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor +from ....model.descriptors import EntityDescriptor, Filter from ....model import EntityPermission -from ....utils import serialize, deserialize, get_user_permissions +from ....utils.main import ( + get_user_permissions, + get_send_message, + clear_state, + get_entity_descriptor, + get_callable_str, +) +from ....utils.serialization import deserialize from ..context import ContextData, CallbackCommand, CommandContext -from ..common import (add_pagination_controls, get_local_text, get_entity_descriptor, - get_callable_str, get_send_message, add_filter_controls) +from ..common.pagination import add_pagination_controls +from ..common.filtering import add_filter_controls +from ..navigation import pop_navigation_context, save_navigation_context if TYPE_CHECKING: from ....main import QBotApp @@ -32,135 +35,226 @@ router = Router() @router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_LIST)) async def entity_list_callback(query: CallbackQuery, **kwargs): - callback_data: ContextData = kwargs["callback_data"] if callback_data.data == "skip": return - + state: FSMContext = kwargs["state"] state_data = await state.get_data() kwargs["state_data"] = state_data - clear_state(state_data = state_data) - stack = save_navigation_context(callback_data = callback_data, state_data = state_data) - - await entity_list(message = query, navigation_stack = stack, **kwargs) + clear_state(state_data=state_data) + stack = save_navigation_context(callback_data=callback_data, state_data=state_data) + + await entity_list(message=query, navigation_stack=stack, **kwargs) + def calc_total_pages(items_count: int, page_size: int) -> int: - return max(items_count // page_size + (1 if items_count % page_size else 0), 1) + return max(items_count // page_size + (1 if items_count % page_size else 0), 1) -async def entity_list(message: CallbackQuery | Message, - callback_data: ContextData, - db_session: AsyncSession, - user: UserBase, - app: "QBotApp", - navigation_stack: list[ContextData], - **kwargs): - + +async def _prepare_static_filter( + db_session: AsyncSession, + entity_descriptor: EntityDescriptor, + static_filters: list[Filter], + params: list[str], +) -> list[Filter]: + return ( + [ + Filter( + field_name=f.field_name, + operator=f.operator, + value_type="const", + value=( + f.value + if f.value_type == "const" + else await deserialize( + session=db_session, + type_=entity_descriptor.fields_descriptors[ + f.field_name + ].type_base, + value=params[f.param_index], + ) + ), + ) + for f in static_filters + ] + if static_filters + else None + ) + + +async def entity_list( + message: CallbackQuery | Message, + callback_data: ContextData, + db_session: AsyncSession, + user: UserBase, + app: "QBotApp", + navigation_stack: list[ContextData], + **kwargs, +): page = int(callback_data.data or "1") entity_descriptor = get_entity_descriptor(app, callback_data) user_permissions = get_user_permissions(user, entity_descriptor) entity_type = entity_descriptor.type_ + form_params = ( + callback_data.form_params.split("&") if callback_data.form_params else [] + ) + form_name = form_params.pop(0) if form_params else "default" + form_list = entity_descriptor.lists.get( + form_name or "default", entity_descriptor.default_list + ) + form_item = entity_descriptor.forms.get( + form_list.item_form or "default", entity_descriptor.default_form + ) keyboard_builder = InlineKeyboardBuilder() - if EntityPermission.CREATE in user_permissions or EntityPermission.CREATE_ALL in user_permissions: + if ( + EntityPermission.CREATE in user_permissions + or EntityPermission.CREATE_ALL in user_permissions + ) and form_list.show_add_new_button: keyboard_builder.row( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_ADD_BTN)), - callback_data = ContextData( - command = CallbackCommand.FIELD_EDITOR, - context = CommandContext.ENTITY_CREATE, - entity_name = entity_descriptor.name, - field_name = entity_descriptor.field_sequence[0], - save_state = True).pack())) + text=(await Settings.get(Settings.APP_STRINGS_ADD_BTN)), + callback_data=ContextData( + command=CallbackCommand.FIELD_EDITOR, + context=CommandContext.ENTITY_CREATE, + entity_name=entity_descriptor.name, + field_name=form_item.edit_field_sequence[0], + form_params=form_list.item_form, + ).pack(), + ) + ) - page_size = await Settings.get(Settings.PAGE_SIZE) - - entity_filter = await ViewSetting.get_filter(session = db_session, user_id = user.id, entity_name = entity_descriptor.class_name) - - if issubclass(entity_type, OwnedBotEntity): - if EntityPermission.READ_ALL in user_permissions or EntityPermission.LIST_ALL in user_permissions: - items_count = await entity_type.get_count(session = db_session, filter = entity_filter) - total_pages = calc_total_pages(items_count, page_size) - page = min(page, total_pages) - items = await entity_type.get_multi( - session = db_session, order_by = entity_type.name, filter = entity_filter, - skip = page_size * (page - 1), limit = page_size) - elif EntityPermission.READ in user_permissions or EntityPermission.LIST in user_permissions: - items_count = await entity_type.get_count_by_user(session = db_session, user_id = user.id, filter = entity_filter) - total_pages = calc_total_pages(items_count, page_size) - page = min(page, total_pages) - items = await entity_type.get_multi_by_user( - session = db_session, user_id = user.id, order_by = entity_type.name, filter = entity_filter, - skip = page_size * (page - 1), limit = page_size) - else: - items = list[OwnedBotEntity]() - items_count = 0 - total_pages = 1 - page = 1 - elif issubclass(entity_type, BotEntity): - if (EntityPermission.READ in user_permissions or EntityPermission.LIST in user_permissions or - EntityPermission.READ_ALL in user_permissions or EntityPermission.LIST_ALL in user_permissions): - items_count = await entity_type.get_count(session = db_session, filter = entity_filter) - total_pages = calc_total_pages(items_count, page_size) - page = min(page, total_pages) - items = await entity_type.get_multi( - session = db_session, order_by = entity_type.name, filter = entity_filter, - skip = page_size * (page - 1), limit = page_size) - - else: - items = list[BotEntity]() - total_pages = 1 - page = 1 - items_count = 0 + if form_list.filtering: + entity_filter = await ViewSetting.get_filter( + session=db_session, + user_id=user.id, + entity_name=entity_descriptor.class_name, + ) else: - raise ValueError(f"Unsupported entity type: {entity_type}") - - + entity_filter = None + + list_all = ( + EntityPermission.LIST_ALL in user_permissions + or EntityPermission.READ_ALL in user_permissions + ) + if ( + list_all + or EntityPermission.LIST in user_permissions + or EntityPermission.READ in user_permissions + ): + if form_list.pagination: + page_size = await Settings.get(Settings.PAGE_SIZE) + items_count = await entity_type.get_count( + session=db_session, + static_filter=await _prepare_static_filter( + db_session=db_session, + entity_descriptor=entity_descriptor, + static_filters=form_list.static_filters, + params=form_params, + ), + filter=entity_filter, + filter_fields=form_list.filtering_fields, + user=user if not list_all else None, + ) + total_pages = calc_total_pages(items_count, page_size) + page = min(page, total_pages) + skip = page_size * (page - 1) + limit = page_size + else: + skip = 0 + limit = None + + items = await entity_type.get_multi( + session=db_session, + order_by=form_list.order_by, + static_filter=await _prepare_static_filter( + db_session=db_session, + entity_descriptor=entity_descriptor, + static_filters=form_list.static_filters, + params=form_params, + ), + filter=entity_filter, + filter_fields=form_list.filtering_fields, + user=user if not list_all else None, + skip=skip, + limit=limit, + ) + else: + items = list[BotEntity]() + items_count = 0 + total_pages = 1 + page = 1 for item in items: - if entity_descriptor.item_caption: - caption = entity_descriptor.item_caption(entity_descriptor, item) - elif entity_descriptor.fields_descriptors["name"].localizable: - caption = get_local_text(item.name, user.lang) + if form_list.item_repr: + caption = form_list.item_repr(entity_descriptor, item) + elif entity_descriptor.item_repr: + caption = entity_descriptor.item_repr(entity_descriptor, item) + elif entity_descriptor.full_name: + caption = f"{ + get_callable_str( + callable_str=entity_descriptor.full_name, + descriptor=entity_descriptor, + entity=item, + ) + }: {item.id}" else: - caption = item.name + caption = f"{entity_descriptor.name}: {item.id}" + keyboard_builder.row( InlineKeyboardButton( - text = caption, - callback_data = ContextData( - command = CallbackCommand.ENTITY_ITEM, - entity_name = entity_descriptor.name, - entity_id = str(item.id)).pack())) - - add_pagination_controls(keyboard_builder = keyboard_builder, - callback_data = callback_data, - total_pages = total_pages, - command = CallbackCommand.ENTITY_LIST, - page = page) - - add_filter_controls(keyboard_builder = keyboard_builder, - entity_descriptor = entity_descriptor, - filter = entity_filter) - + text=caption, + callback_data=ContextData( + command=CallbackCommand.ENTITY_ITEM, + entity_name=entity_descriptor.name, + form_params=form_list.item_form, + entity_id=str(item.id), + ).pack(), + ) + ) + + if form_list.pagination: + add_pagination_controls( + keyboard_builder=keyboard_builder, + callback_data=callback_data, + total_pages=total_pages, + command=CallbackCommand.ENTITY_LIST, + page=page, + ) + + if form_list.filtering and form_list.filtering_fields: + add_filter_controls( + keyboard_builder=keyboard_builder, + entity_descriptor=entity_descriptor, + filter=entity_filter, + ) + context = pop_navigation_context(navigation_stack) if context: keyboard_builder.row( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), - callback_data = context.pack())) - - if entity_descriptor.caption: - entity_text = get_callable_str(entity_descriptor.caption_plural, entity_descriptor) + text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)), + callback_data=context.pack(), + ) + ) + + if form_list.caption: + entity_text = get_callable_str(form_list.caption, entity_descriptor) else: - entity_text = entity_descriptor.name - if entity_descriptor.description: - entity_desciption = get_callable_str(entity_descriptor.description, entity_descriptor) - else: - entity_desciption = None + if entity_descriptor.full_name_plural: + entity_text = get_callable_str( + entity_descriptor.full_name_plural, entity_descriptor + ) + else: + entity_text = entity_descriptor.name + + if entity_descriptor.description: + entity_text = f"{entity_text} {get_callable_str(entity_descriptor.description, entity_descriptor)}" state: FSMContext = kwargs["state"] state_data = kwargs["state_data"] @@ -168,8 +262,4 @@ async def entity_list(message: CallbackQuery | Message, send_message = get_send_message(message) - await send_message(text = f"{entity_text}{f"\n{entity_desciption}" if entity_desciption else ""}", - reply_markup = keyboard_builder.as_markup()) - - -from ..navigation import pop_navigation_context, save_navigation_context, clear_state + await send_message(text=entity_text, reply_markup=keyboard_builder.as_markup()) diff --git a/bot/handlers/menu/entities.py b/bot/handlers/menu/entities.py index 728d77b..7f9939a 100644 --- a/bot/handlers/menu/entities.py +++ b/bot/handlers/menu/entities.py @@ -4,13 +4,12 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder from babel.support import LazyProxy from logging import getLogger -from sqlmodel.ext.asyncio.session import AsyncSession from typing import TYPE_CHECKING from ....model.settings import Settings -from ....model.user import UserBase from ..context import ContextData, CallbackCommand -from ..common import get_send_message +from ....utils.main import get_send_message from ....model.descriptors import EntityCaptionCallable +from ..navigation import save_navigation_context, pop_navigation_context if TYPE_CHECKING: from ....main import QBotApp @@ -20,55 +19,63 @@ logger = getLogger(__name__) router = Router() -@router.callback_query(ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_ENTITIES)) +@router.callback_query( + ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_ENTITIES) +) async def menu_entry_entities(message: CallbackQuery, **kwargs): - callback_data: ContextData = kwargs["callback_data"] state: FSMContext = kwargs["state"] state_data = await state.get_data() kwargs["state_data"] = state_data - stack = save_navigation_context(callback_data = callback_data, state_data = state_data) + stack = save_navigation_context(callback_data=callback_data, state_data=state_data) - await entities_menu(message = message, navigation_stack = stack, **kwargs) + await entities_menu(message=message, navigation_stack=stack, **kwargs) -async def entities_menu(message: Message | CallbackQuery, - app: "QBotApp", - state: FSMContext, - navigation_stack: list[ContextData], - **kwargs): - +async def entities_menu( + message: Message | CallbackQuery, + app: "QBotApp", + state: FSMContext, + navigation_stack: list[ContextData], + **kwargs, +): keyboard_builder = InlineKeyboardBuilder() entity_metadata = app.entity_metadata for entity in entity_metadata.entity_descriptors.values(): - if entity.caption_plural.__class__ == EntityCaptionCallable: - caption = entity.caption_plural(entity) or entity.name - elif entity.caption_plural.__class__ == LazyProxy: - caption = f"{f"{entity.icon} " if entity.icon else ""}{entity.caption_plural.value or entity.name}" + if entity.full_name_plural.__class__ == EntityCaptionCallable: + caption = entity.full_name_plural(entity) or entity.name + elif entity.full_name_plural.__class__ == LazyProxy: + caption = f"{f'{entity.icon} ' if entity.icon else ''}{entity.full_name_plural.value or entity.name}" else: - caption = f"{f"{entity.icon} " if entity.icon else ""}{entity.caption_plural or entity.name}" - + caption = f"{f'{entity.icon} ' if entity.icon else ''}{entity.full_name_plural or entity.name}" + keyboard_builder.row( InlineKeyboardButton( - text = caption, - callback_data = ContextData(command = CallbackCommand.ENTITY_LIST, entity_name = entity.name).pack())) - + text=caption, + callback_data=ContextData( + command=CallbackCommand.ENTITY_LIST, entity_name=entity.name + ).pack(), + ) + ) + context = pop_navigation_context(navigation_stack) if context: keyboard_builder.row( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), - callback_data = context.pack())) - + text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)), + callback_data=context.pack(), + ) + ) + state_data = kwargs["state_data"] await state.set_data(state_data) - + send_message = get_send_message(message) - await send_message(text = (await Settings.get(Settings.APP_STRINGS_REFERENCES)), reply_markup = keyboard_builder.as_markup()) - - -from ..navigation import save_navigation_context, pop_navigation_context + await send_message( + text=(await Settings.get(Settings.APP_STRINGS_REFERENCES)), + reply_markup=keyboard_builder.as_markup(), + ) diff --git a/bot/handlers/menu/language.py b/bot/handlers/menu/language.py index 18061dc..bdfc63a 100644 --- a/bot/handlers/menu/language.py +++ b/bot/handlers/menu/language.py @@ -1,64 +1,85 @@ from aiogram import Router, F -from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.types import ( + Message, + CallbackQuery, + InlineKeyboardButton, + InlineKeyboardMarkup, +) from aiogram.fsm.context import FSMContext from aiogram.utils.i18n import I18n from logging import getLogger from sqlmodel.ext.asyncio.session import AsyncSession +from ..navigation import pop_navigation_context, save_navigation_context from ....model.language import LanguageBase from ....model.settings import Settings from ....model.user import UserBase from ..context import ContextData, CallbackCommand -from ..navigation import route_callback -from ..common import get_send_message +from ..common.routing import route_callback +from ....utils.main import get_send_message logger = getLogger(__name__) router = Router() -@router.callback_query(ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_LANGUAGE)) +@router.callback_query( + ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_LANGUAGE) +) async def menu_entry_language(message: CallbackQuery, **kwargs): - callback_data: ContextData = kwargs["callback_data"] state: FSMContext = kwargs["state"] state_data = await state.get_data() kwargs["state_data"] = state_data - stack = save_navigation_context(callback_data = callback_data, state_data = state_data) + stack = save_navigation_context(callback_data=callback_data, state_data=state_data) - await language_menu(message, navigation_stack = stack, **kwargs) - + await language_menu(message, navigation_stack=stack, **kwargs) -async def language_menu(message: Message | CallbackQuery, - navigation_stack: list[ContextData], - user: UserBase, - **kwargs): +async def language_menu( + message: Message | CallbackQuery, + navigation_stack: list[ContextData], + user: UserBase, + **kwargs, +): send_message = get_send_message(message) inline_keyboard = [ - [InlineKeyboardButton(text = locale.localized(user.lang), - callback_data = ContextData(command = CallbackCommand.SET_LANGUAGE, - data = str(locale)).pack())] - for locale in LanguageBase.all_members.values()] - + [ + InlineKeyboardButton( + text=locale.localized(user.lang), + callback_data=ContextData( + command=CallbackCommand.SET_LANGUAGE, data=str(locale) + ).pack(), + ) + ] + for locale in LanguageBase.all_members.values() + ] + context = pop_navigation_context(navigation_stack) if context: - inline_keyboard.append([InlineKeyboardButton(text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), - callback_data = context.pack())]) - + inline_keyboard.append( + [ + InlineKeyboardButton( + text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)), + callback_data=context.pack(), + ) + ] + ) + state: FSMContext = kwargs["state"] state_data = kwargs["state_data"] await state.set_data(state_data) - - await send_message(text = (await Settings.get(Settings.APP_STRINGS_LANGUAGE)), - reply_markup = InlineKeyboardMarkup(inline_keyboard = inline_keyboard)) + + await send_message( + text=(await Settings.get(Settings.APP_STRINGS_LANGUAGE)), + reply_markup=InlineKeyboardMarkup(inline_keyboard=inline_keyboard), + ) @router.callback_query(ContextData.filter(F.command == CallbackCommand.SET_LANGUAGE)) async def set_language(message: CallbackQuery, **kwargs): - user: UserBase = kwargs["user"] callback_data: ContextData = kwargs["callback_data"] db_session: AsyncSession = kwargs["db_session"] @@ -73,6 +94,3 @@ async def set_language(message: CallbackQuery, **kwargs): i18n: I18n = kwargs["i18n"] with i18n.use_locale(user.lang): await route_callback(message, **kwargs) - - -from ..navigation import pop_navigation_context, save_navigation_context \ No newline at end of file diff --git a/bot/handlers/menu/main.py b/bot/handlers/menu/main.py index 1f3e973..5ef2028 100644 --- a/bot/handlers/menu/main.py +++ b/bot/handlers/menu/main.py @@ -1,93 +1,88 @@ from aiogram import Router, F -from aiogram.filters import Command -from aiogram.fsm.context import FSMContext from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder from logging import getLogger -from sqlmodel.ext.asyncio.session import AsyncSession from ....model.settings import Settings -from ....model.user import UserBase from ..context import ContextData, CallbackCommand -from ..common import get_send_message +from ....utils.main import get_send_message +from ..navigation import save_navigation_context, pop_navigation_context + +import qbot.bot.handlers.menu.entities as entities +import qbot.bot.handlers.menu.settings as settings +import qbot.bot.handlers.menu.parameters as parameters +import qbot.bot.handlers.menu.language as language +import qbot.bot.handlers.editors.main as editor +import qbot.bot.handlers.editors.main_callbacks as editor_callbacks +import qbot.bot.handlers.forms.entity_list as entity_list +import qbot.bot.handlers.forms.entity_form as entity_form +import qbot.bot.handlers.forms.entity_form_callbacks as entity_form_callbacks +import qbot.bot.handlers.common.filtering_callbacks as filtering_callbacks +import qbot.bot.handlers.user_handlers as user_handlers logger = getLogger(__name__) router = Router() -# @router.message(Command("menu")) -# async def command_menu(message: Message, **kwargs): - -# await clear_state(state = kwargs["state"], clear_nav = True) -# callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_MAIN) -# stack = await save_navigation_context(callback_data = callback_data, state = kwargs["state"]) -# kwargs.update({"navigation_stack": stack, "callback_data": callback_data}) - -# await main_menu(message, **kwargs) - - -# @router.callback_query(CallbackData.filter(F.command == CallbackCommand.MENU_ENTRY)) -# async def menu_entry(query: CallbackQuery, callback_data: CallbackData, user: UserBase, db_session: AsyncSession, app: QBotApp): - -# pass - - @router.callback_query(ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_MAIN)) -async def menu_entry_main(message: CallbackQuery, **kwargs): +async def menu_entry_main(message: CallbackQuery, **kwargs): + stack = await save_navigation_context( + callback_data=kwargs["callback_data"], state=kwargs["state"] + ) - stack = await save_navigation_context(callback_data = kwargs["callback_data"], state = kwargs["state"]) - - await main_menu(message, navigation_stack = stack, **kwargs) + await main_menu(message, navigation_stack=stack, **kwargs) -async def main_menu(message: Message | CallbackQuery, navigation_stack: list[ContextData], **kwargs): - +async def main_menu( + message: Message | CallbackQuery, navigation_stack: list[ContextData], **kwargs +): keyboard_builder = InlineKeyboardBuilder() - - keyboard_builder.row( - InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_REFERENCES_BTN)), - callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_ENTITIES).pack())) - keyboard_builder.row( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_SETTINGS_BTN)), - callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_SETTINGS).pack())) - + text=(await Settings.get(Settings.APP_STRINGS_REFERENCES_BTN)), + callback_data=ContextData( + command=CallbackCommand.MENU_ENTRY_ENTITIES + ).pack(), + ) + ) + + keyboard_builder.row( + InlineKeyboardButton( + text=(await Settings.get(Settings.APP_STRINGS_SETTINGS_BTN)), + callback_data=ContextData( + command=CallbackCommand.MENU_ENTRY_SETTINGS + ).pack(), + ) + ) + context = pop_navigation_context(navigation_stack) if context: keyboard_builder.row( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), - callback_data = context.pack())) - + text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)), + callback_data=context.pack(), + ) + ) + send_message = get_send_message(message) - await send_message(text = (await Settings.get(Settings.APP_STRINGS_MAIN_NENU)), - reply_markup = keyboard_builder.as_markup()) + await send_message( + text=(await Settings.get(Settings.APP_STRINGS_MAIN_NENU)), + reply_markup=keyboard_builder.as_markup(), + ) -from .entities import router as entities_router -from .settings import router as settings_router -from .parameters import router as parameters_router -from .language import router as language_router -from ..editors import router as editors_router -from ..forms.entity_list import router as entity_list_router -from ..forms.entity_form import router as entity_form_router -from ..common import router as common_router -from ..user_handlers import router as user_handlers_router - router.include_routers( - entities_router, - settings_router, - parameters_router, - language_router, - editors_router, - entity_list_router, - entity_form_router, - common_router, - user_handlers_router + entities.router, + settings.router, + parameters.router, + language.router, + editor.router, + editor_callbacks.router, + entity_list.router, + entity_form.router, + entity_form_callbacks.router, + filtering_callbacks.router, + user_handlers.router, ) - -from ..navigation import save_navigation_context, pop_navigation_context, clear_state \ No newline at end of file diff --git a/bot/handlers/menu/parameters.py b/bot/handlers/menu/parameters.py index c5d77de..f19453e 100644 --- a/bot/handlers/menu/parameters.py +++ b/bot/handlers/menu/parameters.py @@ -1,79 +1,94 @@ from aiogram import Router, F -from aiogram.filters import Command from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import StatesGroup, State from aiogram.types import Message, CallbackQuery, InlineKeyboardButton -from aiogram.utils.i18n import I18n from aiogram.utils.keyboard import InlineKeyboardBuilder from logging import getLogger -from sqlmodel.ext.asyncio.session import AsyncSession from ....model.settings import Settings from ....model.user import UserBase from ..context import ContextData, CallbackCommand, CommandContext - +from ....utils.main import ( + get_send_message, + clear_state, + get_value_repr, + get_callable_str, +) from ..navigation import save_navigation_context, pop_navigation_context +from ....auth import authorize_command logger = getLogger(__name__) router = Router() -@router.callback_query(ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_PARAMETERS)) +@router.callback_query( + ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_PARAMETERS) +) async def menu_entry_parameters(message: CallbackQuery, **kwargs): - callback_data: ContextData = kwargs["callback_data"] state: FSMContext = kwargs["state"] state_data = await state.get_data() kwargs["state_data"] = state_data - clear_state(state_data = state_data) - stack = save_navigation_context(callback_data = callback_data, state_data = state_data) + clear_state(state_data=state_data) + stack = save_navigation_context(callback_data=callback_data, state_data=state_data) - await parameters_menu(message = message, navigation_stack = stack, **kwargs) + await parameters_menu(message=message, navigation_stack=stack, **kwargs) -async def parameters_menu(message: Message | CallbackQuery, - user: UserBase, - callback_data: ContextData, - navigation_stack: list[ContextData], - **kwargs): - - if not await authorize_command(user = user, callback_data = callback_data): +async def parameters_menu( + message: Message | CallbackQuery, + user: UserBase, + callback_data: ContextData, + navigation_stack: list[ContextData], + **kwargs, +): + if not await authorize_command(user=user, callback_data=callback_data): await message.answer(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) settings = await Settings.get_params() - + keyboard_builder = InlineKeyboardBuilder() for key, value in settings.items(): - if not key.is_visible: continue if key.caption_value: - caption = get_callable_str(callable_str = key.caption_value, descriptor = key, entity = None, value = value) + caption = get_callable_str( + callable_str=key.caption_value, descriptor=key, entity=None, value=value + ) else: if key.caption: - caption = get_callable_str(callable_str = key.caption, descriptor = key, entity = None, value = value) + caption = get_callable_str( + callable_str=key.caption, descriptor=key, entity=None, value=value + ) else: - caption = key.name - - if key.type_ == bool: - caption = f"{"【✔︎】" if value else "【 】"} {caption}" - else: - caption = f"{caption}: {get_value_repr(value = value, field_descriptor = key, locale = user.lang)}" - + caption = key.name + + if key.type_ is bool: + caption = f"{'【✔︎】' if value else '【 】'} {caption}" + else: + caption = f"{caption}: {get_value_repr(value=value, field_descriptor=key, locale=user.lang)}" + + keyboard_builder.row( + InlineKeyboardButton( + text=caption, + callback_data=ContextData( + command=CallbackCommand.FIELD_EDITOR, + context=CommandContext.SETTING_EDIT, + field_name=key.name, + ).pack(), + ) + ) - keyboard_builder.row(InlineKeyboardButton(text = caption, - callback_data = ContextData( - command = CallbackCommand.FIELD_EDITOR, - context = CommandContext.SETTING_EDIT, - field_name = key.name).pack())) - context = pop_navigation_context(navigation_stack) if context: - keyboard_builder.row(InlineKeyboardButton(text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), - callback_data = context.pack())) + keyboard_builder.row( + InlineKeyboardButton( + text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)), + callback_data=context.pack(), + ) + ) state: FSMContext = kwargs["state"] state_data = kwargs["state_data"] @@ -81,8 +96,7 @@ async def parameters_menu(message: Message | CallbackQuery, send_message = get_send_message(message) - await send_message(text = (await Settings.get(Settings.APP_STRINGS_PARAMETERS)), reply_markup = keyboard_builder.as_markup()) - - -from ..navigation import pop_navigation_context, get_navigation_context, clear_state -from ..common import get_send_message, get_value_repr, get_callable_str, authorize_command \ No newline at end of file + await send_message( + text=(await Settings.get(Settings.APP_STRINGS_PARAMETERS)), + reply_markup=keyboard_builder.as_markup(), + ) diff --git a/bot/handlers/menu/settings.py b/bot/handlers/menu/settings.py index 3971698..96a445f 100644 --- a/bot/handlers/menu/settings.py +++ b/bot/handlers/menu/settings.py @@ -1,68 +1,80 @@ from aiogram import Router, F -from aiogram.filters import Command from aiogram.fsm.context import FSMContext from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder from logging import getLogger -from sqlmodel.ext.asyncio.session import AsyncSession from ....model.settings import Settings from ....model.user import UserBase +from ....utils.main import get_send_message from ..context import ContextData, CallbackCommand -from ..common import get_send_message, authorize_command +from ....auth import authorize_command from ..navigation import save_navigation_context, pop_navigation_context logger = getLogger(__name__) router = Router() -@router.callback_query(ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_SETTINGS)) +@router.callback_query( + ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_SETTINGS) +) async def menu_entry_settings(message: CallbackQuery, **kwargs): - callback_data: ContextData = kwargs["callback_data"] state: FSMContext = kwargs["state"] state_data = await state.get_data() kwargs["state_data"] = state_data - stack = save_navigation_context(callback_data = callback_data, state_data = state_data) + stack = save_navigation_context(callback_data=callback_data, state_data=state_data) - await settings_menu(message, navigation_stack = stack, **kwargs) + await settings_menu(message, navigation_stack=stack, **kwargs) -async def settings_menu(message: Message | CallbackQuery, - user: UserBase, - navigation_stack: list[ContextData], - **kwargs): - +async def settings_menu( + message: Message | CallbackQuery, + user: UserBase, + navigation_stack: list[ContextData], + **kwargs, +): keyboard_builder = InlineKeyboardBuilder() - - if await authorize_command(user = user, callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_PARAMETERS)): + + if await authorize_command( + user=user, + callback_data=ContextData(command=CallbackCommand.MENU_ENTRY_PARAMETERS), + ): keyboard_builder.row( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_PARAMETERS_BTN)), - callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_PARAMETERS).pack())) - + text=(await Settings.get(Settings.APP_STRINGS_PARAMETERS_BTN)), + callback_data=ContextData( + command=CallbackCommand.MENU_ENTRY_PARAMETERS + ).pack(), + ) + ) + keyboard_builder.row( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_LANGUAGE_BTN)), - callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_LANGUAGE).pack())) - + text=(await Settings.get(Settings.APP_STRINGS_LANGUAGE_BTN)), + callback_data=ContextData( + command=CallbackCommand.MENU_ENTRY_LANGUAGE + ).pack(), + ) + ) + context = pop_navigation_context(navigation_stack) if context: keyboard_builder.row( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), - callback_data = context.pack())) - + text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)), + callback_data=context.pack(), + ) + ) + state: FSMContext = kwargs["state"] state_data = kwargs["state_data"] await state.set_data(state_data) - + send_message = get_send_message(message) - - - await send_message(text = (await Settings.get(Settings.APP_STRINGS_SETTINGS)), reply_markup = keyboard_builder.as_markup()) - - -from ..navigation import pop_navigation_context, get_navigation_context \ No newline at end of file + await send_message( + text=(await Settings.get(Settings.APP_STRINGS_SETTINGS)), + reply_markup=keyboard_builder.as_markup(), + ) diff --git a/bot/handlers/navigation.py b/bot/handlers/navigation.py index 36193f9..b722a1f 100644 --- a/bot/handlers/navigation.py +++ b/bot/handlers/navigation.py @@ -1,21 +1,25 @@ -from aiogram.fsm.context import FSMContext -from aiogram.types import Message, CallbackQuery - from .context import ContextData, CallbackCommand -def save_navigation_context(callback_data: ContextData, state_data: dict) -> list[ContextData]: - stack = [ContextData.unpack(item) for item in state_data.get("navigation_stack", [])] +def save_navigation_context( + callback_data: ContextData, state_data: dict +) -> list[ContextData]: + stack = [ + ContextData.unpack(item) for item in state_data.get("navigation_stack", []) + ] data_nc = state_data.get("navigation_context") navigation_context = ContextData.unpack(data_nc) if data_nc else None if callback_data.back: - callback_data.back = False + callback_data.back = False if stack: stack.pop() else: - if (stack and navigation_context and - navigation_context.command == callback_data.command and - navigation_context.command != CallbackCommand.USER_COMMAND): + if ( + stack + and navigation_context + and navigation_context.command == callback_data.command + and navigation_context.command != CallbackCommand.USER_COMMAND + ): navigation_context = callback_data elif navigation_context: stack.append(navigation_context) @@ -31,65 +35,14 @@ def pop_navigation_context(stack: list[ContextData]) -> ContextData | None: data = stack[-1] data.back = True return data - -def get_navigation_context(state_data: dict) -> tuple[list[ContextData], ContextData | None]: + +def get_navigation_context( + state_data: dict, +) -> tuple[list[ContextData], ContextData | None]: data_nc = state_data.get("navigation_context") context = ContextData.unpack(data_nc) if data_nc else None - return ([ContextData.unpack(item) for item in state_data.get("navigation_stack", [])], - context) - - -def clear_state(state_data: dict, clear_nav: bool = False): - if clear_nav: - state_data.clear() - else: - stack = state_data.get("navigation_stack") - context = state_data.get("navigation_context") - state_data.clear() - if stack: - state_data["navigation_stack"] = stack - if context: - state_data["navigation_context"] = context - - -async def route_callback(message: Message | CallbackQuery, back: bool = True, **kwargs): - - state_data = kwargs["state_data"] - stack, context = get_navigation_context(state_data) - if back: - context = pop_navigation_context(stack) - stack = save_navigation_context(callback_data = context, state_data = state_data) - kwargs.update({"callback_data": context, "navigation_stack": stack}) - if context: - if context.command == CallbackCommand.MENU_ENTRY_MAIN: - await main_menu(message, **kwargs) - elif context.command == CallbackCommand.MENU_ENTRY_SETTINGS: - await settings_menu(message, **kwargs) - elif context.command == CallbackCommand.MENU_ENTRY_PARAMETERS: - await parameters_menu(message, **kwargs) - elif context.command == CallbackCommand.MENU_ENTRY_LANGUAGE: - await language_menu(message, **kwargs) - elif context.command == CallbackCommand.MENU_ENTRY_ENTITIES: - await entities_menu(message, **kwargs) - elif context.command == CallbackCommand.ENTITY_LIST: - await entity_list(message, **kwargs) - elif context.command == CallbackCommand.ENTITY_ITEM: - await entity_item(message, **kwargs) - elif context.command == CallbackCommand.FIELD_EDITOR: - await field_editor(message, **kwargs) - - else: - raise ValueError(f"Unknown command {context.command}") - else: - raise ValueError("No navigation context") - - -from .menu.main import main_menu -from .menu.settings import settings_menu -from .menu.parameters import parameters_menu -from .menu.language import language_menu -from .menu.entities import entities_menu -from .forms.entity_list import entity_list -from .forms.entity_form import entity_item -from .editors import field_editor \ No newline at end of file + return ( + [ContextData.unpack(item) for item in state_data.get("navigation_stack", [])], + context, + ) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 8d8464c..a5aa0cd 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -8,7 +8,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from ...main import QBotApp from ...model.settings import Settings from ...model.language import LanguageBase -from .navigation import clear_state +from ...utils.main import clear_state logger = getLogger(__name__) @@ -16,40 +16,56 @@ router = Router() @router.message(CommandStart()) -async def start(message: Message, db_session: AsyncSession, app: QBotApp, state: FSMContext): - +async def start( + message: Message, db_session: AsyncSession, app: QBotApp, state: FSMContext +): state_data = await state.get_data() - clear_state(state_data = state_data, clear_nav = True) + clear_state(state_data=state_data, clear_nav=True) User = app.user_class - user = await User.get(session = db_session, id = message.from_user.id) - + user = await User.get(session=db_session, id=message.from_user.id) + if not user: - msg_text = (await Settings.get(Settings.APP_STRINGS_WELCOME_P_NAME)).format(name = message.from_user.full_name) - + msg_text = (await Settings.get(Settings.APP_STRINGS_WELCOME_P_NAME)).format( + name=message.from_user.full_name + ) + try: - if message.from_user.language_code in [item.value for item in LanguageBase.all_members.values()]: + if message.from_user.language_code in [ + item.value for item in LanguageBase.all_members.values() + ]: lang = LanguageBase(message.from_user.language_code) - user = await User.create(session = db_session, - obj_in = User( - id = message.from_user.id, - name = message.from_user.full_name, - lang = lang, - is_active = True), - commit = True) + + user = await User.create( + session=db_session, + obj_in=User( + id=message.from_user.id, + name=message.from_user.full_name, + lang=lang, + is_active=True, + ), + commit=True, + ) + except Exception as e: - - logger.error("Error creating user", exc_info = True) - message.answer((await Settings.get(Settings.APP_STRINGS_INTERNAL_ERROR_P_ERROR)).format(error = str(e))) + logger.error("Error creating user", exc_info=True) + message.answer( + ( + await Settings.get(Settings.APP_STRINGS_INTERNAL_ERROR_P_ERROR) + ).format(error=str(e)) + ) return - + else: if user.is_active: - msg_text = (await Settings.get(Settings.APP_STRINGS_GREETING_P_NAME)).format(name = user.name) + msg_text = ( + await Settings.get(Settings.APP_STRINGS_GREETING_P_NAME) + ).format(name=user.name) else: - msg_text = (await Settings.get(Settings.APP_STRINGS_USER_BLOCKED_P_NAME)).format(name = user.name) - + msg_text = ( + await Settings.get(Settings.APP_STRINGS_USER_BLOCKED_P_NAME) + ).format(name=user.name) + await message.answer(msg_text) - diff --git a/bot/handlers/user_handlers/__init__.py b/bot/handlers/user_handlers/__init__.py index 38afa98..a6af36f 100644 --- a/bot/handlers/user_handlers/__init__.py +++ b/bot/handlers/user_handlers/__init__.py @@ -1,14 +1,16 @@ -from dataclasses import dataclass, field -from typing import Any, Callable, TYPE_CHECKING +from typing import TYPE_CHECKING from aiogram import Router, F from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.fsm.context import FSMContext -from aiogram.utils.i18n import I18n -from aiogram.utils.keyboard import InlineKeyboardBuilder -from sqlmodel.ext.asyncio.session import AsyncSession from ..context import ContextData, CallbackCommand -from ....model.user import UserBase from ....model.settings import Settings +from ....utils.main import get_send_message, clear_state +from ....model.descriptors import CommandCallbackContext +from ..navigation import ( + save_navigation_context, + get_navigation_context, + pop_navigation_context, +) if TYPE_CHECKING: @@ -18,51 +20,22 @@ if TYPE_CHECKING: router = Router() -@dataclass(kw_only = True) -class CommandCallbackContext[UT: UserBase]: - - keyboard_builder: InlineKeyboardBuilder = field(default_factory = InlineKeyboardBuilder) - message_text: str | None = None - register_navigation: bool = True - message: Message | CallbackQuery - callback_data: ContextData - db_session: AsyncSession - user: UT - app: "QBotApp" - state_data: dict[str, Any] - state: FSMContext - i18n: I18n - kwargs: dict[str, Any] = field(default_factory = dict) - - -@dataclass(kw_only = True) -class Command: - - name: str - handler: Callable[[CommandCallbackContext], None] - caption: str | dict[str, str] | None = None - register_navigation: bool = True - clear_navigation: bool = False - clear_state: bool = True - @router.message(F.text.startswith("/")) async def command_text(message: Message, **kwargs): - str_command = message.text.lstrip("/") - callback_data = ContextData(command = CallbackCommand.USER_COMMAND, - user_command = str_command) + callback_data = ContextData( + command=CallbackCommand.USER_COMMAND, user_command=str_command + ) - await command_handler(message = message, callback_data = callback_data, **kwargs) + await command_handler(message=message, callback_data=callback_data, **kwargs) @router.callback_query(ContextData.filter(F.command == CallbackCommand.USER_COMMAND)) async def command_callback(message: CallbackQuery, **kwargs): - - await command_handler(message = message, **kwargs) + await command_handler(message=message, **kwargs) async def command_handler(message: Message | CallbackQuery, **kwargs): - callback_data: ContextData = kwargs.pop("callback_data") str_command = callback_data.user_command app: "QBotApp" = kwargs.pop("app") @@ -70,45 +43,48 @@ async def command_handler(message: Message | CallbackQuery, **kwargs): if not command: return - + state: FSMContext = kwargs.pop("state") state_data = await state.get_data() - + if command.register_navigation: - clear_state(state_data = state_data) + clear_state(state_data=state_data) if command.clear_navigation: state_data.pop("navigation_stack", None) state_data.pop("navigation_context", None) if command.register_navigation: - stack = save_navigation_context(callback_data = callback_data, state_data = state_data) - + stack = save_navigation_context( + callback_data=callback_data, state_data=state_data + ) + callback_context = CommandCallbackContext[app.user_class]( - message = message, - callback_data = callback_data, - db_session = kwargs.pop("db_session"), - user = kwargs.pop("user"), - app = app, - state_data = state_data, - state = state, - i18n = kwargs.pop("i18n"), - kwargs = kwargs) + message=message, + callback_data=callback_data, + db_session=kwargs.pop("db_session"), + user=kwargs.pop("user"), + app=app, + state_data=state_data, + state=state, + i18n=kwargs.pop("i18n"), + kwargs=kwargs, + ) await command.handler(callback_context) await state.set_data(state_data) if command.register_navigation: - - stack, navigation_context = get_navigation_context(state_data = state_data) - back_callback_data = pop_navigation_context(stack = stack) + stack, navigation_context = get_navigation_context(state_data=state_data) + back_callback_data = pop_navigation_context(stack=stack) if back_callback_data: - callback_context.keyboard_builder.row( InlineKeyboardButton( - text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), - callback_data = back_callback_data.pack())) + text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)), + callback_data=back_callback_data.pack(), + ) + ) send_message = get_send_message(message) @@ -116,10 +92,11 @@ async def command_handler(message: Message | CallbackQuery, **kwargs): message = message.message if callback_context.message_text: - await send_message(text = callback_context.message_text, - reply_markup = callback_context.keyboard_builder.as_markup()) + await send_message( + text=callback_context.message_text, + reply_markup=callback_context.keyboard_builder.as_markup(), + ) else: - await message.edit_reply_markup(reply_markup = callback_context.keyboard_builder.as_markup()) - -from ..common import get_send_message -from ..navigation import save_navigation_context, get_navigation_context, clear_state, pop_navigation_context \ No newline at end of file + await message.edit_reply_markup( + reply_markup=callback_context.keyboard_builder.as_markup() + ) diff --git a/config/__init__.py b/config/__init__.py index 02e86ed..66f2cf0 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,4 +1,3 @@ -from babel.support import LazyProxy from pydantic import computed_field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from typing import Literal, Self @@ -6,11 +5,8 @@ import warnings class Config(BaseSettings): - model_config = SettingsConfigDict( - env_file = ".env", - env_ignore_empty = True, - extra = "ignore" + env_file=".env", env_ignore_empty=True, extra="ignore" ) SECRET_KEY: str = "changethis" @@ -35,22 +31,24 @@ class Config(BaseSettings): def API_DOMAIN(self) -> str: if self.ENVIRONMENT == "local": return self.DOMAIN - return f'api.{self.DOMAIN}' - - @computed_field + return f"api.{self.DOMAIN}" + + @computed_field @property def API_URL(self) -> str: if self.USE_NGROK: return self.NGROK_URL - return f"{"http" if self.ENVIRONMENT == "local" else "https"}://{self.API_DOMAIN}" - + return ( + f"{'http' if self.ENVIRONMENT == 'local' else 'https'}://{self.API_DOMAIN}" + ) + API_PORT: int = 8000 TELEGRAM_BOT_TOKEN: str = "changethis" ADMIN_TELEGRAM_ID: int - USE_NGROK : bool = False + USE_NGROK: bool = False NGROK_AUTH_TOKEN: str = "changethis" NGROK_URL: str = "" @@ -59,7 +57,7 @@ class Config(BaseSettings): def _check_default_secret(self, var_name: str, value: str | None) -> None: if value == "changethis": message = ( - f"The value of {var_name} is \"changethis\", " + f'The value of {var_name} is "changethis", ' "for security, please change it, at least for deployments." ) if self.ENVIRONMENT == "local": @@ -76,5 +74,6 @@ class Config(BaseSettings): self._check_default_secret("NGROK_AUTH_TOKEN", self.NGROK_AUTH_TOKEN) return self - -config = Config() \ No newline at end of file + + +config = Config() diff --git a/db/__init__.py b/db/__init__.py index aae659a..f2a556a 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -4,15 +4,16 @@ from sqlalchemy.orm import sessionmaker from ..config import config -import logging -logger = logging.getLogger('sqlalchemy.engine') -logger.setLevel(logging.DEBUG) +# import logging +# logger = logging.getLogger('sqlalchemy.engine') +# logger.setLevel(logging.DEBUG) async_engine = create_async_engine(config.DATABASE_URI) async_session = sessionmaker[AsyncSession]( - async_engine, class_=AsyncSession, expire_on_commit=False - ) + async_engine, class_=AsyncSession, expire_on_commit=False +) -async def get_db() -> AsyncSession: # type: ignore + +async def get_db() -> AsyncSession: # type: ignore async with async_session() as session: - yield session \ No newline at end of file + yield session diff --git a/fsm/db_storage.py b/fsm/db_storage.py index b614949..3e90b08 100644 --- a/fsm/db_storage.py +++ b/fsm/db_storage.py @@ -1,5 +1,11 @@ from aiogram.fsm.state import State -from aiogram.fsm.storage.base import BaseStorage, StorageKey, StateType, DefaultKeyBuilder, KeyBuilder +from aiogram.fsm.storage.base import ( + BaseStorage, + StorageKey, + StateType, + DefaultKeyBuilder, + KeyBuilder, +) from sqlmodel import select from typing import Any, Dict import ujson as json @@ -9,76 +15,70 @@ from ..model.fsm_storage import FSMStorage class DbStorage(BaseStorage): - - def __init__( - self, - key_builder: KeyBuilder | None = None) -> None: - + def __init__(self, key_builder: KeyBuilder | None = None) -> None: if key_builder is None: key_builder = DefaultKeyBuilder() self.key_builder = key_builder - async def set_state(self, key: StorageKey, state: StateType = None) -> None: - db_key = self.key_builder.build(key, "state") async with async_session() as session: - db_state = (await session.exec( - select(FSMStorage).where(FSMStorage.key == db_key))).first() - + db_state = ( + await session.exec(select(FSMStorage).where(FSMStorage.key == db_key)) + ).first() + if db_state: if state is None: await session.delete(db_state) else: db_state.value = state.state if isinstance(state, State) else state elif state is not None: - db_state = FSMStorage(key = db_key, value = state.state if isinstance(state, State) else state) + db_state = FSMStorage( + key=db_key, value=state.state if isinstance(state, State) else state + ) session.add(db_state) else: return - + await session.commit() - async def get_state(self, key: StorageKey) -> str | None: - db_key = self.key_builder.build(key, "state") async with async_session() as session: - db_state = (await session.exec( - select(FSMStorage).where(FSMStorage.key == db_key))).first() + db_state = ( + await session.exec(select(FSMStorage).where(FSMStorage.key == db_key)) + ).first() return db_state.value if db_state else None - async def set_data(self, key: StorageKey, data: Dict[str, Any]) -> None: - db_key = self.key_builder.build(key, "data") async with async_session() as session: - db_data = (await session.exec( - select(FSMStorage).where(FSMStorage.key == db_key))).first() - + db_data = ( + await session.exec(select(FSMStorage).where(FSMStorage.key == db_key)) + ).first() + if db_data: if not data: await session.delete(db_data) else: - db_data.value = json.dumps(data, ensure_ascii = False) + db_data.value = json.dumps(data, ensure_ascii=False) elif data: - db_data = FSMStorage(key = db_key, value = json.dumps(data, ensure_ascii = False)) + db_data = FSMStorage( + key=db_key, value=json.dumps(data, ensure_ascii=False) + ) session.add(db_data) else: return - + await session.commit() - async def get_data(self, key: StorageKey) -> Dict[str, Any]: - db_key = self.key_builder.build(key, "data") async with async_session() as session: - db_data = (await session.exec( - select(FSMStorage).where(FSMStorage.key == db_key))).first() + db_data = ( + await session.exec(select(FSMStorage).where(FSMStorage.key == db_key)) + ).first() return json.loads(db_data.value) if db_data else {} - async def close(self): return await super().close() - diff --git a/lifespan.py b/lifespan.py index 46950fb..1f73d96 100644 --- a/lifespan.py +++ b/lifespan.py @@ -5,57 +5,70 @@ from logging import getLogger logger = getLogger(__name__) + @asynccontextmanager async def default_lifespan(app: QBotApp): - logger.debug("starting qbot app") - + if app.config.USE_NGROK: try: from pyngrok import ngrok from pyngrok.conf import PyngrokConfig - + except ImportError: logger.error("pyngrok is not installed") raise - - tunnel = ngrok.connect(app.config.API_PORT, pyngrok_config = PyngrokConfig(auth_token = app.config.NGROK_AUTH_TOKEN)) + + tunnel = ngrok.connect( + app.config.API_PORT, + pyngrok_config=PyngrokConfig(auth_token=app.config.NGROK_AUTH_TOKEN), + ) app.config.NGROK_URL = tunnel.public_url commands_captions = dict[str, list[tuple[str, str]]]() for command_name, command in app.bot_commands.items(): - if isinstance(command.caption, str): - if "default" not in commands_captions: - commands_captions["default"] = [] - commands_captions["default"].append((command_name, command.caption)) - for locale, description in command.caption.items(): - if locale not in commands_captions: - commands_captions[locale] = [] - commands_captions[locale].append((command_name, description)) + if command.show_in_bot_commands: + if isinstance(command.caption, str) or command.caption is None: + if "default" not in commands_captions: + commands_captions["default"] = [] + commands_captions["default"].append( + (command_name, command.caption or command_name) + ) + else: + for locale, description in command.caption.items(): + if locale not in commands_captions: + commands_captions[locale] = [] + commands_captions[locale].append((command_name, description)) for locale, commands in commands_captions.items(): - await app.bot.set_my_commands([BotCommand(command = command[0], description=command[1]) for command in commands], - language_code = None if locale == "default" else locale) + await app.bot.set_my_commands( + [ + BotCommand(command=command[0], description=command[1]) + for command in commands + ], + language_code=None if locale == "default" else locale, + ) + + await app.bot.set_webhook( + url=f"{app.config.API_URL}/api/telegram/webhook", + drop_pending_updates=True, + allowed_updates=["message", "callback_query", "pre_checkout_query"], + secret_token=app.bot_auth_token, + ) - - await app.bot.set_webhook(url = f"{app.config.API_URL}/api/telegram/webhook", - drop_pending_updates = True, - allowed_updates = ['message', 'callback_query', 'pre_checkout_query'], - secret_token = app.bot_auth_token) - logger.info("qbot app started") - + if app.lifespan: async with app.lifespan(app): yield else: yield - + logger.info("stopping qbot app") await app.bot.delete_webhook() if app.config.USE_NGROK: ngrok.disconnect(app.config.NGROK_URL) ngrok.kill() - logger.info("qbot app stopped") \ No newline at end of file + logger.info("qbot app stopped") diff --git a/main.py b/main.py index 18e05c7..ff1bbe4 100644 --- a/main.py +++ b/main.py @@ -1,24 +1,21 @@ -from functools import wraps -from typing import Annotated, Callable, Any, Union, override +from typing import Annotated, Callable, Any from typing_extensions import Doc from aiogram import Bot, Dispatcher -from aiogram.filters import CommandStart from aiogram.client.default import DefaultBotProperties -from aiogram.types import Message, BotCommand -from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import Message from aiogram.utils.callback_answer import CallbackAnswerMiddleware from aiogram.utils.i18n import I18n from fastapi import FastAPI from fastapi.applications import Lifespan, AppType -from logging import getLogger from secrets import token_hex from .config import Config from .fsm.db_storage import DbStorage -from .middleware.telegram import AuthMiddleware, I18nMiddleware, ResetStateMiddleware +from .middleware.telegram import AuthMiddleware, I18nMiddleware from .model.user import UserBase from .model.entity_metadata import EntityMetadata -from .bot.handlers.user_handlers import Command, CommandCallbackContext +from .model.descriptors import BotCommand +from .router import Router class QBotApp(FastAPI): @@ -26,47 +23,67 @@ class QBotApp(FastAPI): Main class for the QBot application """ - def __init__[UserType: UserBase](self, - user_class: Annotated[type[UserType], Doc( - "User class that will be used in the application" - )] | None = None, - config: Config | None = None, - bot_start: Annotated[Callable[[Annotated[Callable[[Message, Any], None], Doc( - "Default handler for the start command" - )], Message, Any], None], Doc( - "Handler for the start command" - )] | None = None, - bot_commands: list[Command] | None = None, - lifespan: Lifespan[AppType] | None = None, - *args, - **kwargs): - + def __init__[UserType: UserBase]( + self, + user_class: ( + Annotated[ + type[UserType], Doc("User class that will be used in the application") + ] + | None + ) = None, + config: Config | None = None, + bot_start: ( + Annotated[ + Callable[ + [ + Annotated[ + Callable[[Message, Any], None], + Doc("Default handler for the start command"), + ], + Message, + Any, + ], + None, + ], + Doc("Handler for the start command"), + ] + | None + ) = None, + lifespan: Lifespan[AppType] | None = None, + *args, + **kwargs, + ): if config is None: config = Config() if user_class is None: from .model.default_user import DefaultUser + user_class = DefaultUser self.user_class = user_class self.entity_metadata: EntityMetadata = user_class.entity_metadata self.config = config self.lifespan = lifespan - self.bot = Bot(token = self.config.TELEGRAM_BOT_TOKEN, default = DefaultBotProperties(parse_mode = "HTML")) - - dp = Dispatcher(storage = DbStorage()) + self.bot = Bot( + token=self.config.TELEGRAM_BOT_TOKEN, + default=DefaultBotProperties(parse_mode="HTML"), + ) - i18n = I18n(path = "locales", default_locale = "en", domain = "messages") - i18n_middleware = I18nMiddleware(user_class = user_class, i18n = i18n) + dp = Dispatcher(storage=DbStorage()) + + i18n = I18n(path="locales", default_locale="en", domain="messages") + i18n_middleware = I18nMiddleware(user_class=user_class, i18n=i18n) i18n_middleware.setup(dp) - # dp.callback_query.middleware(ResetStateMiddleware()) dp.callback_query.middleware(CallbackAnswerMiddleware()) from .bot.handlers.start import router as start_router + dp.include_router(start_router) from .bot.handlers.menu.main import router as main_menu_router - auth = AuthMiddleware(user_class = user_class) + + auth = AuthMiddleware(user_class=user_class) main_menu_router.message.middleware.register(auth) main_menu_router.callback_query.middleware.register(auth) dp.include_router(main_menu_router) @@ -76,35 +93,17 @@ class QBotApp(FastAPI): self.bot_auth_token = token_hex(128) self.start_handler = bot_start - self.bot_commands = {c.name: c for c in bot_commands or []} + self.bot_commands = dict[str, BotCommand]() from .lifespan import default_lifespan - super().__init__(lifespan = default_lifespan, *args, **kwargs) + super().__init__(lifespan=default_lifespan, *args, **kwargs) from .api_route.telegram import router as telegram_router - self.include_router(telegram_router, prefix = "/api/telegram", tags = ["telegram"]) - @override - def bot_command(self, command: Command): ... + self.include_router(telegram_router, prefix="/api/telegram", tags=["telegram"]) - @override - def bot_command(self, command: str, caption: str | dict[str, str] | None = None): ... - - def bot_command(self, command: str | Command, caption: str | dict[str, str] | None = None): - """ - Decorator for registering bot commands - """ - - def decorator(func: Callable[[CommandCallbackContext], None]): - - if isinstance(command, str): - command = Command(name = command, handler = func, caption = caption) - self.bot_commands[command.name] = command - - wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - return wrapper - - return decorator \ No newline at end of file + def register_routers(self, *routers: Router): + for router in routers: + for command_name, command in router._commands.items(): + self.bot_commands[command_name] = command diff --git a/middleware/telegram/__init__.py b/middleware/telegram/__init__.py index e89704d..978b4d9 100644 --- a/middleware/telegram/__init__.py +++ b/middleware/telegram/__init__.py @@ -1,3 +1,2 @@ -from .auth import AuthMiddleware -from .i18n import I18nMiddleware -from .reset_state import ResetStateMiddleware \ No newline at end of file +from .auth import AuthMiddleware as AuthMiddleware +from .i18n import I18nMiddleware as I18nMiddleware diff --git a/middleware/telegram/auth.py b/middleware/telegram/auth.py index de8413f..89a23fa 100644 --- a/middleware/telegram/auth.py +++ b/middleware/telegram/auth.py @@ -1,6 +1,5 @@ from aiogram import BaseMiddleware from aiogram.types import TelegramObject, Message, CallbackQuery -from aiogram.utils.i18n import gettext as _ from babel.support import LazyProxy from typing import Any, Awaitable, Callable, Dict @@ -8,33 +7,31 @@ from ...model.user import UserBase class AuthMiddleware(BaseMiddleware): - - def __init__[UserType: UserBase](self, - user_class: type[UserType], - not_authenticated_msg: LazyProxy | str = "not authenticated", - not_active_msg: LazyProxy | str = "not active"): - + def __init__[UserType: UserBase]( + self, + user_class: type[UserType], + not_authenticated_msg: LazyProxy | str = "not authenticated", + not_active_msg: LazyProxy | str = "not active", + ): self.user_class = user_class self.not_authenticated_msg = not_authenticated_msg self.not_active_msg = not_active_msg - - async def __call__(self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], - event: TelegramObject, - data: Dict[str, Any]) -> Any: - - user = await self.user_class.get(id = event.from_user.id, session = data["db_session"]) - + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any], + ) -> Any: + user = await self.user_class.get( + id=event.from_user.id, session=data["db_session"] + ) + if user and user.is_active: data["user"] = user return await handler(event, data) - + if type(event) in [Message, CallbackQuery]: if user and not user.is_active: return await event.answer(self.not_active_msg) return await event.answer(self.not_authenticated_msg) - - - - \ No newline at end of file diff --git a/middleware/telegram/i18n.py b/middleware/telegram/i18n.py index 664e558..925f026 100644 --- a/middleware/telegram/i18n.py +++ b/middleware/telegram/i18n.py @@ -5,10 +5,6 @@ from ...model.user import UserBase class I18nMiddleware(SimpleI18nMiddleware): - """ - This middleware stores locale in the FSM storage - """ - def __init__[UserType: UserBase]( self, user_class: type[UserType], @@ -22,7 +18,7 @@ class I18nMiddleware(SimpleI18nMiddleware): async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str: db_session = data.get("db_session") if db_session and event.model_fields.get("from_user"): - user = await self.user_class.get(id = event.from_user.id, session = db_session) + user = await self.user_class.get(id=event.from_user.id, session=db_session) if user and user.lang: return user.lang - return await super().get_locale(event=event, data=data) \ No newline at end of file + return await super().get_locale(event=event, data=data) diff --git a/middleware/telegram/reset_state.py b/middleware/telegram/reset_state.py index a1c9709..8801111 100644 --- a/middleware/telegram/reset_state.py +++ b/middleware/telegram/reset_state.py @@ -1,30 +1,26 @@ -from typing import Any, Awaitable, Callable, Dict -from aiogram import BaseMiddleware -from aiogram.types import TelegramObject -from aiogram.fsm.context import FSMContext -from aiogram.utils.i18n import gettext as _ +# from typing import Any, Awaitable, Callable, Dict +# from aiogram import BaseMiddleware +# from aiogram.types import TelegramObject +# from aiogram.fsm.context import FSMContext +# from aiogram.utils.i18n import gettext as _ -from ...bot.handlers.context import ContextData +# from ...bot.handlers.context import ContextData -class ResetStateMiddleware(BaseMiddleware): - async def __call__(self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], - event: TelegramObject, - data: Dict[str, Any]) -> Any: - - save_state = False - callback_data = data.get("callback_data") - if isinstance(callback_data, ContextData): - save_state = callback_data.save_state +# class ResetStateMiddleware(BaseMiddleware): +# async def __call__(self, +# handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], +# event: TelegramObject, +# data: Dict[str, Any]) -> Any: - if not save_state: - state = data.get("state") - if isinstance(state, FSMContext): - await state.clear() - - return await handler(event, data) - - +# save_state = False +# callback_data = data.get("callback_data") +# if isinstance(callback_data, ContextData): +# save_state = callback_data.save_state - \ No newline at end of file +# if not save_state: +# state = data.get("state") +# if isinstance(state, FSMContext): +# await state.clear() + +# return await handler(event, data) diff --git a/model/__init__.py b/model/__init__.py index 68f59c5..50ee14d 100644 --- a/model/__init__.py +++ b/model/__init__.py @@ -7,9 +7,7 @@ from .bot_enum import BotEnum, EnumMember from ..db import async_session - class EntityPermission(BotEnum): - LIST = EnumMember("list") READ = EnumMember("read") CREATE = EnumMember("create") @@ -23,24 +21,23 @@ class EntityPermission(BotEnum): def session_dep(func): - @wraps(func) - async def wrapper(cls, *args, **kwargs): - - if "session" in kwargs and kwargs["session"]: - return await func(cls, *args, **kwargs) + @wraps(func) + async def wrapper(cls, *args, **kwargs): + if "session" in kwargs and kwargs["session"]: + return await func(cls, *args, **kwargs) - _session = None + _session = None - state = cast(InstanceState, inspect(cls)) - if hasattr(state, "async_session"): - _session = state.async_session - - if not _session: - async with async_session() as session: - kwargs["session"] = session - return await func(cls, *args, **kwargs) - else: - kwargs["session"] = _session + state = cast(InstanceState, inspect(cls)) + if hasattr(state, "async_session"): + _session = state.async_session + + if not _session: + async with async_session() as session: + kwargs["session"] = session return await func(cls, *args, **kwargs) - - return wrapper \ No newline at end of file + else: + kwargs["session"] = _session + return await func(cls, *args, **kwargs) + + return wrapper diff --git a/model/_singleton.py b/model/_singleton.py index 8a09d95..3776cb9 100644 --- a/model/_singleton.py +++ b/model/_singleton.py @@ -1,6 +1,7 @@ class Singleton(type): _instances = {} + def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) - return cls._instances[cls] \ No newline at end of file + return cls._instances[cls] diff --git a/model/bot_entity.py b/model/bot_entity.py index 16ccc4d..c11feb7 100644 --- a/model/bot_entity.py +++ b/model/bot_entity.py @@ -1,33 +1,49 @@ -from functools import wraps from types import NoneType, UnionType -from typing import ClassVar, ForwardRef, Optional, Union, cast, get_args, get_origin +from typing import ( + Any, + ClassVar, + ForwardRef, + Optional, + Self, + Union, + get_args, + get_origin, + TYPE_CHECKING, +) from pydantic import BaseModel from sqlmodel import SQLModel, BIGINT, Field, select, func, column + from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel.sql.expression import SelectOfScalar from sqlmodel.main import SQLModelMetaclass, RelationshipInfo -from .descriptors import EntityDescriptor, EntityField, EntityFieldDescriptor +from .descriptors import EntityDescriptor, EntityField, EntityFieldDescriptor, Filter from .entity_metadata import EntityMetadata from . import session_dep +if TYPE_CHECKING: + from .user import UserBase + class BotEntityMetaclass(SQLModelMetaclass): - __future_references__ = {} def __new__(mcs, name, bases, namespace, **kwargs): - bot_fields_descriptors = {} - + if bases: - bot_entity_descriptor = bases[0].__dict__.get('bot_entity_descriptor') - bot_fields_descriptors = {key: EntityFieldDescriptor(**value.__dict__.copy()) - for key, value in bot_entity_descriptor.fields_descriptors.items()} if bot_entity_descriptor else {} - - if '__annotations__' in namespace: - - for annotation in namespace['__annotations__']: + bot_entity_descriptor = bases[0].__dict__.get("bot_entity_descriptor") + bot_fields_descriptors = ( + { + key: EntityFieldDescriptor(**value.__dict__.copy()) + for key, value in bot_entity_descriptor.fields_descriptors.items() + } + if bot_entity_descriptor + else {} + ) + if "__annotations__" in namespace: + for annotation in namespace["__annotations__"]: if annotation in ["bot_entity_descriptor", "entity_metadata"]: continue @@ -36,41 +52,43 @@ class BotEntityMetaclass(SQLModelMetaclass): if isinstance(attribute_value, RelationshipInfo): continue - descriptor_kwargs = {} + descriptor_kwargs = {} descriptor_name = annotation if attribute_value: - if isinstance(attribute_value, EntityField): descriptor_kwargs = attribute_value.__dict__.copy() sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None) - + if sm_descriptor: namespace[annotation] = sm_descriptor else: - namespace.pop(annotation) + namespace.pop(annotation) descriptor_name = descriptor_kwargs.pop("name") or annotation - type_ = namespace['__annotations__'][annotation] - + type_ = namespace["__annotations__"][annotation] + type_origin = get_origin(type_) field_descriptor = EntityFieldDescriptor( - name = descriptor_name, - field_name = annotation, - type_ = type_, - type_base = type_, - **descriptor_kwargs) + name=descriptor_name, + field_name=annotation, + type_=type_, + type_base=type_, + **descriptor_kwargs, + ) is_list = False - if type_origin == list: + if type_origin is list: field_descriptor.is_list = is_list = True field_descriptor.type_base = type_ = get_args(type_)[0] if type_origin == Union and isinstance(get_args(type_)[0], ForwardRef): field_descriptor.is_optional = True - field_descriptor.type_base = type_ = get_args(type_)[0].__forward_arg__ + field_descriptor.type_base = type_ = get_args(type_)[ + 0 + ].__forward_arg__ if type_origin == UnionType and get_args(type_)[1] == NoneType: field_descriptor.is_optional = True @@ -78,12 +96,26 @@ class BotEntityMetaclass(SQLModelMetaclass): if isinstance(type_, str): type_not_found = True - for entity_descriptor in EntityMetadata().entity_descriptors.values(): + for ( + entity_descriptor + ) in EntityMetadata().entity_descriptors.values(): if type_ == entity_descriptor.class_name: - field_descriptor.type_ = (list[entity_descriptor.type_] if is_list - else Optional[entity_descriptor.type_] if type_origin == Optional - else entity_descriptor.type_ | None if (type_origin == UnionType and get_args(type_)[1] == NoneType) - else entity_descriptor.type_) + field_descriptor.type_ = ( + list[entity_descriptor.type_] + if is_list + else ( + Optional[entity_descriptor.type_] + if type_origin == Optional + else ( + entity_descriptor.type_ | None + if ( + type_origin == UnionType + and get_args(type_)[1] == NoneType + ) + else entity_descriptor.type_ + ) + ) + ) type_not_found = False break if type_not_found: @@ -91,7 +123,7 @@ class BotEntityMetaclass(SQLModelMetaclass): mcs.__future_references__[type_].append(field_descriptor) else: mcs.__future_references__[type_] = [field_descriptor] - + bot_fields_descriptors[descriptor_name] = field_descriptor descriptor_name = name @@ -101,123 +133,246 @@ class BotEntityMetaclass(SQLModelMetaclass): descriptor_kwargs: dict = entity_descriptor.__dict__.copy() descriptor_name = descriptor_kwargs.pop("name", None) descriptor_name = descriptor_name or name.lower() - descriptor_fields_sequence = descriptor_kwargs.pop("field_sequence", None) - if not descriptor_fields_sequence: - descriptor_fields_sequence = list(bot_fields_descriptors.keys()) - descriptor_fields_sequence.remove("id") namespace["bot_entity_descriptor"] = EntityDescriptor( - name = descriptor_name, - class_name = name, - type_ = name, - fields_descriptors = bot_fields_descriptors, - field_sequence = descriptor_fields_sequence, - **descriptor_kwargs) + name=descriptor_name, + class_name=name, + type_=name, + fields_descriptors=bot_fields_descriptors, + **descriptor_kwargs, + ) else: - descriptor_fields_sequence = list(bot_fields_descriptors.keys()) - descriptor_fields_sequence.remove("id") descriptor_name = name.lower() namespace["bot_entity_descriptor"] = EntityDescriptor( - name = descriptor_name, - class_name = name, - type_ = name, - fields_descriptors = bot_fields_descriptors, - field_sequence = descriptor_fields_sequence) - + name=descriptor_name, + class_name=name, + type_=name, + fields_descriptors=bot_fields_descriptors, + ) + + descriptor_fields_sequence = [ + key + for key, val in bot_fields_descriptors.items() + if not (val.is_optional or val.name == "id") + ] + + entity_descriptor: EntityDescriptor = namespace["bot_entity_descriptor"] + + if entity_descriptor.default_form.edit_field_sequence is None: + entity_descriptor.default_form.edit_field_sequence = ( + descriptor_fields_sequence + ) + + for form in entity_descriptor.forms.values(): + if form.edit_field_sequence is None: + form.edit_field_sequence = descriptor_fields_sequence + for field_descriptor in bot_fields_descriptors.values(): field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"] if "table" not in kwargs: kwargs["table"] = True - if kwargs["table"] == True: + if kwargs["table"]: entity_metadata = EntityMetadata() - entity_metadata.entity_descriptors[descriptor_name] = namespace["bot_entity_descriptor"] - + entity_metadata.entity_descriptors[descriptor_name] = namespace[ + "bot_entity_descriptor" + ] + if "__annotations__" in namespace: - namespace["__annotations__"]["entity_metadata"] = ClassVar[EntityMetadata] + namespace["__annotations__"]["entity_metadata"] = ClassVar[ + EntityMetadata + ] else: - namespace["__annotations__"] = {"entity_metadata": ClassVar[EntityMetadata]} - + namespace["__annotations__"] = { + "entity_metadata": ClassVar[EntityMetadata] + } + namespace["entity_metadata"] = entity_metadata - + type_ = super().__new__(mcs, name, bases, namespace, **kwargs) if name in mcs.__future_references__: for field_descriptor in mcs.__future_references__[name]: type_origin = get_origin(field_descriptor.type_) - field_descriptor.type_base = type_ - field_descriptor.type_ = (list[type_] if get_origin(field_descriptor.type_) == list else - Optional[type_] if type_origin == Union and isinstance(get_args(field_descriptor.type_)[0], ForwardRef) else - type_ | None if type_origin == UnionType else - type_) + field_descriptor.type_base = type_ + + field_descriptor.type_ = ( + list[type_] + if get_origin(field_descriptor.type_) is list + else ( + Optional[type_] + if type_origin == Union + and isinstance(get_args(field_descriptor.type_)[0], ForwardRef) + else type_ | None + if type_origin == UnionType + else type_ + ) + ) setattr(namespace["bot_entity_descriptor"], "type_", type_) return type_ - -class BotEntity[CreateSchemaType: BaseModel, - UpdateSchemaType: BaseModel](SQLModel, - metaclass = BotEntityMetaclass, - table = False): - + +class BotEntity[CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel]( + SQLModel, metaclass=BotEntityMetaclass, table=False +): bot_entity_descriptor: ClassVar[EntityDescriptor] entity_metadata: ClassVar[EntityMetadata] - id: int = Field( - primary_key = True, - sa_type = BIGINT) - - name: str - + id: int = Field(primary_key=True, sa_type=BIGINT) @classmethod @session_dep - async def get(cls, *, - session: AsyncSession | None = None, - id: int): - - return await session.get(cls, id, populate_existing = True) - + async def get(cls, *, session: AsyncSession | None = None, id: int): + return await session.get(cls, id, populate_existing=True) + + @classmethod + def _static_fiter_condition( + cls, select_statement: SelectOfScalar[Self], static_filter: list[Filter] + ): + for sfilt in static_filter: + if sfilt.operator == "==": + condition = column(sfilt.field_name).__eq__(sfilt.value) + elif sfilt.operator == "!=": + condition = column(sfilt.field_name).__ne__(sfilt.value) + elif sfilt.operator == "<": + condition = column(sfilt.field_name).__lt__(sfilt.value) + elif sfilt.operator == "<=": + condition = column(sfilt.field_name).__le__(sfilt.value) + elif sfilt.operator == ">": + condition = column(sfilt.field_name).__gt__(sfilt.value) + elif sfilt.operator == ">=": + condition = column(sfilt.field_name).__ge__(sfilt.value) + elif sfilt.operator == "ilike": + condition = column(sfilt.field_name).ilike(f"%{sfilt.value}%") + elif sfilt.operator == "like": + condition = column(sfilt.field_name).like(f"%{sfilt.value}%") + elif sfilt.operator == "in": + condition = column(sfilt.field_name).in_(sfilt.value) + elif sfilt.operator == "not in": + condition = column(sfilt.field_name).notin_(sfilt.value) + elif sfilt.operator == "is": + condition = column(sfilt.field_name).is_(None) + elif sfilt.operator == "is not": + condition = column(sfilt.field_name).isnot(None) + else: + condition = None + if condition: + select_statement = select_statement.where(condition) + return select_statement + + @classmethod + def _filter_condition( + cls, + select_statement: SelectOfScalar[Self], + filter: str, + filter_fields: list[str], + ): + condition = None + for field in filter_fields: + if condition is not None: + condition = condition | (column(field).ilike(f"%{filter}%")) + else: + condition = column(field).ilike(f"%{filter}%") + return select_statement.where(condition) @classmethod @session_dep - async def get_count(cls, *, - session: AsyncSession | None = None, - filter: str = None) -> int: - + async def get_count( + cls, + *, + session: AsyncSession | None = None, + static_filter: list[Filter] | Any = None, + filter: str = None, + filter_fields: list[str] = None, + ext_filter: Any = None, + user: "UserBase" = None, + ) -> int: select_statement = select(func.count()).select_from(cls) - if filter: - select_statement = select_statement.where(column("name").ilike(f"%{filter}%")) + if static_filter: + if isinstance(static_filter, list): + select_statement = cls._static_fiter_condition( + select_statement, static_filter + ) + else: + select_statement = select_statement.where(static_filter) + if filter and filter_fields: + select_statement = cls._filter_condition( + select_statement, filter, filter_fields + ) + if ext_filter: + select_statement = select_statement.where(ext_filter) + if user: + select_statement = cls._ownership_condition(select_statement, user) return await session.scalar(select_statement) - - + @classmethod @session_dep - async def get_multi(cls, *, - session: AsyncSession | None = None, - order_by = None, - filter:str = None, - skip: int = 0, - limit: int = None): - + async def get_multi( + cls, + *, + session: AsyncSession | None = None, + order_by=None, + static_filter: list[Filter] | Any = None, + filter: str = None, + filter_fields: list[str] = None, + ext_filter: Any = None, + user: "UserBase" = None, + skip: int = 0, + limit: int = None, + ): select_statement = select(cls).offset(skip) if limit: select_statement = select_statement.limit(limit) - if filter: - select_statement = select_statement.where(column("name").ilike(f"%{filter}%")) + if static_filter: + if isinstance(static_filter, list): + select_statement = cls._static_fiter_condition( + select_statement, static_filter + ) + else: + select_statement = select_statement.where(static_filter) + if filter and filter_fields: + select_statement = cls._filter_condition( + select_statement, filter, filter_fields + ) + if ext_filter: + select_statement = select_statement.where(ext_filter) + if user: + select_statement = cls._ownership_condition(select_statement, user) if order_by: select_statement = select_statement.order_by(order_by) return (await session.exec(select_statement)).all() - + + @classmethod + def _ownership_condition( + cls, select_statement: SelectOfScalar[Self], user: "UserBase" + ): + if cls.bot_entity_descriptor.ownership_fields: + condition = None + for role in user.roles: + if role in cls.bot_entity_descriptor.ownership_fields: + owner_col = column(cls.bot_entity_descriptor.ownership_fields[role]) + if condition is not None: + condition = condition | (owner_col == user.id) + else: + condition = owner_col == user.id + else: + condition = None + break + if condition is not None: + return select_statement.where(condition) + return select_statement @classmethod @session_dep - async def create(cls, *, - session: AsyncSession | None = None, - obj_in: CreateSchemaType, - commit: bool = False): - + async def create( + cls, + *, + session: AsyncSession | None = None, + obj_in: CreateSchemaType, + commit: bool = False, + ): if isinstance(obj_in, cls): obj = obj_in else: @@ -226,16 +381,17 @@ class BotEntity[CreateSchemaType: BaseModel, if commit: await session.commit() return obj - - + @classmethod @session_dep - async def update(cls, *, - session: AsyncSession | None = None, - id: int, - obj_in: UpdateSchemaType, - commit: bool = False): - + async def update( + cls, + *, + session: AsyncSession | None = None, + id: int, + obj_in: UpdateSchemaType, + commit: bool = False, + ): obj = await session.get(cls, id) if obj: obj_data = obj.model_dump() @@ -248,14 +404,12 @@ class BotEntity[CreateSchemaType: BaseModel, await session.commit() return obj return None - - + @classmethod @session_dep - async def remove(cls, *, - session: AsyncSession | None = None, - id: int, - commit: bool = False): + async def remove( + cls, *, session: AsyncSession | None = None, id: int, commit: bool = False + ): obj = await session.get(cls, id) if obj: await session.delete(obj) @@ -263,4 +417,3 @@ class BotEntity[CreateSchemaType: BaseModel, await session.commit() return obj return None - diff --git a/model/bot_enum.py b/model/bot_enum.py index 274984b..a72b83e 100644 --- a/model/bot_enum.py +++ b/model/bot_enum.py @@ -4,36 +4,46 @@ from typing import Any, Self, overload class BotEnumMetaclass(type): - def __new__(cls, name: str, bases: tuple[type], namespace: dict[str, Any]): all_members = {} - if bases and bases[0].__name__ != "BotEnum" and "all_members" in bases[0].__dict__: + if ( + bases + and bases[0].__name__ != "BotEnum" + and "all_members" in bases[0].__dict__ + ): all_members = bases[0].__dict__["all_members"] - + annotations = {} for key, value in namespace.items(): - if (key.isupper() and - not key.startswith("__") and - not key.endswith("__")): - + if key.isupper() and not key.startswith("__") and not key.endswith("__"): if not isinstance(value, EnumMember): value = EnumMember(value, None) if key in all_members.keys() and all_members[key].value != value.value: - raise ValueError(f"Enum member {key} already exists with different value. Use same value to extend it.") - - if (value.value in [member.value for member in all_members.values()] and - key not in all_members.keys()): + raise ValueError( + f"Enum member {key} already exists with different value. Use same value to extend it." + ) + + if ( + value.value in [member.value for member in all_members.values()] + and key not in all_members.keys() + ): raise ValueError(f"Duplicate enum value {value[0]}") - - member = EnumMember(value = value.value, loc_obj = value.loc_obj, parent = None, name = key, casting = False) - + + member = EnumMember( + value=value.value, + loc_obj=value.loc_obj, + parent=None, + name=key, + casting=False, + ) + namespace[key] = member all_members[key] = member annotations[key] = type(member) - - namespace["__annotations__"] = annotations + + namespace["__annotations__"] = annotations namespace["all_members"] = all_members type_ = super().__new__(cls, name, bases, namespace) @@ -46,22 +56,23 @@ class BotEnumMetaclass(type): class EnumMember(object): + @overload + def __init__(self, value: str) -> "EnumMember": ... @overload - def __init__(self, value: str) -> "EnumMember":... + def __init__(self, value: "EnumMember") -> "EnumMember": ... @overload - def __init__(self, value: "EnumMember") -> "EnumMember":... + def __init__(self, value: str, loc_obj: dict[str, str]) -> "EnumMember": ... - @overload - def __init__(self, value: str, loc_obj: dict[str, str]) -> "EnumMember":... - - def __init__(self, - value: str = None, - loc_obj: dict[str, str] = None, - parent: type = None, - name: str = None, - casting: bool = True) -> "EnumMember": + def __init__( + self, + value: str = None, + loc_obj: dict[str, str] = None, + parent: type = None, + name: str = None, + casting: bool = True, + ) -> "EnumMember": if not casting: self._parent = parent self._name = name @@ -69,10 +80,9 @@ class EnumMember(object): self.loc_obj = loc_obj @overload - def __new__(cls: Self, *args, **kwargs) -> "EnumMember":... + def __new__(cls: Self, *args, **kwargs) -> "EnumMember": ... def __new__(cls, *args, casting: bool = True, **kwargs) -> "EnumMember": - if (cls.__name__ == "EnumMember") or not casting: obj = super().__new__(cls) kwargs["casting"] = False @@ -80,56 +90,59 @@ class EnumMember(object): return obj 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]] + if args.__len__() == 1 and isinstance(args[0], str): + return {member.value: member for key, member in cls.all_members.items()}[ + args[0] + ] elif args.__len__() == 1: - return {member.value: member for key, member in cls.all_members.items()}[args[0].value] + return {member.value: member for key, member in cls.all_members.items()}[ + args[0].value + ] else: return args[0] - + def __get_pydantic_core_schema__(cls, *args, **kwargs): return str_schema() - + def __get__(self, instance, owner) -> Self: - # return {member.value: member for key, member in owner.all_members.items()}[self.value] - return {member.value: member for key, member in self._parent.all_members.items()}[self.value] - + return { + member.value: member for key, member in self._parent.all_members.items() + }[self.value] + def __set__(self, instance, value): instance.__dict__[self] = value def __repr__(self): - return f"<{self._parent.__name__ if self._parent else "EnumMember"}.{self._name}: '{self.value}'>" - + return f"<{self._parent.__name__ if self._parent else 'EnumMember'}.{self._name}: '{self.value}'>" + def __str__(self): return self.value - - def __eq__(self, other : Self | str) -> bool: - if other is None: - return False - if isinstance(other, str): - return self.value == other - return self.value == other.value - + + def __eq__(self, other: Self | str) -> bool: + if other is None: + return False + if isinstance(other, str): + return self.value == other + return self.value == other.value + def __hash__(self): return hash(self.value) - + def localized(self, lang: str = None) -> str: if self.loc_obj and len(self.loc_obj) > 0: if lang and lang in self.loc_obj.keys(): return self.loc_obj[lang] else: return self.loc_obj[list(self.loc_obj.keys())[0]] - + return self.value - -class BotEnum(EnumMember, metaclass = BotEnumMetaclass): +class BotEnum(EnumMember, metaclass=BotEnumMetaclass): all_members: dict[str, EnumMember] class EnumType(TypeDecorator): - impl = String(256) def __init__(self, enum_type: BotEnum): @@ -140,8 +153,8 @@ class EnumType(TypeDecorator): if value and isinstance(value, EnumMember): return value.value return None - + def process_result_value(self, value, dialect): if value: return self._enum_type(value) - return None \ No newline at end of file + return None diff --git a/model/default_user.py b/model/default_user.py index a83b15d..453ae12 100644 --- a/model/default_user.py +++ b/model/default_user.py @@ -1,4 +1,4 @@ from .user import UserBase -class DefaultUser(UserBase): ... \ No newline at end of file +class DefaultUser(UserBase): ... diff --git a/model/descriptors.py b/model/descriptors.py index c9bc222..7e94e90 100644 --- a/model/descriptors.py +++ b/model/descriptors.py @@ -1,20 +1,85 @@ -from typing import Any, Callable, TYPE_CHECKING +from aiogram.types import Message, CallbackQuery +from aiogram.fsm.context import FSMContext +from aiogram.utils.i18n import I18n +from aiogram.utils.keyboard import InlineKeyboardBuilder +from typing import Any, Callable, TYPE_CHECKING, Literal from babel.support import LazyProxy from dataclasses import dataclass, field +from sqlmodel.ext.asyncio.session import AsyncSession from .role import RoleBase from . import EntityPermission +from ..bot.handlers.context import ContextData if TYPE_CHECKING: from .bot_entity import BotEntity + from ..main import QBotApp + from .user import UserBase EntityCaptionCallable = Callable[["EntityDescriptor"], str] EntityItemCaptionCallable = Callable[["EntityDescriptor", Any], str] EntityFieldCaptionCallable = Callable[["EntityFieldDescriptor", Any, Any], str] -@dataclass(kw_only = True) -class _BaseEntityFieldDescriptor(): +@dataclass +class FieldEditButton: + field_name: str + caption: str | LazyProxy | EntityFieldCaptionCallable | None = None + + +@dataclass +class CommandButton: + command: str + caption: str | LazyProxy | EntityItemCaptionCallable | None = None + context_data: ContextData | None = None + + +@dataclass +class Filter: + field_name: str + operator: Literal[ + "==", + "!=", + ">", + "<", + ">=", + "<=", + "in", + "not in", + "like", + "ilike", + "is", + "is not", + ] + value_type: Literal["const", "param"] + value: Any | None = None + param_index: int | None = None + + +@dataclass +class EntityList: + caption: str | LazyProxy | EntityCaptionCallable | None = None + item_repr: EntityItemCaptionCallable | None = None + show_add_new_button: bool = True + item_form: str | None = None + pagination: bool = True + static_filters: list[Filter] | Any = None + filtering: bool = True + filtering_fields: list[str] = None + order_by: str | Any | None = None + + +@dataclass +class EntityForm: + item_repr: EntityItemCaptionCallable | None = None + edit_field_sequence: list[str] = None + form_buttons: list[list[FieldEditButton | CommandButton]] = None + show_edit_button: bool = True + show_delete_button: bool = True + + +@dataclass(kw_only=True) +class _BaseEntityFieldDescriptor: icon: str = None caption: str | LazyProxy | EntityFieldCaptionCallable | None = None description: str | LazyProxy | EntityFieldCaptionCallable | None = None @@ -24,21 +89,25 @@ class _BaseEntityFieldDescriptor(): localizable: bool = False bool_false_value: str | LazyProxy = "no" bool_true_value: str | LazyProxy = "yes" + ep_form: str | None = None + ep_parent_field: str | None = None + ep_child_field: str | None = None + dt_type: Literal["date", "datetime"] = "date" default: Any = None -@dataclass(kw_only = True) +@dataclass(kw_only=True) class EntityField(_BaseEntityFieldDescriptor): name: str | None = None sm_descriptor: Any = None -@dataclass(kw_only = True) +@dataclass(kw_only=True) class Setting(_BaseEntityFieldDescriptor): name: str | None = None -@dataclass(kw_only = True) +@dataclass(kw_only=True) class EntityFieldDescriptor(_BaseEntityFieldDescriptor): name: str field_name: str @@ -52,42 +121,80 @@ class EntityFieldDescriptor(_BaseEntityFieldDescriptor): return self.name.__hash__() -@dataclass(kw_only = True) +@dataclass(kw_only=True) class _BaseEntityDescriptor: - icon: str = "📘" - caption: str | LazyProxy | EntityCaptionCallable | None = None - caption_plural: str | LazyProxy | EntityCaptionCallable | None = None + full_name: str | LazyProxy | EntityCaptionCallable | None = None + full_name_plural: str | LazyProxy | EntityCaptionCallable | None = None description: str | LazyProxy | EntityCaptionCallable | None = None - item_caption: EntityItemCaptionCallable | None = None + item_repr: EntityItemCaptionCallable | 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]) + forms: dict[str, EntityForm] = field(default_factory=dict[str, EntityForm]) show_in_entities_menu: bool = True - field_sequence: list[str] = None - edit_button_visible: bool = True - edit_buttons: list[list[str | tuple[str, str | LazyProxy | EntityFieldCaptionCallable]]] = None - permissions: dict[EntityPermission, list[RoleBase]] = field(default_factory = lambda: { - EntityPermission.LIST: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], - EntityPermission.READ: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], - EntityPermission.CREATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], - EntityPermission.UPDATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], - EntityPermission.DELETE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], - EntityPermission.LIST_ALL: [RoleBase.SUPER_USER], - EntityPermission.READ_ALL: [RoleBase.SUPER_USER], - EntityPermission.CREATE_ALL: [RoleBase.SUPER_USER], - EntityPermission.UPDATE_ALL: [RoleBase.SUPER_USER], - EntityPermission.DELETE_ALL: [RoleBase.SUPER_USER] - }) + ownership_fields: dict[RoleBase, str] = field(default_factory=dict[RoleBase, str]) + permissions: dict[EntityPermission, list[RoleBase]] = field( + default_factory=lambda: { + EntityPermission.LIST: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], + EntityPermission.READ: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], + EntityPermission.CREATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], + EntityPermission.UPDATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], + EntityPermission.DELETE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], + EntityPermission.LIST_ALL: [RoleBase.SUPER_USER], + EntityPermission.READ_ALL: [RoleBase.SUPER_USER], + EntityPermission.CREATE_ALL: [RoleBase.SUPER_USER], + EntityPermission.UPDATE_ALL: [RoleBase.SUPER_USER], + EntityPermission.DELETE_ALL: [RoleBase.SUPER_USER], + } + ) -@dataclass(kw_only = True) +@dataclass(kw_only=True) class Entity(_BaseEntityDescriptor): - name: str | None = None - + @dataclass class EntityDescriptor(_BaseEntityDescriptor): - name: str class_name: str type_: type["BotEntity"] fields_descriptors: dict[str, EntityFieldDescriptor] + + +@dataclass(kw_only=True) +class CommandCallbackContext[UT: UserBase]: + keyboard_builder: InlineKeyboardBuilder = field( + default_factory=InlineKeyboardBuilder + ) + message_text: str | None = None + register_navigation: bool = True + message: Message | CallbackQuery + callback_data: ContextData + db_session: AsyncSession + user: UT + app: "QBotApp" + state_data: dict[str, Any] + state: FSMContext + i18n: I18n + kwargs: dict[str, Any] = field(default_factory=dict) + + +@dataclass(kw_only=True) +class _BotCommand: + name: str + caption: str | dict[str, str] | None = None + show_in_bot_commands: bool = False + register_navigation: bool = True + clear_navigation: bool = False + clear_state: bool = True + + +@dataclass(kw_only=True) +class BotCommand(_BotCommand): + handler: Callable[[CommandCallbackContext], None] + + +@dataclass(kw_only=True) +class Command(_BotCommand): ... diff --git a/model/entity_metadata.py b/model/entity_metadata.py index 5feb309..47c9886 100644 --- a/model/entity_metadata.py +++ b/model/entity_metadata.py @@ -2,7 +2,6 @@ from .descriptors import EntityDescriptor from ._singleton import Singleton -class EntityMetadata(metaclass = Singleton): - +class EntityMetadata(metaclass=Singleton): def __init__(self): self.entity_descriptors: dict[str, EntityDescriptor] = {} diff --git a/model/field_types.py b/model/field_types.py deleted file mode 100644 index 29dede7..0000000 --- a/model/field_types.py +++ /dev/null @@ -1,45 +0,0 @@ -from dataclasses import dataclass -from babel.support import LazyProxy -from typing import TypeVar - -from .bot_entity import BotEntity - -@dataclass -class FieldType: ... - - -class LocStr(str): ... - - -class String(FieldType): - localizable: bool = False - - -class Integer(FieldType): - pass - - -@dataclass -class Decimal: - precision: int = 0 - - -@dataclass -class Boolean: - true_value: str | LazyProxy = "true" - false_value: str | LazyProxy = "false" - - -@dataclass -class DateTime: - pass - - -EntityType = TypeVar('EntityType', bound = BotEntity) - -@dataclass -class EntityReference: - entity_type: type[EntityType] - - - diff --git a/model/fsm_storage.py b/model/fsm_storage.py index 58ad936..5148f2c 100644 --- a/model/fsm_storage.py +++ b/model/fsm_storage.py @@ -1,8 +1,7 @@ from sqlmodel import SQLModel, Field -class FSMStorage(SQLModel, table = True): - +class FSMStorage(SQLModel, table=True): __tablename__ = "fsm_storage" - key: str = Field(primary_key = True) - value: str | None = None \ No newline at end of file + key: str = Field(primary_key=True) + value: str | None = None diff --git a/model/language.py b/model/language.py index 097364e..afee9ba 100644 --- a/model/language.py +++ b/model/language.py @@ -2,5 +2,4 @@ from .bot_enum import BotEnum, EnumMember class LanguageBase(BotEnum): - - EN = EnumMember("en", {"en": "🇬🇧 english"}) \ No newline at end of file + EN = EnumMember("en", {"en": "🇬🇧 english"}) diff --git a/model/menu.py b/model/menu.py deleted file mode 100644 index e37b46b..0000000 --- a/model/menu.py +++ /dev/null @@ -1,26 +0,0 @@ -# from aiogram.types import Message, CallbackQuery -# from aiogram.utils.keyboard import InlineKeyboardBuilder -# from typing import Any, Callable, Self, Union, overload -# from babel.support import LazyProxy -# from dataclasses import dataclass - -# from ..bot.handlers.context import ContextData - - -# class Menu: - -# @overload -# def __init__(self, description: str | LazyProxy): ... - - -# @overload -# def __init__(self, menu_factory: Callable[[InlineKeyboardBuilder, Union[Message, CallbackQuery], Any], str]): ... - - -# def __init__(self, description: str | LazyProxy = None, -# menu_factory: Callable[[InlineKeyboardBuilder, Union[Message, CallbackQuery], Any], str] = None) -> None: - -# self.menu_factory = menu_factory -# self.description = description -# self.parent: Menu = None -# self.items: list[list[Menu]] = [] \ No newline at end of file diff --git a/model/owned_bot_entity.py b/model/owned_bot_entity.py deleted file mode 100644 index dfbc546..0000000 --- a/model/owned_bot_entity.py +++ /dev/null @@ -1,49 +0,0 @@ -from sqlmodel import BIGINT, Field, select, func, column -from sqlmodel.ext.asyncio.session import AsyncSession - - -from .bot_entity import BotEntity -from .descriptors import EntityField -from .user import UserBase -from . import session_dep - - -class OwnedBotEntity(BotEntity, table = False): - - user_id: int | None = EntityField( - sm_descriptor = Field(sa_type = BIGINT, foreign_key = "user.id", ondelete="SET NULL"), - is_visible = False) - - - @classmethod - @session_dep - async def get_multi_by_user(cls, *, - session: AsyncSession | None = None, - user_id: int, - filter: str = None, - order_by = None, - skip: int = 0, - limit: int = None): - - select_statement = select(cls).where(cls.user_id == user_id).offset(skip) - if filter: - select_statement = select_statement.where(column("name").ilike(f"%{filter}%")) - if limit: - select_statement = select_statement.limit(limit) - if order_by: - select_statement = select_statement.order_by(order_by) - return (await session.exec(select_statement)).all() - - - @classmethod - @session_dep - async def get_count_by_user(cls, *, - session: AsyncSession | None = None, - user_id: int, - filter: str = None) -> int: - - select_statement = select(func.count()).select_from(cls).where(cls.user_id == user_id) - if filter: - select_statement = select_statement.where(column("name").ilike(f"%{filter}%")) - - return await session.scalar(select_statement) \ No newline at end of file diff --git a/model/role.py b/model/role.py index 9f41833..38462b6 100644 --- a/model/role.py +++ b/model/role.py @@ -2,6 +2,5 @@ from .bot_enum import BotEnum, EnumMember class RoleBase(BotEnum): - SUPER_USER = EnumMember("super_user") - DEFAULT_USER = EnumMember("default_user") \ No newline at end of file + DEFAULT_USER = EnumMember("default_user") diff --git a/model/settings.py b/model/settings.py index 215a5d8..1ed5ba9 100644 --- a/model/settings.py +++ b/model/settings.py @@ -7,131 +7,193 @@ from typing import Any, get_args, get_origin from ..db import async_session from .role import RoleBase from .descriptors import EntityFieldDescriptor, Setting -from ..utils import deserialize, serialize +from ..utils.serialization import deserialize, serialize import ujson as json -class DbSettings(SQLModel, table = True): +class DbSettings(SQLModel, table=True): __tablename__ = "settings" - name: str = Field(primary_key = True) + name: str = Field(primary_key=True) value: str class SettingsMetaclass(type): - def __new__(cls, class_name, base_classes, attributes): - settings_descriptors = {} if base_classes: - settings_descriptors = base_classes[0].__dict__.get("_settings_descriptors", {}) + settings_descriptors = base_classes[0].__dict__.get( + "_settings_descriptors", {} + ) - for annotation in attributes.get('__annotations__', {}): - + for annotation in attributes.get("__annotations__", {}): if annotation in ["_settings_descriptors", "_cache", "_cached_settings"]: continue - + attr_value = attributes.get(annotation) name = annotation - - type_ = attributes['__annotations__'][annotation] + + type_ = attributes["__annotations__"][annotation] if isinstance(attr_value, Setting): descriptor_kwargs = attr_value.__dict__.copy() name = descriptor_kwargs.pop("name") or annotation attributes[annotation] = EntityFieldDescriptor( - name = name, - field_name = annotation, - type_ = type_, - type_base = type_, - **descriptor_kwargs) - + name=name, + field_name=annotation, + type_=type_, + type_base=type_, + **descriptor_kwargs, + ) + else: attributes[annotation] = EntityFieldDescriptor( - name = annotation, - field_name = annotation, - type_ = type_, - type_base = type_, - default = attr_value) - + name=annotation, + field_name=annotation, + type_=type_, + type_base=type_, + default=attr_value, + ) + type_origin = get_origin(type_) - if type_origin == list: + if type_origin is list: attributes[annotation].is_list = True attributes[annotation].type_base = type_ = get_args(type_)[0] elif type_origin == UnionType and get_args(type_)[1] == NoneType: attributes[annotation].is_optional = True attributes[annotation].type_base = type_ = get_args(type_)[0] - + settings_descriptors[name] = attributes[annotation] - if base_classes and base_classes[0].__name__ == "Settings" and hasattr(base_classes[0], annotation): + if ( + base_classes + and base_classes[0].__name__ == "Settings" + and hasattr(base_classes[0], annotation) + ): setattr(base_classes[0], annotation, attributes[annotation]) - + attributes["__annotations__"] = {} attributes["_settings_descriptors"] = settings_descriptors - + return super().__new__(cls, class_name, base_classes, attributes) -class Settings(metaclass = SettingsMetaclass): - +class Settings(metaclass=SettingsMetaclass): _cache: dict[str, Any] = dict[str, Any]() _settings_descriptors: dict[str, EntityFieldDescriptor] = {} - - PAGE_SIZE: int = Setting(default = 10, ) - SECURITY_PARAMETERS_ROLES: list[RoleBase] = Setting(name = "SECPARAMS_ROLES", default = [RoleBase.SUPER_USER], is_visible = False) - APP_STRINGS_WELCOME_P_NAME: str = Setting(name = "AS_WELCOME", default = "Welcome, {name}", is_visible = False) - APP_STRINGS_GREETING_P_NAME: str = Setting(name = "AS_GREETING", default = "Hello, {name}", is_visible = False) - APP_STRINGS_INTERNAL_ERROR_P_ERROR: str = Setting(name = "AS_INTERNAL_ERROR", default = "Internal error\n{error}", is_visible = False) - APP_STRINGS_USER_BLOCKED_P_NAME: str = Setting(name = "AS_USER_BLOCKED", default = "User {name} is blocked", is_visible = False) - APP_STRINGS_FORBIDDEN: str = Setting(name = "AS_FORBIDDEN", default = "Forbidden", is_visible = False) - APP_STRINGS_NOT_FOUND: str = Setting(name = "AS_NOT_FOUND", default = "Object not found", is_visible = False) - APP_STRINGS_MAIN_NENU: str = Setting(name = "AS_MAIN_MENU", default = "Main menu", is_visible = False) - APP_STRINGS_REFERENCES: str = Setting(name = "AS_REFERENCES", default = "References", is_visible = False) - APP_STRINGS_REFERENCES_BTN: str = Setting(name = "AS_REFERENCES_BTN", default = "📚 References", is_visible = False) - APP_STRINGS_SETTINGS: str = Setting(name = "AS_SETTINGS", default = "Settings", is_visible = False) - APP_STRINGS_SETTINGS_BTN: str = Setting(name = "AS_SETTINGS_BTN", default = "⚙️ Settings", is_visible = False) - APP_STRINGS_PARAMETERS: str = Setting(name = "AS_PARAMETERS", default = "Parameters", is_visible = False) - APP_STRINGS_PARAMETERS_BTN: str = Setting(name = "AS_PARAMETERS_BTN", default = "🎛️ Parameters", is_visible = False) - APP_STRINGS_LANGUAGE: str = Setting(name = "AS_LANGUAGE", default = "Language", is_visible = False) - APP_STRINGS_LANGUAGE_BTN: str = Setting(name = "AS_LANGUAGE_BTN", default = "🗣️ Language", is_visible = False) - APP_STRINGS_BACK_BTN: str = Setting(name = "AS_BACK_BTN", default = "⬅️ Back", is_visible = False) - APP_STRINGS_DELETE_BTN: str = Setting(name = "AS_DELETE_BTN", default = "🗑️ Delete", is_visible = False) + PAGE_SIZE: int = Setting( + default=10, + ) + SECURITY_PARAMETERS_ROLES: list[RoleBase] = Setting( + name="SECPARAMS_ROLES", default=[RoleBase.SUPER_USER], is_visible=False + ) + + APP_STRINGS_WELCOME_P_NAME: str = Setting( + name="AS_WELCOME", default="Welcome, {name}", is_visible=False + ) + APP_STRINGS_GREETING_P_NAME: str = Setting( + name="AS_GREETING", default="Hello, {name}", is_visible=False + ) + APP_STRINGS_INTERNAL_ERROR_P_ERROR: str = Setting( + name="AS_INTERNAL_ERROR", default="Internal error\n{error}", is_visible=False + ) + APP_STRINGS_USER_BLOCKED_P_NAME: str = Setting( + name="AS_USER_BLOCKED", default="User {name} is blocked", is_visible=False + ) + APP_STRINGS_FORBIDDEN: str = Setting( + name="AS_FORBIDDEN", default="Forbidden", is_visible=False + ) + APP_STRINGS_NOT_FOUND: str = Setting( + name="AS_NOT_FOUND", default="Object not found", is_visible=False + ) + APP_STRINGS_MAIN_NENU: str = Setting( + name="AS_MAIN_MENU", default="Main menu", is_visible=False + ) + APP_STRINGS_REFERENCES: str = Setting( + name="AS_REFERENCES", default="References", is_visible=False + ) + APP_STRINGS_REFERENCES_BTN: str = Setting( + name="AS_REFERENCES_BTN", default="📚 References", is_visible=False + ) + APP_STRINGS_SETTINGS: str = Setting( + name="AS_SETTINGS", default="Settings", is_visible=False + ) + APP_STRINGS_SETTINGS_BTN: str = Setting( + name="AS_SETTINGS_BTN", default="⚙️ Settings", is_visible=False + ) + APP_STRINGS_PARAMETERS: str = Setting( + name="AS_PARAMETERS", default="Parameters", is_visible=False + ) + APP_STRINGS_PARAMETERS_BTN: str = Setting( + name="AS_PARAMETERS_BTN", default="🎛️ Parameters", is_visible=False + ) + APP_STRINGS_LANGUAGE: str = Setting( + name="AS_LANGUAGE", default="Language", is_visible=False + ) + APP_STRINGS_LANGUAGE_BTN: str = Setting( + name="AS_LANGUAGE_BTN", default="🗣️ Language", is_visible=False + ) + APP_STRINGS_BACK_BTN: str = Setting( + name="AS_BACK_BTN", default="⬅️ Back", is_visible=False + ) + APP_STRINGS_DELETE_BTN: str = Setting( + name="AS_DELETE_BTN", default="🗑️ Delete", is_visible=False + ) APP_STRINGS_CONFIRM_DELETE_P_NAME: str = Setting( - name = "AS_CONFIRM_DEL", - default = "Are you sure you want to delete \"{name}\"?", - is_visible = False) - APP_STRINGS_EDIT_BTN: str = Setting(name = "AS_EDIT_BTN", default = "✏️ Edit", is_visible = False) - APP_STRINGS_ADD_BTN: str = Setting(name = "AS_ADD_BTN", default = "➕ Add", is_visible = False) - APP_STRINGS_YES_BTN: str = Setting(name = "AS_YES_BTN", default = "✅ Yes", is_visible = False) - APP_STRINGS_NO_BTN: str = Setting(name = "AS_NO_BTN", default = "❌ No", is_visible = False) - APP_STRINGS_CANCEL_BTN: str = Setting(name = "AS_CANCEL_BTN", default = "❌ Cancel", is_visible = False) - APP_STRINGS_CLEAR_BTN: str = Setting(name = "AS_CLEAR_BTN", default = "⌫ Clear", is_visible = False) - APP_STRINGS_DONE_BTN: str = Setting(name = "AS_DONE_BTN", default = "✅ Done", is_visible = False) - APP_STRINGS_SKIP_BTN: str = Setting(name = "AS_SKIP_BTN", default = "⏩️ Skip", is_visible = False) + name="AS_CONFIRM_DEL", + default='Are you sure you want to delete "{name}"?', + is_visible=False, + ) + APP_STRINGS_EDIT_BTN: str = Setting( + name="AS_EDIT_BTN", default="✏️ Edit", is_visible=False + ) + APP_STRINGS_ADD_BTN: str = Setting( + name="AS_ADD_BTN", default="➕ Add", is_visible=False + ) + APP_STRINGS_YES_BTN: str = Setting( + name="AS_YES_BTN", default="✅ Yes", is_visible=False + ) + APP_STRINGS_NO_BTN: str = Setting( + name="AS_NO_BTN", default="❌ No", is_visible=False + ) + APP_STRINGS_CANCEL_BTN: str = Setting( + name="AS_CANCEL_BTN", default="❌ Cancel", is_visible=False + ) + APP_STRINGS_CLEAR_BTN: str = Setting( + name="AS_CLEAR_BTN", default="⌫ Clear", is_visible=False + ) + APP_STRINGS_DONE_BTN: str = Setting( + name="AS_DONE_BTN", default="✅ Done", is_visible=False + ) + APP_STRINGS_SKIP_BTN: str = Setting( + name="AS_SKIP_BTN", default="⏩️ Skip", is_visible=False + ) APP_STRINGS_FIELD_EDIT_PROMPT_TEMPLATE_P_NAME_VALUE: str = Setting( - name = "AS_FIELDEDIT_PROMPT", - default = "Enter new value for \"{name}\" (current value: {value})", - is_visible = False) + name="AS_FIELDEDIT_PROMPT", + default='Enter new value for "{name}" (current value: {value})', + is_visible=False, + ) APP_STRINGS_FIELD_CREATE_PROMPT_TEMPLATE_P_NAME: str = Setting( - name = "AS_FIELDCREATE_PROMPT", - default = "Enter new value for \"{name}\"", - is_visible = False) + name="AS_FIELDCREATE_PROMPT", + default='Enter new value for "{name}"', + is_visible=False, + ) APP_STRINGS_STRING_EDITOR_LOCALE_TEMPLATE_P_NAME: str = Setting( - name = "AS_STREDIT_LOC_TEMPLATE", - default = "string for \"{name}\"", - is_visible = False) - APP_STRINGS_VIEW_FILTER_EDIT_PROMPT: str = Setting(name = "AS_FILTEREDIT_PROMPT", default = "Enter filter value", is_visible = False) - APP_STRINGS_INVALID_INPUT: str = Setting(name = "AS_INVALID_INPUT", default = "Invalid input", is_visible = False) + name="AS_STREDIT_LOC_TEMPLATE", default='string for "{name}"', is_visible=False + ) + APP_STRINGS_VIEW_FILTER_EDIT_PROMPT: str = Setting( + name="AS_FILTEREDIT_PROMPT", default="Enter filter value", is_visible=False + ) + APP_STRINGS_INVALID_INPUT: str = Setting( + name="AS_INVALID_INPUT", default="Invalid input", is_visible=False + ) - @classmethod - async def get[T](cls, param: T, all_locales = False, locale: str = None) -> T: - + async def get[T](cls, param: T, all_locales=False, locale: str = None) -> T: name = param.field_name if name not in cls._cache.keys(): @@ -144,73 +206,79 @@ class Settings(metaclass = SettingsMetaclass): locale = get_i18n().current_locale try: obj = json.loads(ret_val) - except: + except Exception: return ret_val return obj.get(locale, obj[list(obj.keys())[0]]) return ret_val - @classmethod async def load_param(cls, param: EntityFieldDescriptor) -> Any: - async with async_session() as session: - db_setting = (await session.exec( - select(DbSettings) - .where(DbSettings.name == param.field_name))).first() - - if db_setting: - return await deserialize(session = session, - type_ = param.type_, - value = db_setting.value) - - return (param.default if param.default else - [] if (get_origin(param.type_) is list or param.type_ == list) else - datetime(2000, 1, 1) if param.type_ == datetime else - param.type_()) - + db_setting = ( + await session.exec( + select(DbSettings).where(DbSettings.name == param.field_name) + ) + ).first() + if db_setting: + return await deserialize( + session=session, type_=param.type_, value=db_setting.value + ) + + return ( + param.default + if param.default + else ( + [] + if (get_origin(param.type_) is list or param.type_ is list) + else datetime(2000, 1, 1) + if param.type_ == datetime + else param.type_() + ) + ) @classmethod async def load_params(cls): - async with async_session() as session: db_settings = (await session.exec(select(DbSettings))).all() for db_setting in db_settings: if db_setting.name in cls.__dict__: - setting = cls.__dict__[db_setting.name] - cls._cache[db_setting.name] = await deserialize(session = session, - type_ = setting.type_, - value = db_setting.value, - default = setting.default) - + setting = cls.__dict__[db_setting.name] # type: EntityFieldDescriptor + cls._cache[db_setting.name] = await deserialize( + session=session, + type_=setting.type_, + value=db_setting.value, + default=setting.default, + ) + cls._loaded = True - - + @classmethod async def set_param(cls, param: str | EntityFieldDescriptor, value) -> None: if isinstance(param, str): - param = cls._settings_descriptors[param] + param = cls._settings_descriptors[param] ser_value = serialize(value, param) async with async_session() as session: - db_setting = (await session.exec( - select(DbSettings) - .where(DbSettings.name == param.field_name))).first() + db_setting = ( + await session.exec( + select(DbSettings).where(DbSettings.name == param.field_name) + ) + ).first() if db_setting is None: - db_setting = DbSettings(name = param.field_name) + db_setting = DbSettings(name=param.field_name) db_setting.value = str(ser_value) session.add(db_setting) await session.commit() cls._cache[param.field_name] = value - @classmethod def list_params(cls) -> dict[str, EntityFieldDescriptor]: return cls._settings_descriptors - - + @classmethod async def get_params(cls) -> dict[EntityFieldDescriptor, Any]: - params = cls.list_params() - return {param: await cls.get(param, all_locales = True) for _, param in params.items()} \ No newline at end of file + return { + param: await cls.get(param, all_locales=True) for _, param in params.items() + } diff --git a/model/user.py b/model/user.py index 889e802..9880f31 100644 --- a/model/user.py +++ b/model/user.py @@ -10,11 +10,12 @@ from .fsm_storage import FSMStorage as FSMStorage from .view_setting import ViewSetting as ViewSetting -class UserBase(BotEntity, table = False): - +class UserBase(BotEntity, table=False): __tablename__ = "user" - lang: LanguageBase = Field(sa_type = EnumType(LanguageBase), default = LanguageBase.EN) + lang: LanguageBase = Field(sa_type=EnumType(LanguageBase), default=LanguageBase.EN) is_active: bool = True - - roles: list[RoleBase] = Field(sa_type=ARRAY(EnumType(RoleBase)), default = [RoleBase.DEFAULT_USER]) \ No newline at end of file + + roles: list[RoleBase] = Field( + sa_type=ARRAY(EnumType(RoleBase)), default=[RoleBase.DEFAULT_USER] + ) diff --git a/model/view_setting.py b/model/view_setting.py index 8ecaec7..8f99ac7 100644 --- a/model/view_setting.py +++ b/model/view_setting.py @@ -4,36 +4,36 @@ from sqlalchemy.ext.asyncio.session import AsyncSession from . import session_dep -class ViewSetting(SQLModel, table = True): - +class ViewSetting(SQLModel, table=True): __tablename__ = "view_setting" - user_id: int = Field(sa_type = BIGINT, primary_key = True, foreign_key="user.id", ondelete="CASCADE") - entity_name: str = Field(primary_key = True) - filter: str | None = None + user_id: int = Field( + sa_type=BIGINT, primary_key=True, foreign_key="user.id", ondelete="CASCADE" + ) + entity_name: str = Field(primary_key=True) + filter: str | None = None - @classmethod @session_dep - async def get_filter(cls, *, - session: AsyncSession | None = None, - user_id: int, - entity_name: str): - + async def get_filter( + cls, *, session: AsyncSession | None = None, user_id: int, entity_name: str + ): setting = await session.get(cls, (user_id, entity_name)) return setting.filter if setting else None - + @classmethod @session_dep - async def set_filter(cls, *, - session: AsyncSession | None = None, - user_id: int, - entity_name: str, - filter: str): - - setting = await session.get(cls, (user_id, entity_name)) - if setting: - setting.filter = filter - else: - setting = cls(user_id = user_id, entity_name = entity_name, filter = filter) - session.add(setting) - await session.commit() \ No newline at end of file + async def set_filter( + cls, + *, + session: AsyncSession | None = None, + user_id: int, + entity_name: str, + filter: str, + ): + setting = await session.get(cls, (user_id, entity_name)) + if setting: + setting.filter = filter + else: + setting = cls(user_id=user_id, entity_name=entity_name, filter=filter) + session.add(setting) + await session.commit() diff --git a/router.py b/router.py new file mode 100644 index 0000000..2aa4a9b --- /dev/null +++ b/router.py @@ -0,0 +1,32 @@ +from functools import wraps +from typing import Callable, overload +from .model.descriptors import BotCommand, Command, CommandCallbackContext + + +class Router: + def __init__(self): + self._commands = dict[str, BotCommand]() + + @overload + def command(self, command: Command): ... + + @overload + def command(self, command: str, caption: str | dict[str, str] | None = None): ... + + def command( + self, command: str | Command, caption: str | dict[str, str] | None = None + ): + def decorator(func: Callable[[CommandCallbackContext], None]): + if isinstance(command, str): + cmd = BotCommand(name=command, handler=func, caption=caption) + else: + cmd = BotCommand(handler=func, **command.__dict__) + self._commands[cmd.name] = cmd + + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/utils/main.py b/utils/main.py new file mode 100644 index 0000000..250487e --- /dev/null +++ b/utils/main.py @@ -0,0 +1,199 @@ +from babel.support import LazyProxy +from inspect import signature +from aiogram.types import Message, CallbackQuery +from typing import Any, TYPE_CHECKING +import ujson as json + +from ..model.bot_entity import BotEntity +from ..model.bot_enum import BotEnum +from ..model.settings import Settings + +from ..model.descriptors import ( + EntityFieldDescriptor, + EntityDescriptor, + EntityItemCaptionCallable, + EntityFieldCaptionCallable, + EntityPermission, + EntityCaptionCallable, +) + +from ..bot.handlers.context import ContextData, CommandContext + +if TYPE_CHECKING: + from ..model.user import UserBase + from ..main import QBotApp + + +def get_user_permissions( + user: "UserBase", entity_descriptor: EntityDescriptor +) -> list[EntityPermission]: + permissions = list[EntityPermission]() + for permission, roles in entity_descriptor.permissions.items(): + for role in roles: + if role in user.roles: + permissions.append(permission) + break + return permissions + + +def get_local_text(text: str, locale: str) -> str: + try: + obj = json.loads(text) # @IgnoreException + except Exception: + return text + else: + return obj.get(locale, obj[list(obj.keys())[0]]) + + +def check_entity_permission( + entity: BotEntity, user: "UserBase", permission: EntityPermission +) -> bool: + perm_mapping = { + EntityPermission.LIST: EntityPermission.LIST_ALL, + EntityPermission.READ: EntityPermission.READ_ALL, + EntityPermission.UPDATE: EntityPermission.UPDATE_ALL, + EntityPermission.CREATE: EntityPermission.CREATE_ALL, + EntityPermission.DELETE: EntityPermission.DELETE_ALL, + } + + if permission not in perm_mapping: + raise ValueError(f"Invalid permission: {permission}") + + entity_descriptor = entity.__class__.bot_entity_descriptor + permissions = get_user_permissions(user, entity_descriptor) + + if perm_mapping[permission] in permissions: + return True + + ownership_filds = entity_descriptor.ownership_fields + + for role in user.roles: + if role in ownership_filds: + if getattr(entity, ownership_filds[role]) == user.id: + return True + else: + if permission in permissions: + return True + + return False + + +def get_send_message(message: Message | CallbackQuery): + if isinstance(message, Message): + return message.answer + else: + return message.message.edit_text + + +def clear_state(state_data: dict, clear_nav: bool = False): + if clear_nav: + state_data.clear() + else: + stack = state_data.get("navigation_stack") + context = state_data.get("navigation_context") + state_data.clear() + if stack: + state_data["navigation_stack"] = stack + if context: + state_data["navigation_context"] = context + + +def get_entity_item_repr( + entity: BotEntity, item_repr: EntityItemCaptionCallable | None = None +) -> str: + descr = entity.bot_entity_descriptor + if item_repr: + return item_repr(descr, entity) + return ( + descr.item_repr(descr, entity) + if descr.item_repr + else f"{ + get_callable_str(descr.full_name, descr, entity) + if descr.full_name + else descr.name + }: {str(entity.id)}" + ) + + +def get_value_repr( + value: Any, field_descriptor: EntityFieldDescriptor, locale: str | None = None +) -> str: + if value is None: + return "" + + type_ = field_descriptor.type_base + if isinstance(value, bool): + return "【✔︎】" if value else "【 】" + elif field_descriptor.is_list: + if issubclass(type_, BotEntity): + return f"[{', '.join([get_entity_item_repr(item) for item in value])}]" + elif issubclass(type_, BotEnum): + return f"[{', '.join(item.localized(locale) for item in value)}]" + elif type_ is str: + return f"[{', '.join([f'"{item}"' for item in value])}]" + else: + return f"[{', '.join([str(item) for item in value])}]" + elif issubclass(type_, BotEntity): + return get_entity_item_repr(value) + elif issubclass(type_, BotEnum): + return value.localized(locale) + elif isinstance(value, str): + if field_descriptor and field_descriptor.localizable: + return get_local_text(text=value, locale=locale) + return value + elif isinstance(value, int): + return str(value) + elif isinstance(value, float): + return str(value) + else: + return str(value) + + +def get_callable_str( + callable_str: ( + str + | LazyProxy + | EntityCaptionCallable + | EntityItemCaptionCallable + | EntityFieldCaptionCallable + ), + descriptor: EntityFieldDescriptor | EntityDescriptor, + entity: Any = None, + value: Any = None, +) -> str: + if isinstance(callable_str, str): + return callable_str + elif isinstance(callable_str, LazyProxy): + return callable_str.value + elif callable(callable_str): + args = signature(callable_str).parameters + if len(args) == 1: + return callable_str(descriptor) + elif len(args) == 2: + return callable_str(descriptor, entity) + elif len(args) == 3: + return callable_str(descriptor, entity, value) + + +def get_entity_descriptor( + app: "QBotApp", callback_data: ContextData +) -> EntityDescriptor: + if callback_data.entity_name: + return app.entity_metadata.entity_descriptors[callback_data.entity_name] + return None + + +def get_field_descriptor( + app: "QBotApp", callback_data: ContextData +) -> EntityFieldDescriptor | None: + if callback_data.context == CommandContext.SETTING_EDIT: + return Settings.list_params()[callback_data.field_name] + elif callback_data.context in [ + CommandContext.ENTITY_CREATE, + CommandContext.ENTITY_EDIT, + CommandContext.ENTITY_FIELD_EDIT, + ]: + entity_descriptor = get_entity_descriptor(app, callback_data) + if entity_descriptor: + return entity_descriptor.fields_descriptors.get(callback_data.field_name) + return None diff --git a/utils/__init__.py b/utils/serialization.py similarity index 63% rename from utils/__init__.py rename to utils/serialization.py index e23e0b5..ce1d6d2 100644 --- a/utils/__init__.py +++ b/utils/serialization.py @@ -1,18 +1,14 @@ -from datetime import datetime +from datetime import datetime, time from decimal import Decimal -from types import NoneType, UnionType from sqlmodel import select, column from sqlmodel.ext.asyncio.session import AsyncSession -from typing import Any, Union, get_origin, get_args, TYPE_CHECKING +from typing import Any, Union, get_origin, get_args +from types import UnionType, NoneType import ujson as json from ..model.bot_entity import BotEntity from ..model.bot_enum import BotEnum -from ..model.descriptors import EntityFieldDescriptor, EntityDescriptor -from ..model import EntityPermission - -if TYPE_CHECKING: - from ..model.user import UserBase +from ..model.descriptors import EntityFieldDescriptor async def deserialize[T](session: AsyncSession, type_: type[T], value: str = None) -> T: @@ -20,12 +16,12 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non is_optional = False if type_origin in [UnionType, Union]: args = get_args(type_) - if args[1] == NoneType: + if args[1] is NoneType: type_ = args[0] if value is None: return None is_optional = True - if get_origin(type_) == list: + if get_origin(type_) is list: arg_type = None args = get_args(type_) if args: @@ -34,7 +30,9 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non if arg_type: if issubclass(arg_type, BotEntity): ret = list[arg_type]() - items = (await session.exec(select(arg_type).where(column("id").in_(values)))).all() + items = ( + await session.exec(select(arg_type).where(column("id").in_(values))) + ).all() for item in items: ret.append(item) return ret @@ -52,55 +50,43 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non if is_optional and not value: return None return type_(value) - elif type_ == datetime: + elif type_ is time: if is_optional and not value: return None - return datetime.fromisoformat(value) - elif type_ == bool: + return time.fromisoformat(value.replace("-", ":")) + elif type_ is datetime: + if is_optional and not value: + return None + if value[-3] == ":": + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + elif value[-3] == "-": + return datetime.strptime(value, "%Y-%m-%d %H-%M") + else: + raise ValueError("Invalid datetime format") + elif type_ is bool: return value == "True" - elif type_ == Decimal: + elif type_ is Decimal: if is_optional and not value: return None return Decimal(value) - + if is_optional and not value: return None return type_(value) def serialize(value: Any, field_descriptor: EntityFieldDescriptor) -> str: - if value is None: return "" type_ = field_descriptor.type_base - + if field_descriptor.is_list: if issubclass(type_, BotEntity): - return json.dumps([item.id for item in value], ensure_ascii = False) + return json.dumps([item.id for item in value], ensure_ascii=False) elif issubclass(type_, BotEnum): - return json.dumps([item.value for item in value], ensure_ascii = False) + return json.dumps([item.value for item in value], ensure_ascii=False) else: - return json.dumps(value, ensure_ascii = False) + return json.dumps(value, ensure_ascii=False) elif issubclass(type_, BotEntity): return str(value.id) if value else "" return str(value) - - -def get_user_permissions(user: "UserBase", entity_descriptor: EntityDescriptor) -> list[EntityPermission]: - - permissions = list[EntityPermission]() - for permission, roles in entity_descriptor.permissions.items(): - for role in roles: - if role in user.roles: - permissions.append(permission) - break - return permissions - - -def get_local_text(text: str, locale: str) -> str: - try: - obj = json.loads(text) #@IgnoreException - except: - return text - else: - return obj.get(locale, obj[list(obj.keys())[0]]) \ No newline at end of file