add ruff format, ruff check, time_picker, project structure and imports reorganized

This commit is contained in:
Alexander Kalinovsky
2025-01-21 23:50:19 +01:00
parent ced47ac993
commit 9dd0708a5b
58 changed files with 3690 additions and 2583 deletions

View File

@@ -1 +1,13 @@
from .main import QBotApp as QBotApp, Config as Config 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,
)

View File

@@ -1,8 +1,9 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..main import QBotApp from ..main import QBotApp
from ..db import get_db from ..db import get_db
from aiogram.types import Update from aiogram.types import Update
from logging import getLogger from logging import getLogger
@@ -14,24 +15,25 @@ router = APIRouter()
@router.post("/webhook") @router.post("/webhook")
async def telegram_webhook(db_session: Annotated[AsyncSession, Depends(get_db)], async def telegram_webhook(
request: Request): db_session: Annotated[AsyncSession, Depends(get_db)], request: Request
):
logger.debug("Webhook request %s", await request.json()) 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") request_token = request.headers.get("X-Telegram-Bot-Api-Secret-Token")
if request_token != app.bot_auth_token: if request_token != app.bot_auth_token:
logger.warning("Unauthorized request %s", request) logger.warning("Unauthorized request %s", request)
return Response(status_code = 403) return Response(status_code=403)
try: try:
update = Update(**await request.json()) update = Update(**await request.json())
except: except Exception:
logger.error("Invalid request", exc_info = True) logger.error("Invalid request", exc_info=True)
return Response(status_code = 400) return Response(status_code=400)
try: try:
await app.dp.feed_webhook_update(app.bot, update, db_session = db_session, app = app) await app.dp.feed_webhook_update(
except: app.bot, update, db_session=db_session, app=app
logger.error("Error processing update", exc_info = True) )
return Response(status_code = 200) except Exception:
logger.error("Error processing update", exc_info=True)
return Response(status_code=200)

14
auth/__init__.py Normal file
View File

@@ -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

View File

@@ -8,7 +8,6 @@ logger = getLogger(__name__)
class CallbackCommandFilter(Filter): class CallbackCommandFilter(Filter):
def __init__(self, command: CallbackCommand): def __init__(self, command: CallbackCommand):
self.command = command self.command = command
@@ -19,11 +18,9 @@ class CallbackCommandFilter(Filter):
if context_data: if context_data:
try: try:
context_data = ContextData.unpack(context_data) context_data = ContextData.unpack(context_data)
except Exception as e: except Exception:
logger.error(f"Error unpacking context data", exc_info = True) logger.error("Error unpacking context data", exc_info=True)
return False return False
else: else:
return context_data.command == self.command return context_data.command == self.command
return False return False

View File

@@ -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

View File

@@ -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(),
)
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")

View File

@@ -1,8 +1,8 @@
from aiogram.filters.callback_data import CallbackData as BaseCallbackData from aiogram.filters.callback_data import CallbackData as BaseCallbackData
from enum import StrEnum from enum import StrEnum
class CallbackCommand(StrEnum):
class CallbackCommand(StrEnum):
FIELD_EDITOR = "fe" FIELD_EDITOR = "fe"
FIELD_EDITOR_CALLBACK = "fc" FIELD_EDITOR_CALLBACK = "fc"
ENTITY_LIST = "el" ENTITY_LIST = "el"
@@ -16,25 +16,28 @@ class CallbackCommand(StrEnum):
SET_LANGUAGE = "ls" SET_LANGUAGE = "ls"
DATE_PICKER_MONTH = "dm" DATE_PICKER_MONTH = "dm"
DATE_PICKER_YEAR = "dy" DATE_PICKER_YEAR = "dy"
#STRING_EDITOR_LOCALE = "sl" TIME_PICKER = "tp"
# STRING_EDITOR_LOCALE = "sl"
ENTITY_PICKER_PAGE = "ep" ENTITY_PICKER_PAGE = "ep"
ENTITY_PICKER_TOGGLE_ITEM = "et" ENTITY_PICKER_TOGGLE_ITEM = "et"
VIEW_FILTER_EDIT = "vf" VIEW_FILTER_EDIT = "vf"
USER_COMMAND = "uc" USER_COMMAND = "uc"
class CommandContext(StrEnum):
class CommandContext(StrEnum):
SETTING_EDIT = "se" SETTING_EDIT = "se"
ENTITY_CREATE = "ec" ENTITY_CREATE = "ec"
ENTITY_EDIT = "ee" ENTITY_EDIT = "ee"
ENTITY_FIELD_EDIT = "ef" ENTITY_FIELD_EDIT = "ef"
class ContextData(BaseCallbackData, prefix = "cd"):
class ContextData(BaseCallbackData, prefix="cd"):
command: CallbackCommand command: CallbackCommand
context: CommandContext | None = None context: CommandContext | None = None
entity_name: str | None = None entity_name: str | None = None
entity_id: int | None = None entity_id: int | None = None
field_name: str | None = None field_name: str | None = None
form_params: str | None = None
user_command: str | None = None user_command: str | None = None
data: str | None = None data: str | None = None
back: bool = False back: bool = False

View File

@@ -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

View File

@@ -5,22 +5,23 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from babel.support import LazyProxy from babel.support import LazyProxy
from logging import getLogger from logging import getLogger
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor from ....model.descriptors import EntityFieldDescriptor
from ..context import ContextData, CallbackCommand from ..context import ContextData, CallbackCommand
from ..common import get_send_message from ....utils.main import get_send_message
from .common import wrap_editor from .wrapper import wrap_editor
logger = getLogger(__name__) logger = getLogger(__name__)
router = Router() router = Router()
async def bool_editor(message: Message | CallbackQuery, async def bool_editor(
edit_prompt: str, message: Message | CallbackQuery,
field_descriptor: EntityFieldDescriptor, edit_prompt: str,
callback_data: ContextData, field_descriptor: EntityFieldDescriptor,
**kwargs): callback_data: ContextData,
**kwargs,
):
keyboard_builder = InlineKeyboardBuilder() keyboard_builder = InlineKeyboardBuilder()
if isinstance(field_descriptor.bool_true_value, LazyProxy): 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 false_caption = field_descriptor.bool_false_value
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton(text = true_caption, InlineKeyboardButton(
callback_data = ContextData( text=true_caption,
command = CallbackCommand.FIELD_EDITOR_CALLBACK, callback_data=ContextData(
context = callback_data.context, command=CallbackCommand.FIELD_EDITOR_CALLBACK,
entity_name = callback_data.entity_name, context=callback_data.context,
entity_id = callback_data.entity_id, entity_name=callback_data.entity_name,
field_name = callback_data.field_name, entity_id=callback_data.entity_id,
data = str(True), field_name=callback_data.field_name,
save_state = True).pack()), form_params=callback_data.form_params,
InlineKeyboardButton(text = false_caption, data=str(True),
callback_data = ContextData( ).pack(),
command = CallbackCommand.FIELD_EDITOR_CALLBACK, ),
context = callback_data.context, InlineKeyboardButton(
entity_name = callback_data.entity_name, text=false_caption,
entity_id = callback_data.entity_id, callback_data=ContextData(
field_name = callback_data.field_name, command=CallbackCommand.FIELD_EDITOR_CALLBACK,
data = str(False), context=callback_data.context,
save_state = True).pack()) 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"] state_data = kwargs["state_data"]
await wrap_editor(keyboard_builder = keyboard_builder, await wrap_editor(
field_descriptor = field_descriptor, keyboard_builder=keyboard_builder,
callback_data = callback_data, field_descriptor=field_descriptor,
state_data = state_data) callback_data=callback_data,
state_data=state_data,
)
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
await state.set_data(state_data) await state.set_data(state_data)
send_message = get_send_message(message) 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())

View File

@@ -1,67 +1,75 @@
from types import NoneType, UnionType from aiogram.types import Message, CallbackQuery
from typing import get_args, get_origin from decimal import Decimal
from aiogram.fsm.context import FSMContext from datetime import datetime, time
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
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 ....model.settings import Settings
from ..context import ContextData, CallbackCommand, CommandContext from ....model.user import UserBase
from ..navigation import get_navigation_context, pop_navigation_context 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, async def show_editor(message: Message | CallbackQuery, **kwargs):
field_descriptor: EntityFieldDescriptor, field_descriptor: EntityFieldDescriptor = kwargs["field_descriptor"]
callback_data: ContextData, current_value = kwargs["current_value"]
state_data: dict): user: UserBase = kwargs["user"]
callback_data: ContextData = kwargs.get("callback_data", None)
state_data: dict = kwargs["state_data"]
if callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT, CommandContext.ENTITY_FIELD_EDIT]: value_type = field_descriptor.type_base
btns = [] if field_descriptor.edit_prompt:
entity_descriptor = field_descriptor.entity_descriptor edit_prompt = get_callable_str(
field_index = (entity_descriptor.field_sequence.index(field_descriptor.name) field_descriptor.edit_prompt, field_descriptor, None, current_value
if callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT] )
else 0) 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)
stack, context = get_navigation_context(state_data = state_data) kwargs["edit_prompt"] = edit_prompt
context = pop_navigation_context(stack)
if field_index > 0: if value_type not in [int, float, Decimal, str]:
btns.append(InlineKeyboardButton( state_data.update({"context_data": callback_data.pack()})
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: if value_type is bool:
btns.append(InlineKeyboardButton( await bool_editor(message=message, **kwargs)
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) 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)
keyboard_builder.row(InlineKeyboardButton( elif value_type is time:
text = (await Settings.get(Settings.APP_STRINGS_CANCEL_BTN)), await time_picker(message=message, **kwargs)
callback_data = context.pack()))
elif callback_data.context == CommandContext.SETTING_EDIT: elif issubclass(value_type, BotEntity) or issubclass(value_type, BotEnum):
await entity_picker(message=message, **kwargs)
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()))
else:
raise ValueError(f"Unsupported field type: {value_type}")

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta from datetime import datetime, time, timedelta
from aiogram import Router, F from aiogram import Router, F
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
@@ -6,10 +6,11 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger from logging import getLogger
from typing import TYPE_CHECKING 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 ..context import ContextData, CallbackCommand
from ..common import get_send_message, get_field_descriptor, get_entity_descriptor from ....utils.main import get_send_message, get_field_descriptor
from .common import wrap_editor from .wrapper import wrap_editor
if TYPE_CHECKING: if TYPE_CHECKING:
from ....main import QBotApp from ....main import QBotApp
@@ -19,154 +20,343 @@ logger = getLogger(__name__)
router = Router() router = Router()
async def date_picker(message: Message | CallbackQuery, @router.callback_query(ContextData.filter(F.command == CallbackCommand.TIME_PICKER))
field_descriptor: EntityFieldDescriptor, async def time_picker_callback(
callback_data: ContextData, query: CallbackQuery, callback_data: ContextData, app: "QBotApp", **kwargs
current_value: datetime, ):
state: FSMContext, if not callback_data.data:
edit_prompt: str | None = None, return
**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):
field_descriptor = get_field_descriptor(app, callback_data) field_descriptor = get_field_descriptor(app, callback_data)
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = await state.get_data() state_data = await state.get_data()
kwargs["state_data"] = state_data kwargs["state_data"] = state_data
await date_picker(query.message, await time_picker(
field_descriptor = field_descriptor, query.message,
callback_data = callback_data, field_descriptor=field_descriptor,
current_value = datetime.strptime(callback_data.data, "%Y-%m-%d"), callback_data=callback_data,
**kwargs) 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,
)

View File

@@ -1,25 +1,32 @@
from types import UnionType
from aiogram import Router, F from aiogram import Router, F
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger from logging import getLogger
from sqlmodel import column
from sqlmodel.ext.asyncio.session import AsyncSession 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.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.bot_enum import BotEnum
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase from ....model.user import UserBase
from ....model.view_setting import ViewSetting from ....model.view_setting import ViewSetting
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor from ....model.descriptors import EntityFieldDescriptor, Filter
from ....model import EntityPermission 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 ..context import ContextData, CallbackCommand
from ..common import (get_send_message, get_local_text, get_field_descriptor, from ..common.pagination import add_pagination_controls
get_entity_descriptor, add_pagination_controls, add_filter_controls) from ..common.filtering import add_filter_controls
from .common import wrap_editor from .wrapper import wrap_editor
if TYPE_CHECKING: if TYPE_CHECKING:
from ....main import QBotApp from ....main import QBotApp
@@ -28,59 +35,55 @@ logger = getLogger(__name__)
router = Router() router = Router()
async def entity_picker(message: Message | CallbackQuery, async def entity_picker(
field_descriptor: EntityFieldDescriptor, message: Message | CallbackQuery,
edit_prompt: str, field_descriptor: EntityFieldDescriptor,
current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum], edit_prompt: str,
**kwargs): current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum],
**kwargs,
):
state_data: dict = kwargs["state_data"] state_data: dict = kwargs["state_data"]
state_data.update({"current_value": serialize(current_value, field_descriptor), state_data.update(
"value": serialize(current_value, field_descriptor), {
"edit_prompt": edit_prompt}) "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, await render_entity_picker(
message = message, field_descriptor=field_descriptor,
current_value = current_value, message=message,
edit_prompt = edit_prompt, current_value=current_value,
**kwargs) edit_prompt=edit_prompt,
**kwargs,
)
def calc_total_pages(items_count: int, page_size: int) -> int: 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 render_entity_picker(*, async def render_entity_picker(
field_descriptor: EntityFieldDescriptor, *,
message: Message | CallbackQuery, field_descriptor: EntityFieldDescriptor,
callback_data: ContextData, message: Message | CallbackQuery,
user: UserBase, callback_data: ContextData,
db_session: AsyncSession, user: UserBase,
state: FSMContext, db_session: AsyncSession,
current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum], state: FSMContext,
edit_prompt: str, current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum],
page: int = 1, edit_prompt: str,
**kwargs): page: int = 1,
**kwargs,
):
if callback_data.command in [CallbackCommand.ENTITY_PICKER_PAGE, CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM]: if callback_data.command in [
CallbackCommand.ENTITY_PICKER_PAGE,
CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM,
]:
page = int(callback_data.data.split("&")[0]) 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 type_ = field_descriptor.type_base
is_list = field_descriptor.is_list is_list = field_descriptor.is_list
@@ -88,114 +91,234 @@ async def render_entity_picker(*,
raise ValueError("Unsupported type") raise ValueError("Unsupported type")
page_size = await Settings.get(Settings.PAGE_SIZE) page_size = await Settings.get(Settings.PAGE_SIZE)
form_list = None
if issubclass(type_, BotEnum): if issubclass(type_, BotEnum):
items_count = len(type_.all_members) items_count = len(type_.all_members)
total_pages = calc_total_pages(items_count, page_size) total_pages = calc_total_pages(items_count, page_size)
page = min(page, total_pages) page = min(page, total_pages)
enum_items = list(type_.all_members.values())[page_size * (page - 1):page_size * page] enum_items = list(type_.all_members.values())[
items = [{"text": f"{"" if not is_list else "【✔︎】 " if item in (current_value or []) else "【 】 "}{item.localized(user.lang)}", page_size * (page - 1) : page_size * page
"value": item.value} for item in enum_items] ]
else: 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) 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 form_list.filtering:
if (EntityPermission.LIST_ALL in permissions or entity_filter = await ViewSetting.get_filter(
(EntityPermission.LIST in permissions and session=db_session,
not issubclass(type_, OwnedBotEntity))): user_id=user.id,
items_count = await type_.get_count(session = db_session, filter = entity_filter) entity_name=type_.bot_entity_descriptor.class_name,
total_pages = calc_total_pages(items_count, page_size) )
page = min(page, total_pages) 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( entity_items = await type_.get_multi(
session = db_session, order_by = type_.name, filter = entity_filter, session=db_session,
skip = page_size * (page - 1), limit = page_size) order_by=form_list.order_by,
elif (EntityPermission.LIST in permissions and static_filter=(
issubclass(type_, OwnedBotEntity)): [
items_count = await type_.get_count_by_user(session = db_session, user_id = user.id, filter = entity_filter) Filter(
total_pages = calc_total_pages(items_count, page_size) field_name=f.field_name,
page = min(page, total_pages) operator=f.operator,
entity_items = await type_.get_multi_by_user( value_type="const",
session = db_session, user_id = user.id, order_by = type_.name, filter = entity_filter, value=f.value,
skip = page_size * (page - 1), limit = page_size) )
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: else:
items_count = 0 items_count = 0
total_pages = 1 total_pages = 1
page = 1 page = 1
entity_items = list[BotEntity]() entity_items = list[BotEntity]()
items = [{"text": f"{"" if not is_list else "【✔︎】 " if item in (current_value or []) else "【 】 "}{ items = [
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}", "text": f"{
"value": str(item.id)} for item in entity_items] ''
if not is_list
# total_pages = items_count // page_size + (1 if items_count % page_size else 0) 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() keyboard_builder = InlineKeyboardBuilder()
for item in items: for item in items:
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton(text = item["text"], InlineKeyboardButton(
callback_data = ContextData( text=item["text"],
command = CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM if is_list else CallbackCommand.FIELD_EDITOR_CALLBACK, callback_data=ContextData(
context = callback_data.context, command=(
entity_name = callback_data.entity_name, CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM
entity_id = callback_data.entity_id, if is_list
field_name = callback_data.field_name, else CallbackCommand.FIELD_EDITOR_CALLBACK
data = f"{page}&{item['value']}" if is_list else item["value"], ),
save_state = True).pack())) 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(),
)
)
add_pagination_controls(keyboard_builder = keyboard_builder, if form_list and form_list.pagination:
callback_data = callback_data, add_pagination_controls(
total_pages = total_pages, keyboard_builder=keyboard_builder,
command = CallbackCommand.ENTITY_PICKER_PAGE, callback_data=callback_data,
page = page) total_pages=total_pages,
command=CallbackCommand.ENTITY_PICKER_PAGE,
page=page,
)
if issubclass(type_, BotEntity): if (
add_filter_controls(keyboard_builder = keyboard_builder, issubclass(type_, BotEntity)
entity_descriptor = type_.bot_entity_descriptor, and form_list.filtering
filter = entity_filter) and form_list.filtering_fields
):
add_filter_controls(
keyboard_builder=keyboard_builder,
entity_descriptor=type_.bot_entity_descriptor,
filter=entity_filter,
)
if is_list: if is_list:
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton(text = await Settings.get(Settings.APP_STRINGS_DONE_BTN), InlineKeyboardButton(
callback_data = ContextData( text=await Settings.get(Settings.APP_STRINGS_DONE_BTN),
command = CallbackCommand.FIELD_EDITOR_CALLBACK, callback_data=ContextData(
context = callback_data.context, command=CallbackCommand.FIELD_EDITOR_CALLBACK,
entity_name = callback_data.entity_name, context=callback_data.context,
entity_id = callback_data.entity_id, entity_name=callback_data.entity_name,
field_name = callback_data.field_name, entity_id=callback_data.entity_id,
save_state = True).pack())) field_name=callback_data.field_name,
form_params=callback_data.form_params,
).pack(),
)
)
state_data = kwargs["state_data"] state_data = kwargs["state_data"]
await wrap_editor(keyboard_builder = keyboard_builder, await wrap_editor(
field_descriptor = field_descriptor, keyboard_builder=keyboard_builder,
callback_data = callback_data, field_descriptor=field_descriptor,
state_data = state_data) callback_data=callback_data,
state_data=state_data,
)
await state.set_data(state_data) await state.set_data(state_data)
send_message = get_send_message(message) 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(
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM)) ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_PAGE)
async def entity_picker_callback(query: CallbackQuery, )
callback_data: ContextData, @router.callback_query(
db_session: AsyncSession, ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM)
app: "QBotApp", )
state: FSMContext, async def entity_picker_callback(
**kwargs): query: CallbackQuery,
callback_data: ContextData,
db_session: AsyncSession,
app: "QBotApp",
state: FSMContext,
**kwargs,
):
state_data = await state.get_data() state_data = await state.get_data()
kwargs["state_data"] = state_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"] 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: if callback_data.command == CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM:
page, id_value = callback_data.data.split("&") page, id_value = callback_data.data.split("&")
@@ -208,7 +331,7 @@ async def entity_picker_callback(query: CallbackQuery,
else: else:
value.append(item) value.append(item)
else: 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: if item in value:
value.remove(item) value.remove(item)
else: else:
@@ -222,15 +345,15 @@ async def entity_picker_callback(query: CallbackQuery,
else: else:
raise ValueError("Unsupported command") raise ValueError("Unsupported command")
await render_entity_picker(field_descriptor = field_descriptor, await render_entity_picker(
message = query, field_descriptor=field_descriptor,
callback_data = callback_data, message=query,
current_value = value, callback_data=callback_data,
edit_prompt = edit_prompt, current_value=value,
db_session = db_session, edit_prompt=edit_prompt,
app = app, db_session=db_session,
state = state, app=app,
page = page, state=state,
**kwargs) page=page,
**kwargs,
)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -1,98 +1,97 @@
from types import UnionType
from aiogram import Router from aiogram import Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, CopyTextButton from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, CopyTextButton
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger 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.language import LanguageBase
from ....model.settings import Settings 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 ..context import ContextData, CallbackCommand
from ..common import get_send_message, get_local_text from .wrapper import wrap_editor
from .common import wrap_editor
logger = getLogger(__name__) logger = getLogger(__name__)
router = Router() router = Router()
async def string_editor(message: Message | CallbackQuery, async def string_editor(
field_descriptor: EntityFieldDescriptor, message: Message | CallbackQuery,
callback_data: ContextData, field_descriptor: EntityFieldDescriptor,
current_value: Any, callback_data: ContextData,
edit_prompt: str, current_value: Any,
state: FSMContext, edit_prompt: str,
locale_index: int = 0, state: FSMContext,
**kwargs): locale_index: int = 0,
**kwargs,
):
keyboard_builder = InlineKeyboardBuilder() keyboard_builder = InlineKeyboardBuilder()
state_data: dict = kwargs["state_data"] state_data: dict = kwargs["state_data"]
_edit_prompt = edit_prompt _edit_prompt = edit_prompt
# type_ = field_descriptor.type_ context_data = ContextData(
# type_origin = get_origin(type_) command=CallbackCommand.FIELD_EDITOR_CALLBACK,
# if type_origin == UnionType: context=callback_data.context,
# type_ = get_args(type_)[0] entity_name=callback_data.entity_name,
entity_id=callback_data.entity_id,
if field_descriptor.type_base == str and field_descriptor.localizable: field_name=callback_data.field_name,
form_params=callback_data.form_params,
)
if field_descriptor.type_base is str and field_descriptor.localizable:
current_locale = list(LanguageBase.all_members.values())[locale_index] 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( _edit_prompt = f"{edit_prompt}\n{
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 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({ state_data.update(
"context_data": context_data.pack(), {
"edit_prompt": edit_prompt, "context_data": context_data.pack(),
"locale_index": str(locale_index), "edit_prompt": edit_prompt,
"current_value": current_value}) "locale_index": str(locale_index),
"current_value": current_value,
}
)
else: 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)
_current_value = serialize(current_value, field_descriptor) _current_value = serialize(current_value, field_descriptor)
state_data.update({ state_data.update({"context_data": context_data.pack()})
"context_data": context_data.pack()})
if _current_value: if _current_value:
_current_value_caption = (
_current_value_caption = f"{_current_value[:30]}..." if len(_current_value) > 30 else _current_value f"{_current_value[:30]}..." if len(_current_value) > 30 else _current_value
keyboard_builder.row(InlineKeyboardButton(text = _current_value_caption, )
copy_text = CopyTextButton(text = _current_value))) keyboard_builder.row(
InlineKeyboardButton(
text=_current_value_caption,
copy_text=CopyTextButton(text=_current_value),
)
)
state_data = kwargs["state_data"] state_data = kwargs["state_data"]
await wrap_editor(keyboard_builder = keyboard_builder, await wrap_editor(
field_descriptor = field_descriptor, keyboard_builder=keyboard_builder,
callback_data = callback_data, field_descriptor=field_descriptor,
state_data = state_data) callback_data=callback_data,
state_data=state_data,
)
await state.set_data(state_data) await state.set_data(state_data)
send_message = get_send_message(message) 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())
# async def context_command_fiter(*args, **kwargs):
# print(args, kwargs)
# return True

View File

@@ -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(),
)
)

View File

@@ -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 import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.i18n import I18n
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ....model.bot_entity import BotEntity from ....model.descriptors import FieldEditButton, CommandButton
from ....model.bot_enum import BotEnum
from ....model.owned_bot_entity import OwnedBotEntity
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase from ....model.user import UserBase
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....model import EntityPermission 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 ..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: if TYPE_CHECKING:
from ....main import QBotApp from ....main import QBotApp
@@ -30,136 +34,207 @@ router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_ITEM)) @router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_ITEM))
async def entity_item_callback(query: CallbackQuery, **kwargs): async def entity_item_callback(query: CallbackQuery, **kwargs):
callback_data: ContextData = kwargs["callback_data"] callback_data: ContextData = kwargs["callback_data"]
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = await state.get_data() state_data = await state.get_data()
kwargs["state_data"] = state_data kwargs["state_data"] = state_data
clear_state(state_data = state_data) clear_state(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 entity_item(query = query, navigation_stack = stack, **kwargs) await entity_item(query=query, navigation_stack=stack, **kwargs)
async def entity_item(query: CallbackQuery, async def entity_item(
callback_data: ContextData, query: CallbackQuery,
db_session: AsyncSession, callback_data: ContextData,
user: UserBase, db_session: AsyncSession,
app: "QBotApp", user: UserBase,
navigation_stack: list[ContextData], app: "QBotApp",
**kwargs): navigation_stack: list[ContextData],
**kwargs,
):
entity_descriptor = get_entity_descriptor(app, callback_data) entity_descriptor = get_entity_descriptor(app, callback_data)
user_permissions = get_user_permissions(user, entity_descriptor) # user_permissions = get_user_permissions(user, entity_descriptor)
entity_type: BotEntity = entity_descriptor.type_ entity_type = entity_descriptor.type_
keyboard_builder = InlineKeyboardBuilder() 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: 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 if not check_entity_permission(
EntityPermission.READ_ALL not in user_permissions): 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 form = entity_descriptor.forms.get(
EntityPermission.READ_ALL not in user_permissions and callback_data.form_params or "default", entity_descriptor.default_form
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))
if can_edit: if can_edit:
for edit_buttons_row in entity_descriptor.edit_buttons: for edit_buttons_row in form.form_buttons:
btn_row = [] btn_row = []
for field_name in edit_buttons_row: for button in edit_buttons_row:
field_name, btn_caption = field_name if isinstance(field_name, tuple) else (field_name, None) if isinstance(button, FieldEditButton):
if field_name in entity_descriptor.fields_descriptors: field_name = button.field_name
field_descriptor = entity_descriptor.fields_descriptors[field_name] btn_caption = button.caption
# if field_descriptor.is_list and issubclass(field_descriptor.type_base, BotEntity): if field_name in entity_descriptor.fields_descriptors:
# await field_descriptor.type_base. field_descriptor = entity_descriptor.fields_descriptors[
field_value = getattr(entity_item, field_descriptor.field_name) field_name
if btn_caption: ]
btn_text = get_callable_str(btn_caption, field_descriptor, entity_item, field_value) field_value = getattr(entity_item, field_descriptor.field_name)
else: if btn_caption:
if field_descriptor.type_base == bool: btn_text = get_callable_str(
btn_text = f"{"【✔︎】 " if field_value else "【 】 "}{ btn_caption, field_descriptor, entity_item, field_value
get_callable_str(field_descriptor.caption, field_descriptor, entity_item, field_value) if field_descriptor.caption )
else field_name}"
else: else:
btn_text = (f"✏️ {get_callable_str(field_descriptor.caption, field_descriptor, entity_item, field_value)}" if field_descriptor.type_base is bool:
if field_descriptor.caption else f"✏️ {field_name}") 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( btn_row.append(
InlineKeyboardButton( InlineKeyboardButton(
text = btn_text, text=btn_text,
callback_data = ContextData( callback_data=(
command = CallbackCommand.FIELD_EDITOR, button.context_data.pack()
context = CommandContext.ENTITY_FIELD_EDIT, if button.context_data
entity_name = entity_descriptor.name, else ContextData(
entity_id = str(entity_item.id), command=CallbackCommand.USER_COMMAND,
field_name = field_name).pack())) user_command=button.command,
data=str(entity_item.id),
).pack()
),
)
)
if btn_row: if btn_row:
keyboard_builder.row(*btn_row) keyboard_builder.row(*btn_row)
edit_delete_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( edit_delete_row.append(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_EDIT_BTN)), text=(await Settings.get(Settings.APP_STRINGS_EDIT_BTN)),
callback_data = ContextData( callback_data=ContextData(
command = CallbackCommand.FIELD_EDITOR, command=CallbackCommand.FIELD_EDITOR,
context = CommandContext.ENTITY_EDIT, context=CommandContext.ENTITY_EDIT,
entity_name = entity_descriptor.name, entity_name=entity_descriptor.name,
entity_id = str(entity_item.id), entity_id=str(entity_item.id),
field_name = entity_descriptor.field_sequence[0]).pack())) form_params=callback_data.form_params,
field_name=form.edit_field_sequence[0],
).pack(),
)
)
if can_delete: if (
check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.DELETE
)
and form.show_delete_button
):
edit_delete_row.append( edit_delete_row.append(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_DELETE_BTN)), text=(await Settings.get(Settings.APP_STRINGS_DELETE_BTN)),
callback_data = ContextData( callback_data=ContextData(
command = CallbackCommand.ENTITY_DELETE, command=CallbackCommand.ENTITY_DELETE,
entity_name = entity_descriptor.name, entity_name=entity_descriptor.name,
entity_id = str(entity_item.id)).pack())) form_params=callback_data.form_params,
entity_id=str(entity_item.id),
).pack(),
)
)
if edit_delete_row: if edit_delete_row:
keyboard_builder.row(*edit_delete_row) keyboard_builder.row(*edit_delete_row)
entity_caption = get_callable_str(entity_descriptor.caption, entity_descriptor, entity_item) if form.item_repr:
entity_item_name = get_local_text(entity_item.name, user.lang) if entity_descriptor.fields_descriptors["name"].localizable else entity_item.name 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"<b><u><i>{entity_caption or entity_descriptor.name}:</i></u></b> <b>{entity_item_name}</b>" entity_item_repr = (
get_callable_str(
entity_descriptor.item_repr, entity_descriptor, entity_item
)
if entity_descriptor.item_repr
else str(entity_item.id)
)
for field_descriptor in entity_descriptor.fields_descriptors.values(): item_text = f"<b><u><i>{entity_caption or entity_descriptor.name}:</i></u></b> <b>{entity_item_repr}</b>"
if field_descriptor.name == "name" or not field_descriptor.is_visible:
continue for field_descriptor in entity_descriptor.fields_descriptors.values():
field_caption = get_callable_str(field_descriptor.caption, field_descriptor, entity_item) if field_descriptor.is_visible:
value = get_value_repr(value = getattr(entity_item, field_descriptor.name), field_caption = get_callable_str(
field_descriptor = field_descriptor, field_descriptor.caption, field_descriptor, entity_item
locale = user.lang) )
item_text += f"\n{field_caption or field_descriptor.name}:{f" <b>{value}</b>" if value else ""}" 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' <b>{value}</b>' if value else ''}"
context = pop_navigation_context(navigation_stack) context = pop_navigation_context(navigation_stack)
if context: if context:
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = context.pack())) callback_data=context.pack(),
)
)
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = kwargs["state_data"] state_data = kwargs["state_data"]
@@ -167,67 +242,4 @@ async def entity_item(query: CallbackQuery,
send_message = get_send_message(query) send_message = get_send_message(query)
await send_message(text = item_text, reply_markup = keyboard_builder.as_markup()) 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

View File

@@ -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(),
)

View File

@@ -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 import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.i18n import I18n
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ....model.bot_entity import BotEntity 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.settings import Settings
from ....model.user import UserBase from ....model.user import UserBase
from ....model.view_setting import ViewSetting from ....model.view_setting import ViewSetting
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor from ....model.descriptors import EntityDescriptor, Filter
from ....model import EntityPermission 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 ..context import ContextData, CallbackCommand, CommandContext
from ..common import (add_pagination_controls, get_local_text, get_entity_descriptor, from ..common.pagination import add_pagination_controls
get_callable_str, get_send_message, add_filter_controls) from ..common.filtering import add_filter_controls
from ..navigation import pop_navigation_context, save_navigation_context
if TYPE_CHECKING: if TYPE_CHECKING:
from ....main import QBotApp from ....main import QBotApp
@@ -32,7 +35,6 @@ router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_LIST)) @router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_LIST))
async def entity_list_callback(query: CallbackQuery, **kwargs): async def entity_list_callback(query: CallbackQuery, **kwargs):
callback_data: ContextData = kwargs["callback_data"] callback_data: ContextData = kwargs["callback_data"]
if callback_data.data == "skip": if callback_data.data == "skip":
@@ -42,125 +44,217 @@ async def entity_list_callback(query: CallbackQuery, **kwargs):
state_data = await state.get_data() state_data = await state.get_data()
kwargs["state_data"] = state_data kwargs["state_data"] = state_data
clear_state(state_data = state_data) clear_state(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 entity_list(message=query, navigation_stack=stack, **kwargs)
await entity_list(message = query, navigation_stack = stack, **kwargs)
def calc_total_pages(items_count: int, page_size: int) -> int: 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") page = int(callback_data.data or "1")
entity_descriptor = get_entity_descriptor(app, callback_data) entity_descriptor = get_entity_descriptor(app, callback_data)
user_permissions = get_user_permissions(user, entity_descriptor) user_permissions = get_user_permissions(user, entity_descriptor)
entity_type = entity_descriptor.type_ 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() 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( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_ADD_BTN)), text=(await Settings.get(Settings.APP_STRINGS_ADD_BTN)),
callback_data = ContextData( callback_data=ContextData(
command = CallbackCommand.FIELD_EDITOR, command=CallbackCommand.FIELD_EDITOR,
context = CommandContext.ENTITY_CREATE, context=CommandContext.ENTITY_CREATE,
entity_name = entity_descriptor.name, entity_name=entity_descriptor.name,
field_name = entity_descriptor.field_sequence[0], field_name=form_item.edit_field_sequence[0],
save_state = True).pack())) form_params=form_list.item_form,
).pack(),
)
)
page_size = await Settings.get(Settings.PAGE_SIZE) if form_list.filtering:
entity_filter = await ViewSetting.get_filter(
entity_filter = await ViewSetting.get_filter(session = db_session, user_id = user.id, entity_name = entity_descriptor.class_name) session=db_session,
user_id=user.id,
if issubclass(entity_type, OwnedBotEntity): entity_name=entity_descriptor.class_name,
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
else: 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: for item in items:
if entity_descriptor.item_caption: if form_list.item_repr:
caption = entity_descriptor.item_caption(entity_descriptor, item) caption = form_list.item_repr(entity_descriptor, item)
elif entity_descriptor.fields_descriptors["name"].localizable: elif entity_descriptor.item_repr:
caption = get_local_text(item.name, user.lang) 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: else:
caption = item.name caption = f"{entity_descriptor.name}: {item.id}"
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = caption, text=caption,
callback_data = ContextData( callback_data=ContextData(
command = CallbackCommand.ENTITY_ITEM, command=CallbackCommand.ENTITY_ITEM,
entity_name = entity_descriptor.name, entity_name=entity_descriptor.name,
entity_id = str(item.id)).pack())) form_params=form_list.item_form,
entity_id=str(item.id),
).pack(),
)
)
add_pagination_controls(keyboard_builder = keyboard_builder, if form_list.pagination:
callback_data = callback_data, add_pagination_controls(
total_pages = total_pages, keyboard_builder=keyboard_builder,
command = CallbackCommand.ENTITY_LIST, callback_data=callback_data,
page = page) total_pages=total_pages,
command=CallbackCommand.ENTITY_LIST,
page=page,
)
add_filter_controls(keyboard_builder = keyboard_builder, if form_list.filtering and form_list.filtering_fields:
entity_descriptor = entity_descriptor, add_filter_controls(
filter = entity_filter) keyboard_builder=keyboard_builder,
entity_descriptor=entity_descriptor,
filter=entity_filter,
)
context = pop_navigation_context(navigation_stack) context = pop_navigation_context(navigation_stack)
if context: if context:
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = context.pack())) callback_data=context.pack(),
)
)
if entity_descriptor.caption: if form_list.caption:
entity_text = get_callable_str(entity_descriptor.caption_plural, entity_descriptor) entity_text = get_callable_str(form_list.caption, entity_descriptor)
else: else:
entity_text = entity_descriptor.name if entity_descriptor.full_name_plural:
if entity_descriptor.description: entity_text = get_callable_str(
entity_desciption = get_callable_str(entity_descriptor.description, entity_descriptor) entity_descriptor.full_name_plural, entity_descriptor
else: )
entity_desciption = None 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: FSMContext = kwargs["state"]
state_data = kwargs["state_data"] state_data = kwargs["state_data"]
@@ -168,8 +262,4 @@ async def entity_list(message: CallbackQuery | Message,
send_message = get_send_message(message) send_message = get_send_message(message)
await send_message(text = f"{entity_text}{f"\n{entity_desciption}" if entity_desciption else ""}", await send_message(text=entity_text, reply_markup=keyboard_builder.as_markup())
reply_markup = keyboard_builder.as_markup())
from ..navigation import pop_navigation_context, save_navigation_context, clear_state

View File

@@ -4,13 +4,12 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from babel.support import LazyProxy from babel.support import LazyProxy
from logging import getLogger from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand from ..context import ContextData, CallbackCommand
from ..common import get_send_message from ....utils.main import get_send_message
from ....model.descriptors import EntityCaptionCallable from ....model.descriptors import EntityCaptionCallable
from ..navigation import save_navigation_context, pop_navigation_context
if TYPE_CHECKING: if TYPE_CHECKING:
from ....main import QBotApp from ....main import QBotApp
@@ -20,55 +19,63 @@ logger = getLogger(__name__)
router = Router() 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): async def menu_entry_entities(message: CallbackQuery, **kwargs):
callback_data: ContextData = kwargs["callback_data"] callback_data: ContextData = kwargs["callback_data"]
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = await state.get_data() state_data = await state.get_data()
kwargs["state_data"] = state_data kwargs["state_data"] = state_data
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, async def entities_menu(
app: "QBotApp", message: Message | CallbackQuery,
state: FSMContext, app: "QBotApp",
navigation_stack: list[ContextData], state: FSMContext,
**kwargs): navigation_stack: list[ContextData],
**kwargs,
):
keyboard_builder = InlineKeyboardBuilder() keyboard_builder = InlineKeyboardBuilder()
entity_metadata = app.entity_metadata entity_metadata = app.entity_metadata
for entity in entity_metadata.entity_descriptors.values(): for entity in entity_metadata.entity_descriptors.values():
if entity.caption_plural.__class__ == EntityCaptionCallable: if entity.full_name_plural.__class__ == EntityCaptionCallable:
caption = entity.caption_plural(entity) or entity.name caption = entity.full_name_plural(entity) or entity.name
elif entity.caption_plural.__class__ == LazyProxy: elif entity.full_name_plural.__class__ == LazyProxy:
caption = f"{f"{entity.icon} " if entity.icon else ""}{entity.caption_plural.value or entity.name}" caption = f"{f'{entity.icon} ' if entity.icon else ''}{entity.full_name_plural.value or entity.name}"
else: 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( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = caption, text=caption,
callback_data = ContextData(command = CallbackCommand.ENTITY_LIST, entity_name = entity.name).pack())) callback_data=ContextData(
command=CallbackCommand.ENTITY_LIST, entity_name=entity.name
).pack(),
)
)
context = pop_navigation_context(navigation_stack) context = pop_navigation_context(navigation_stack)
if context: if context:
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = context.pack())) callback_data=context.pack(),
)
)
state_data = kwargs["state_data"] state_data = kwargs["state_data"]
await state.set_data(state_data) await state.set_data(state_data)
send_message = get_send_message(message) send_message = get_send_message(message)
await send_message(text = (await Settings.get(Settings.APP_STRINGS_REFERENCES)), reply_markup = keyboard_builder.as_markup()) 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 )

View File

@@ -1,64 +1,85 @@
from aiogram import Router, F 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.fsm.context import FSMContext
from aiogram.utils.i18n import I18n from aiogram.utils.i18n import I18n
from logging import getLogger from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..navigation import pop_navigation_context, save_navigation_context
from ....model.language import LanguageBase from ....model.language import LanguageBase
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase from ....model.user import UserBase
from ..context import ContextData, CallbackCommand from ..context import ContextData, CallbackCommand
from ..navigation import route_callback from ..common.routing import route_callback
from ..common import get_send_message from ....utils.main import get_send_message
logger = getLogger(__name__) logger = getLogger(__name__)
router = Router() 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): async def menu_entry_language(message: CallbackQuery, **kwargs):
callback_data: ContextData = kwargs["callback_data"] callback_data: ContextData = kwargs["callback_data"]
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = await state.get_data() state_data = await state.get_data()
kwargs["state_data"] = state_data kwargs["state_data"] = state_data
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, async def language_menu(
navigation_stack: list[ContextData], message: Message | CallbackQuery,
user: UserBase, navigation_stack: list[ContextData],
**kwargs): user: UserBase,
**kwargs,
):
send_message = get_send_message(message) send_message = get_send_message(message)
inline_keyboard = [ inline_keyboard = [
[InlineKeyboardButton(text = locale.localized(user.lang), [
callback_data = ContextData(command = CallbackCommand.SET_LANGUAGE, InlineKeyboardButton(
data = str(locale)).pack())] text=locale.localized(user.lang),
for locale in LanguageBase.all_members.values()] callback_data=ContextData(
command=CallbackCommand.SET_LANGUAGE, data=str(locale)
).pack(),
)
]
for locale in LanguageBase.all_members.values()
]
context = pop_navigation_context(navigation_stack) context = pop_navigation_context(navigation_stack)
if context: if context:
inline_keyboard.append([InlineKeyboardButton(text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), inline_keyboard.append(
callback_data = context.pack())]) [
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data=context.pack(),
)
]
)
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = kwargs["state_data"] state_data = kwargs["state_data"]
await state.set_data(state_data) await state.set_data(state_data)
await send_message(text = (await Settings.get(Settings.APP_STRINGS_LANGUAGE)), await send_message(
reply_markup = InlineKeyboardMarkup(inline_keyboard = inline_keyboard)) 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)) @router.callback_query(ContextData.filter(F.command == CallbackCommand.SET_LANGUAGE))
async def set_language(message: CallbackQuery, **kwargs): async def set_language(message: CallbackQuery, **kwargs):
user: UserBase = kwargs["user"] user: UserBase = kwargs["user"]
callback_data: ContextData = kwargs["callback_data"] callback_data: ContextData = kwargs["callback_data"]
db_session: AsyncSession = kwargs["db_session"] db_session: AsyncSession = kwargs["db_session"]
@@ -73,6 +94,3 @@ async def set_language(message: CallbackQuery, **kwargs):
i18n: I18n = kwargs["i18n"] i18n: I18n = kwargs["i18n"]
with i18n.use_locale(user.lang): with i18n.use_locale(user.lang):
await route_callback(message, **kwargs) await route_callback(message, **kwargs)
from ..navigation import pop_navigation_context, save_navigation_context

View File

@@ -1,93 +1,88 @@
from aiogram import Router, F 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.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand 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__) logger = getLogger(__name__)
router = Router() 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)) @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 = InlineKeyboardBuilder()
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_REFERENCES_BTN)), text=(await Settings.get(Settings.APP_STRINGS_REFERENCES_BTN)),
callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_ENTITIES).pack())) callback_data=ContextData(
command=CallbackCommand.MENU_ENTRY_ENTITIES
).pack(),
)
)
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_SETTINGS_BTN)), text=(await Settings.get(Settings.APP_STRINGS_SETTINGS_BTN)),
callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_SETTINGS).pack())) callback_data=ContextData(
command=CallbackCommand.MENU_ENTRY_SETTINGS
).pack(),
)
)
context = pop_navigation_context(navigation_stack) context = pop_navigation_context(navigation_stack)
if context: if context:
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = context.pack())) callback_data=context.pack(),
)
)
send_message = get_send_message(message) send_message = get_send_message(message)
await send_message(text = (await Settings.get(Settings.APP_STRINGS_MAIN_NENU)), await send_message(
reply_markup = keyboard_builder.as_markup()) 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( router.include_routers(
entities_router, entities.router,
settings_router, settings.router,
parameters_router, parameters.router,
language_router, language.router,
editors_router, editor.router,
entity_list_router, editor_callbacks.router,
entity_form_router, entity_list.router,
common_router, entity_form.router,
user_handlers_router entity_form_callbacks.router,
filtering_callbacks.router,
user_handlers.router,
) )
from ..navigation import save_navigation_context, pop_navigation_context, clear_state

View File

@@ -1,79 +1,94 @@
from aiogram import Router, F from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.i18n import I18n
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase from ....model.user import UserBase
from ..context import ContextData, CallbackCommand, CommandContext 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 ..navigation import save_navigation_context, pop_navigation_context
from ....auth import authorize_command
logger = getLogger(__name__) logger = getLogger(__name__)
router = Router() 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): async def menu_entry_parameters(message: CallbackQuery, **kwargs):
callback_data: ContextData = kwargs["callback_data"] callback_data: ContextData = kwargs["callback_data"]
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = await state.get_data() state_data = await state.get_data()
kwargs["state_data"] = state_data kwargs["state_data"] = state_data
clear_state(state_data = state_data) clear_state(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 parameters_menu(message = message, navigation_stack = stack, **kwargs) await parameters_menu(message=message, navigation_stack=stack, **kwargs)
async def parameters_menu(message: Message | CallbackQuery, async def parameters_menu(
user: UserBase, message: Message | CallbackQuery,
callback_data: ContextData, user: UserBase,
navigation_stack: list[ContextData], callback_data: ContextData,
**kwargs): navigation_stack: list[ContextData],
**kwargs,
if not await authorize_command(user = user, callback_data = callback_data): ):
if not await authorize_command(user=user, callback_data=callback_data):
await message.answer(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) await message.answer(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
settings = await Settings.get_params() settings = await Settings.get_params()
keyboard_builder = InlineKeyboardBuilder() keyboard_builder = InlineKeyboardBuilder()
for key, value in settings.items(): for key, value in settings.items():
if not key.is_visible: if not key.is_visible:
continue continue
if key.caption_value: 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: else:
if key.caption: 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: else:
caption = key.name caption = key.name
if key.type_ == bool: if key.type_ is bool:
caption = f"{"【✔︎】" if value else "【 】"} {caption}" caption = f"{'【✔︎】' if value else '【 】'} {caption}"
else: else:
caption = f"{caption}: {get_value_repr(value = value, field_descriptor = key, locale = user.lang)}" caption = f"{caption}: {get_value_repr(value=value, field_descriptor=key, locale=user.lang)}"
keyboard_builder.row(
keyboard_builder.row(InlineKeyboardButton(text = caption, InlineKeyboardButton(
callback_data = ContextData( text=caption,
command = CallbackCommand.FIELD_EDITOR, callback_data=ContextData(
context = CommandContext.SETTING_EDIT, command=CallbackCommand.FIELD_EDITOR,
field_name = key.name).pack())) context=CommandContext.SETTING_EDIT,
field_name=key.name,
).pack(),
)
)
context = pop_navigation_context(navigation_stack) context = pop_navigation_context(navigation_stack)
if context: if context:
keyboard_builder.row(InlineKeyboardButton(text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), keyboard_builder.row(
callback_data = context.pack())) InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data=context.pack(),
)
)
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = kwargs["state_data"] state_data = kwargs["state_data"]
@@ -81,8 +96,7 @@ async def parameters_menu(message: Message | CallbackQuery,
send_message = get_send_message(message) send_message = get_send_message(message)
await send_message(text = (await Settings.get(Settings.APP_STRINGS_PARAMETERS)), reply_markup = keyboard_builder.as_markup()) 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

View File

@@ -1,58 +1,72 @@
from aiogram import Router, F from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase from ....model.user import UserBase
from ....utils.main import get_send_message
from ..context import ContextData, CallbackCommand 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 from ..navigation import save_navigation_context, pop_navigation_context
logger = getLogger(__name__) logger = getLogger(__name__)
router = Router() 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): async def menu_entry_settings(message: CallbackQuery, **kwargs):
callback_data: ContextData = kwargs["callback_data"] callback_data: ContextData = kwargs["callback_data"]
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = await state.get_data() state_data = await state.get_data()
kwargs["state_data"] = state_data kwargs["state_data"] = state_data
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, async def settings_menu(
user: UserBase, message: Message | CallbackQuery,
navigation_stack: list[ContextData], user: UserBase,
**kwargs): navigation_stack: list[ContextData],
**kwargs,
):
keyboard_builder = InlineKeyboardBuilder() 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( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_PARAMETERS_BTN)), text=(await Settings.get(Settings.APP_STRINGS_PARAMETERS_BTN)),
callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_PARAMETERS).pack())) callback_data=ContextData(
command=CallbackCommand.MENU_ENTRY_PARAMETERS
).pack(),
)
)
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_LANGUAGE_BTN)), text=(await Settings.get(Settings.APP_STRINGS_LANGUAGE_BTN)),
callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_LANGUAGE).pack())) callback_data=ContextData(
command=CallbackCommand.MENU_ENTRY_LANGUAGE
).pack(),
)
)
context = pop_navigation_context(navigation_stack) context = pop_navigation_context(navigation_stack)
if context: if context:
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = context.pack())) callback_data=context.pack(),
)
)
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = kwargs["state_data"] state_data = kwargs["state_data"]
@@ -60,9 +74,7 @@ async def settings_menu(message: Message | CallbackQuery,
send_message = get_send_message(message) send_message = get_send_message(message)
await send_message(
text=(await Settings.get(Settings.APP_STRINGS_SETTINGS)),
await send_message(text = (await Settings.get(Settings.APP_STRINGS_SETTINGS)), reply_markup = keyboard_builder.as_markup()) reply_markup=keyboard_builder.as_markup(),
)
from ..navigation import pop_navigation_context, get_navigation_context

View File

@@ -1,11 +1,12 @@
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery
from .context import ContextData, CallbackCommand from .context import ContextData, CallbackCommand
def save_navigation_context(callback_data: ContextData, state_data: dict) -> list[ContextData]: def save_navigation_context(
stack = [ContextData.unpack(item) for item in state_data.get("navigation_stack", [])] 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") data_nc = state_data.get("navigation_context")
navigation_context = ContextData.unpack(data_nc) if data_nc else None navigation_context = ContextData.unpack(data_nc) if data_nc else None
if callback_data.back: if callback_data.back:
@@ -13,9 +14,12 @@ def save_navigation_context(callback_data: ContextData, state_data: dict) -> lis
if stack: if stack:
stack.pop() stack.pop()
else: else:
if (stack and navigation_context and if (
navigation_context.command == callback_data.command and stack
navigation_context.command != CallbackCommand.USER_COMMAND): and navigation_context
and navigation_context.command == callback_data.command
and navigation_context.command != CallbackCommand.USER_COMMAND
):
navigation_context = callback_data navigation_context = callback_data
elif navigation_context: elif navigation_context:
stack.append(navigation_context) stack.append(navigation_context)
@@ -33,63 +37,12 @@ def pop_navigation_context(stack: list[ContextData]) -> ContextData | None:
return data 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") data_nc = state_data.get("navigation_context")
context = ContextData.unpack(data_nc) if data_nc else None context = ContextData.unpack(data_nc) if data_nc else None
return ([ContextData.unpack(item) for item in state_data.get("navigation_stack", [])], return (
context) [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

View File

@@ -8,7 +8,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ...main import QBotApp from ...main import QBotApp
from ...model.settings import Settings from ...model.settings import Settings
from ...model.language import LanguageBase from ...model.language import LanguageBase
from .navigation import clear_state from ...utils.main import clear_state
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -16,40 +16,56 @@ router = Router()
@router.message(CommandStart()) @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() 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 = 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: 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: 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) 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)
except Exception as e:
logger.error("Error creating user", exc_info = True) user = await User.create(
message.answer((await Settings.get(Settings.APP_STRINGS_INTERNAL_ERROR_P_ERROR)).format(error = str(e))) 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))
)
return return
else: else:
if user.is_active: 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: 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) await message.answer(msg_text)

View File

@@ -1,14 +1,16 @@
from dataclasses import dataclass, field from typing import TYPE_CHECKING
from typing import Any, Callable, TYPE_CHECKING
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.fsm.context import FSMContext 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 ..context import ContextData, CallbackCommand
from ....model.user import UserBase
from ....model.settings import Settings 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: if TYPE_CHECKING:
@@ -18,51 +20,22 @@ if TYPE_CHECKING:
router = Router() 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("/")) @router.message(F.text.startswith("/"))
async def command_text(message: Message, **kwargs): async def command_text(message: Message, **kwargs):
str_command = message.text.lstrip("/") str_command = message.text.lstrip("/")
callback_data = ContextData(command = CallbackCommand.USER_COMMAND, callback_data = ContextData(
user_command = str_command) 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)) @router.callback_query(ContextData.filter(F.command == CallbackCommand.USER_COMMAND))
async def command_callback(message: CallbackQuery, **kwargs): 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): async def command_handler(message: Message | CallbackQuery, **kwargs):
callback_data: ContextData = kwargs.pop("callback_data") callback_data: ContextData = kwargs.pop("callback_data")
str_command = callback_data.user_command str_command = callback_data.user_command
app: "QBotApp" = kwargs.pop("app") app: "QBotApp" = kwargs.pop("app")
@@ -75,40 +48,43 @@ async def command_handler(message: Message | CallbackQuery, **kwargs):
state_data = await state.get_data() state_data = await state.get_data()
if command.register_navigation: if command.register_navigation:
clear_state(state_data = state_data) clear_state(state_data=state_data)
if command.clear_navigation: if command.clear_navigation:
state_data.pop("navigation_stack", None) state_data.pop("navigation_stack", None)
state_data.pop("navigation_context", None) state_data.pop("navigation_context", None)
if command.register_navigation: 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]( callback_context = CommandCallbackContext[app.user_class](
message = message, message=message,
callback_data = callback_data, callback_data=callback_data,
db_session = kwargs.pop("db_session"), db_session=kwargs.pop("db_session"),
user = kwargs.pop("user"), user=kwargs.pop("user"),
app = app, app=app,
state_data = state_data, state_data=state_data,
state = state, state=state,
i18n = kwargs.pop("i18n"), i18n=kwargs.pop("i18n"),
kwargs = kwargs) kwargs=kwargs,
)
await command.handler(callback_context) await command.handler(callback_context)
await state.set_data(state_data) await state.set_data(state_data)
if command.register_navigation: if command.register_navigation:
stack, navigation_context = get_navigation_context(state_data=state_data)
stack, navigation_context = get_navigation_context(state_data = state_data) back_callback_data = pop_navigation_context(stack=stack)
back_callback_data = pop_navigation_context(stack = stack)
if back_callback_data: if back_callback_data:
callback_context.keyboard_builder.row( callback_context.keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)), text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = back_callback_data.pack())) callback_data=back_callback_data.pack(),
)
)
send_message = get_send_message(message) send_message = get_send_message(message)
@@ -116,10 +92,11 @@ async def command_handler(message: Message | CallbackQuery, **kwargs):
message = message.message message = message.message
if callback_context.message_text: if callback_context.message_text:
await send_message(text = callback_context.message_text, await send_message(
reply_markup = callback_context.keyboard_builder.as_markup()) text=callback_context.message_text,
reply_markup=callback_context.keyboard_builder.as_markup(),
)
else: else:
await message.edit_reply_markup(reply_markup = callback_context.keyboard_builder.as_markup()) 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

View File

@@ -1,4 +1,3 @@
from babel.support import LazyProxy
from pydantic import computed_field, model_validator from pydantic import computed_field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Literal, Self from typing import Literal, Self
@@ -6,11 +5,8 @@ import warnings
class Config(BaseSettings): class Config(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file = ".env", env_file=".env", env_ignore_empty=True, extra="ignore"
env_ignore_empty = True,
extra = "ignore"
) )
SECRET_KEY: str = "changethis" SECRET_KEY: str = "changethis"
@@ -35,14 +31,16 @@ class Config(BaseSettings):
def API_DOMAIN(self) -> str: def API_DOMAIN(self) -> str:
if self.ENVIRONMENT == "local": if self.ENVIRONMENT == "local":
return self.DOMAIN return self.DOMAIN
return f'api.{self.DOMAIN}' return f"api.{self.DOMAIN}"
@computed_field @computed_field
@property @property
def API_URL(self) -> str: def API_URL(self) -> str:
if self.USE_NGROK: if self.USE_NGROK:
return self.NGROK_URL 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 API_PORT: int = 8000
@@ -50,7 +48,7 @@ class Config(BaseSettings):
ADMIN_TELEGRAM_ID: int ADMIN_TELEGRAM_ID: int
USE_NGROK : bool = False USE_NGROK: bool = False
NGROK_AUTH_TOKEN: str = "changethis" NGROK_AUTH_TOKEN: str = "changethis"
NGROK_URL: str = "" NGROK_URL: str = ""
@@ -59,7 +57,7 @@ class Config(BaseSettings):
def _check_default_secret(self, var_name: str, value: str | None) -> None: def _check_default_secret(self, var_name: str, value: str | None) -> None:
if value == "changethis": if value == "changethis":
message = ( 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." "for security, please change it, at least for deployments."
) )
if self.ENVIRONMENT == "local": if self.ENVIRONMENT == "local":
@@ -77,4 +75,5 @@ class Config(BaseSettings):
return self return self
config = Config() config = Config()

View File

@@ -4,15 +4,16 @@ from sqlalchemy.orm import sessionmaker
from ..config import config from ..config import config
import logging # import logging
logger = logging.getLogger('sqlalchemy.engine') # logger = logging.getLogger('sqlalchemy.engine')
logger.setLevel(logging.DEBUG) # logger.setLevel(logging.DEBUG)
async_engine = create_async_engine(config.DATABASE_URI) async_engine = create_async_engine(config.DATABASE_URI)
async_session = sessionmaker[AsyncSession]( 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: async with async_session() as session:
yield session yield session

View File

@@ -1,5 +1,11 @@
from aiogram.fsm.state import State 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 sqlmodel import select
from typing import Any, Dict from typing import Any, Dict
import ujson as json import ujson as json
@@ -9,22 +15,17 @@ from ..model.fsm_storage import FSMStorage
class DbStorage(BaseStorage): 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: if key_builder is None:
key_builder = DefaultKeyBuilder() key_builder = DefaultKeyBuilder()
self.key_builder = key_builder self.key_builder = key_builder
async def set_state(self, key: StorageKey, state: StateType = None) -> None: async def set_state(self, key: StorageKey, state: StateType = None) -> None:
db_key = self.key_builder.build(key, "state") db_key = self.key_builder.build(key, "state")
async with async_session() as session: async with async_session() as session:
db_state = (await session.exec( db_state = (
select(FSMStorage).where(FSMStorage.key == db_key))).first() await session.exec(select(FSMStorage).where(FSMStorage.key == db_key))
).first()
if db_state: if db_state:
if state is None: if state is None:
@@ -32,53 +33,52 @@ class DbStorage(BaseStorage):
else: else:
db_state.value = state.state if isinstance(state, State) else state db_state.value = state.state if isinstance(state, State) else state
elif state is not None: 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) session.add(db_state)
else: else:
return return
await session.commit() await session.commit()
async def get_state(self, key: StorageKey) -> str | None: async def get_state(self, key: StorageKey) -> str | None:
db_key = self.key_builder.build(key, "state") db_key = self.key_builder.build(key, "state")
async with async_session() as session: async with async_session() as session:
db_state = (await session.exec( db_state = (
select(FSMStorage).where(FSMStorage.key == db_key))).first() await session.exec(select(FSMStorage).where(FSMStorage.key == db_key))
).first()
return db_state.value if db_state else None return db_state.value if db_state else None
async def set_data(self, key: StorageKey, data: Dict[str, Any]) -> None: async def set_data(self, key: StorageKey, data: Dict[str, Any]) -> None:
db_key = self.key_builder.build(key, "data") db_key = self.key_builder.build(key, "data")
async with async_session() as session: async with async_session() as session:
db_data = (await session.exec( db_data = (
select(FSMStorage).where(FSMStorage.key == db_key))).first() await session.exec(select(FSMStorage).where(FSMStorage.key == db_key))
).first()
if db_data: if db_data:
if not data: if not data:
await session.delete(db_data) await session.delete(db_data)
else: else:
db_data.value = json.dumps(data, ensure_ascii = False) db_data.value = json.dumps(data, ensure_ascii=False)
elif data: 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) session.add(db_data)
else: else:
return return
await session.commit() await session.commit()
async def get_data(self, key: StorageKey) -> Dict[str, Any]: async def get_data(self, key: StorageKey) -> Dict[str, Any]:
db_key = self.key_builder.build(key, "data") db_key = self.key_builder.build(key, "data")
async with async_session() as session: async with async_session() as session:
db_data = (await session.exec( db_data = (
select(FSMStorage).where(FSMStorage.key == db_key))).first() await session.exec(select(FSMStorage).where(FSMStorage.key == db_key))
).first()
return json.loads(db_data.value) if db_data else {} return json.loads(db_data.value) if db_data else {}
async def close(self): async def close(self):
return await super().close() return await super().close()

View File

@@ -5,9 +5,9 @@ from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def default_lifespan(app: QBotApp): async def default_lifespan(app: QBotApp):
logger.debug("starting qbot app") logger.debug("starting qbot app")
if app.config.USE_NGROK: if app.config.USE_NGROK:
@@ -19,30 +19,43 @@ async def default_lifespan(app: QBotApp):
logger.error("pyngrok is not installed") logger.error("pyngrok is not installed")
raise 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 app.config.NGROK_URL = tunnel.public_url
commands_captions = dict[str, list[tuple[str, str]]]() commands_captions = dict[str, list[tuple[str, str]]]()
for command_name, command in app.bot_commands.items(): for command_name, command in app.bot_commands.items():
if isinstance(command.caption, str): if command.show_in_bot_commands:
if "default" not in commands_captions: if isinstance(command.caption, str) or command.caption is None:
commands_captions["default"] = [] if "default" not in commands_captions:
commands_captions["default"].append((command_name, command.caption)) commands_captions["default"] = []
for locale, description in command.caption.items(): commands_captions["default"].append(
if locale not in commands_captions: (command_name, command.caption or command_name)
commands_captions[locale] = [] )
commands_captions[locale].append((command_name, description)) 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(): for locale, commands in commands_captions.items():
await app.bot.set_my_commands([BotCommand(command = command[0], description=command[1]) for command in commands], await app.bot.set_my_commands(
language_code = None if locale == "default" else locale) [
BotCommand(command=command[0], description=command[1])
for command in commands
],
language_code=None if locale == "default" else locale,
)
await app.bot.set_webhook(
await app.bot.set_webhook(url = f"{app.config.API_URL}/api/telegram/webhook", url=f"{app.config.API_URL}/api/telegram/webhook",
drop_pending_updates = True, drop_pending_updates=True,
allowed_updates = ['message', 'callback_query', 'pre_checkout_query'], allowed_updates=["message", "callback_query", "pre_checkout_query"],
secret_token = app.bot_auth_token) secret_token=app.bot_auth_token,
)
logger.info("qbot app started") logger.info("qbot app started")

107
main.py
View File

@@ -1,24 +1,21 @@
from functools import wraps from typing import Annotated, Callable, Any
from typing import Annotated, Callable, Any, Union, override
from typing_extensions import Doc from typing_extensions import Doc
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.filters import CommandStart
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.types import Message, BotCommand from aiogram.types import Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.utils.callback_answer import CallbackAnswerMiddleware from aiogram.utils.callback_answer import CallbackAnswerMiddleware
from aiogram.utils.i18n import I18n from aiogram.utils.i18n import I18n
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.applications import Lifespan, AppType from fastapi.applications import Lifespan, AppType
from logging import getLogger
from secrets import token_hex from secrets import token_hex
from .config import Config from .config import Config
from .fsm.db_storage import DbStorage 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.user import UserBase
from .model.entity_metadata import EntityMetadata 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): class QBotApp(FastAPI):
@@ -26,47 +23,67 @@ class QBotApp(FastAPI):
Main class for the QBot application Main class for the QBot application
""" """
def __init__[UserType: UserBase](self, def __init__[UserType: UserBase](
user_class: Annotated[type[UserType], Doc( self,
"User class that will be used in the application" user_class: (
)] | None = None, Annotated[
config: Config | None = None, type[UserType], Doc("User class that will be used in the application")
bot_start: Annotated[Callable[[Annotated[Callable[[Message, Any], None], Doc( ]
"Default handler for the start command" | None
)], Message, Any], None], Doc( ) = None,
"Handler for the start command" config: Config | None = None,
)] | None = None, bot_start: (
bot_commands: list[Command] | None = None, Annotated[
lifespan: Lifespan[AppType] | None = None, Callable[
*args, [
**kwargs): 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: if config is None:
config = Config() config = Config()
if user_class is None: if user_class is None:
from .model.default_user import DefaultUser from .model.default_user import DefaultUser
user_class = DefaultUser user_class = DefaultUser
self.user_class = user_class self.user_class = user_class
self.entity_metadata: EntityMetadata = user_class.entity_metadata self.entity_metadata: EntityMetadata = user_class.entity_metadata
self.config = config self.config = config
self.lifespan = lifespan self.lifespan = lifespan
self.bot = Bot(token = self.config.TELEGRAM_BOT_TOKEN, default = DefaultBotProperties(parse_mode = "HTML")) self.bot = Bot(
token=self.config.TELEGRAM_BOT_TOKEN,
default=DefaultBotProperties(parse_mode="HTML"),
)
dp = Dispatcher(storage = DbStorage()) dp = Dispatcher(storage=DbStorage())
i18n = I18n(path = "locales", default_locale = "en", domain = "messages") i18n = I18n(path="locales", default_locale="en", domain="messages")
i18n_middleware = I18nMiddleware(user_class = user_class, i18n = i18n) i18n_middleware = I18nMiddleware(user_class=user_class, i18n=i18n)
i18n_middleware.setup(dp) i18n_middleware.setup(dp)
# dp.callback_query.middleware(ResetStateMiddleware())
dp.callback_query.middleware(CallbackAnswerMiddleware()) dp.callback_query.middleware(CallbackAnswerMiddleware())
from .bot.handlers.start import router as start_router from .bot.handlers.start import router as start_router
dp.include_router(start_router) dp.include_router(start_router)
from .bot.handlers.menu.main import router as main_menu_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.message.middleware.register(auth)
main_menu_router.callback_query.middleware.register(auth) main_menu_router.callback_query.middleware.register(auth)
dp.include_router(main_menu_router) dp.include_router(main_menu_router)
@@ -76,35 +93,17 @@ class QBotApp(FastAPI):
self.bot_auth_token = token_hex(128) self.bot_auth_token = token_hex(128)
self.start_handler = bot_start 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 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 from .api_route.telegram import router as telegram_router
self.include_router(telegram_router, prefix = "/api/telegram", tags = ["telegram"])
@override self.include_router(telegram_router, prefix="/api/telegram", tags=["telegram"])
def bot_command(self, command: Command): ...
@override def register_routers(self, *routers: Router):
def bot_command(self, command: str, caption: str | dict[str, str] | None = None): ... for router in routers:
for command_name, command in router._commands.items():
def bot_command(self, command: str | Command, caption: str | dict[str, str] | None = None): self.bot_commands[command_name] = command
"""
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

View File

@@ -1,3 +1,2 @@
from .auth import AuthMiddleware from .auth import AuthMiddleware as AuthMiddleware
from .i18n import I18nMiddleware from .i18n import I18nMiddleware as I18nMiddleware
from .reset_state import ResetStateMiddleware

View File

@@ -1,6 +1,5 @@
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Message, CallbackQuery from aiogram.types import TelegramObject, Message, CallbackQuery
from aiogram.utils.i18n import gettext as _
from babel.support import LazyProxy from babel.support import LazyProxy
from typing import Any, Awaitable, Callable, Dict from typing import Any, Awaitable, Callable, Dict
@@ -8,23 +7,25 @@ from ...model.user import UserBase
class AuthMiddleware(BaseMiddleware): class AuthMiddleware(BaseMiddleware):
def __init__[UserType: UserBase](
def __init__[UserType: UserBase](self, self,
user_class: type[UserType], user_class: type[UserType],
not_authenticated_msg: LazyProxy | str = "not authenticated", not_authenticated_msg: LazyProxy | str = "not authenticated",
not_active_msg: LazyProxy | str = "not active"): not_active_msg: LazyProxy | str = "not active",
):
self.user_class = user_class self.user_class = user_class
self.not_authenticated_msg = not_authenticated_msg self.not_authenticated_msg = not_authenticated_msg
self.not_active_msg = not_active_msg self.not_active_msg = not_active_msg
async def __call__(
async def __call__(self, self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject, event: TelegramObject,
data: Dict[str, Any]) -> Any: data: Dict[str, Any],
) -> Any:
user = await self.user_class.get(id = event.from_user.id, session = data["db_session"]) user = await self.user_class.get(
id=event.from_user.id, session=data["db_session"]
)
if user and user.is_active: if user and user.is_active:
data["user"] = user data["user"] = user
@@ -34,7 +35,3 @@ class AuthMiddleware(BaseMiddleware):
if user and not user.is_active: if user and not user.is_active:
return await event.answer(self.not_active_msg) return await event.answer(self.not_active_msg)
return await event.answer(self.not_authenticated_msg) return await event.answer(self.not_authenticated_msg)

View File

@@ -5,10 +5,6 @@ from ...model.user import UserBase
class I18nMiddleware(SimpleI18nMiddleware): class I18nMiddleware(SimpleI18nMiddleware):
"""
This middleware stores locale in the FSM storage
"""
def __init__[UserType: UserBase]( def __init__[UserType: UserBase](
self, self,
user_class: type[UserType], user_class: type[UserType],
@@ -22,7 +18,7 @@ class I18nMiddleware(SimpleI18nMiddleware):
async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str: async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str:
db_session = data.get("db_session") db_session = data.get("db_session")
if db_session and event.model_fields.get("from_user"): 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: if user and user.lang:
return user.lang return user.lang
return await super().get_locale(event=event, data=data) return await super().get_locale(event=event, data=data)

View File

@@ -1,30 +1,26 @@
from typing import Any, Awaitable, Callable, Dict # from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware # from aiogram import BaseMiddleware
from aiogram.types import TelegramObject # from aiogram.types import TelegramObject
from aiogram.fsm.context import FSMContext # from aiogram.fsm.context import FSMContext
from aiogram.utils.i18n import gettext as _ # from aiogram.utils.i18n import gettext as _
from ...bot.handlers.context import ContextData # from ...bot.handlers.context import ContextData
class ResetStateMiddleware(BaseMiddleware): # class ResetStateMiddleware(BaseMiddleware):
async def __call__(self, # async def __call__(self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], # handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject, # event: TelegramObject,
data: Dict[str, Any]) -> Any: # 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
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
# if not save_state:
# state = data.get("state")
# if isinstance(state, FSMContext):
# await state.clear()
# return await handler(event, data)

View File

@@ -7,9 +7,7 @@ from .bot_enum import BotEnum, EnumMember
from ..db import async_session from ..db import async_session
class EntityPermission(BotEnum): class EntityPermission(BotEnum):
LIST = EnumMember("list") LIST = EnumMember("list")
READ = EnumMember("read") READ = EnumMember("read")
CREATE = EnumMember("create") CREATE = EnumMember("create")
@@ -23,24 +21,23 @@ class EntityPermission(BotEnum):
def session_dep(func): def session_dep(func):
@wraps(func) @wraps(func)
async def wrapper(cls, *args, **kwargs): async def wrapper(cls, *args, **kwargs):
if "session" in kwargs and kwargs["session"]:
return await func(cls, *args, **kwargs)
if "session" in kwargs and kwargs["session"]: _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) return await func(cls, *args, **kwargs)
else:
kwargs["session"] = _session
return await func(cls, *args, **kwargs)
_session = None return wrapper
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
return await func(cls, *args, **kwargs)
return wrapper

View File

@@ -1,5 +1,6 @@
class Singleton(type): class Singleton(type):
_instances = {} _instances = {}
def __call__(cls, *args, **kwargs): def __call__(cls, *args, **kwargs):
if cls not in cls._instances: if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)

View File

@@ -1,33 +1,49 @@
from functools import wraps
from types import NoneType, UnionType 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 pydantic import BaseModel
from sqlmodel import SQLModel, BIGINT, Field, select, func, column from sqlmodel import SQLModel, BIGINT, Field, select, func, column
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import SelectOfScalar
from sqlmodel.main import SQLModelMetaclass, RelationshipInfo 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 .entity_metadata import EntityMetadata
from . import session_dep from . import session_dep
if TYPE_CHECKING:
from .user import UserBase
class BotEntityMetaclass(SQLModelMetaclass): class BotEntityMetaclass(SQLModelMetaclass):
__future_references__ = {} __future_references__ = {}
def __new__(mcs, name, bases, namespace, **kwargs): def __new__(mcs, name, bases, namespace, **kwargs):
bot_fields_descriptors = {} bot_fields_descriptors = {}
if bases: if bases:
bot_entity_descriptor = bases[0].__dict__.get('bot_entity_descriptor') bot_entity_descriptor = bases[0].__dict__.get("bot_entity_descriptor")
bot_fields_descriptors = {key: EntityFieldDescriptor(**value.__dict__.copy()) bot_fields_descriptors = (
for key, value in bot_entity_descriptor.fields_descriptors.items()} if bot_entity_descriptor else {} {
key: EntityFieldDescriptor(**value.__dict__.copy())
if '__annotations__' in namespace: for key, value in bot_entity_descriptor.fields_descriptors.items()
}
for annotation in namespace['__annotations__']: if bot_entity_descriptor
else {}
)
if "__annotations__" in namespace:
for annotation in namespace["__annotations__"]:
if annotation in ["bot_entity_descriptor", "entity_metadata"]: if annotation in ["bot_entity_descriptor", "entity_metadata"]:
continue continue
@@ -40,7 +56,6 @@ class BotEntityMetaclass(SQLModelMetaclass):
descriptor_name = annotation descriptor_name = annotation
if attribute_value: if attribute_value:
if isinstance(attribute_value, EntityField): if isinstance(attribute_value, EntityField):
descriptor_kwargs = attribute_value.__dict__.copy() descriptor_kwargs = attribute_value.__dict__.copy()
sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None) sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None)
@@ -52,25 +67,28 @@ class BotEntityMetaclass(SQLModelMetaclass):
descriptor_name = descriptor_kwargs.pop("name") or annotation descriptor_name = descriptor_kwargs.pop("name") or annotation
type_ = namespace['__annotations__'][annotation] type_ = namespace["__annotations__"][annotation]
type_origin = get_origin(type_) type_origin = get_origin(type_)
field_descriptor = EntityFieldDescriptor( field_descriptor = EntityFieldDescriptor(
name = descriptor_name, name=descriptor_name,
field_name = annotation, field_name=annotation,
type_ = type_, type_=type_,
type_base = type_, type_base=type_,
**descriptor_kwargs) **descriptor_kwargs,
)
is_list = False is_list = False
if type_origin == list: if type_origin is list:
field_descriptor.is_list = is_list = True field_descriptor.is_list = is_list = True
field_descriptor.type_base = type_ = get_args(type_)[0] field_descriptor.type_base = type_ = get_args(type_)[0]
if type_origin == Union and isinstance(get_args(type_)[0], ForwardRef): if type_origin == Union and isinstance(get_args(type_)[0], ForwardRef):
field_descriptor.is_optional = True 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: if type_origin == UnionType and get_args(type_)[1] == NoneType:
field_descriptor.is_optional = True field_descriptor.is_optional = True
@@ -78,12 +96,26 @@ class BotEntityMetaclass(SQLModelMetaclass):
if isinstance(type_, str): if isinstance(type_, str):
type_not_found = True 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: if type_ == entity_descriptor.class_name:
field_descriptor.type_ = (list[entity_descriptor.type_] if is_list field_descriptor.type_ = (
else Optional[entity_descriptor.type_] if type_origin == Optional list[entity_descriptor.type_]
else entity_descriptor.type_ | None if (type_origin == UnionType and get_args(type_)[1] == NoneType) if is_list
else entity_descriptor.type_) 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 type_not_found = False
break break
if type_not_found: if type_not_found:
@@ -101,27 +133,38 @@ class BotEntityMetaclass(SQLModelMetaclass):
descriptor_kwargs: dict = entity_descriptor.__dict__.copy() descriptor_kwargs: dict = entity_descriptor.__dict__.copy()
descriptor_name = descriptor_kwargs.pop("name", None) descriptor_name = descriptor_kwargs.pop("name", None)
descriptor_name = descriptor_name or name.lower() 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( namespace["bot_entity_descriptor"] = EntityDescriptor(
name = descriptor_name, name=descriptor_name,
class_name = name, class_name=name,
type_ = name, type_=name,
fields_descriptors = bot_fields_descriptors, fields_descriptors=bot_fields_descriptors,
field_sequence = descriptor_fields_sequence, **descriptor_kwargs,
**descriptor_kwargs) )
else: else:
descriptor_fields_sequence = list(bot_fields_descriptors.keys())
descriptor_fields_sequence.remove("id")
descriptor_name = name.lower() descriptor_name = name.lower()
namespace["bot_entity_descriptor"] = EntityDescriptor( namespace["bot_entity_descriptor"] = EntityDescriptor(
name = descriptor_name, name=descriptor_name,
class_name = name, class_name=name,
type_ = name, type_=name,
fields_descriptors = bot_fields_descriptors, fields_descriptors=bot_fields_descriptors,
field_sequence = descriptor_fields_sequence) )
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(): for field_descriptor in bot_fields_descriptors.values():
field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"] field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"]
@@ -129,14 +172,20 @@ class BotEntityMetaclass(SQLModelMetaclass):
if "table" not in kwargs: if "table" not in kwargs:
kwargs["table"] = True kwargs["table"] = True
if kwargs["table"] == True: if kwargs["table"]:
entity_metadata = EntityMetadata() 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: if "__annotations__" in namespace:
namespace["__annotations__"]["entity_metadata"] = ClassVar[EntityMetadata] namespace["__annotations__"]["entity_metadata"] = ClassVar[
EntityMetadata
]
else: else:
namespace["__annotations__"] = {"entity_metadata": ClassVar[EntityMetadata]} namespace["__annotations__"] = {
"entity_metadata": ClassVar[EntityMetadata]
}
namespace["entity_metadata"] = entity_metadata namespace["entity_metadata"] = entity_metadata
@@ -146,78 +195,184 @@ class BotEntityMetaclass(SQLModelMetaclass):
for field_descriptor in mcs.__future_references__[name]: for field_descriptor in mcs.__future_references__[name]:
type_origin = get_origin(field_descriptor.type_) type_origin = get_origin(field_descriptor.type_)
field_descriptor.type_base = 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 field_descriptor.type_ = (
type_ | None if type_origin == UnionType else list[type_]
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_) setattr(namespace["bot_entity_descriptor"], "type_", type_)
return type_ return type_
class BotEntity[CreateSchemaType: BaseModel, class BotEntity[CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel](
UpdateSchemaType: BaseModel](SQLModel, SQLModel, metaclass=BotEntityMetaclass, table=False
metaclass = BotEntityMetaclass, ):
table = False):
bot_entity_descriptor: ClassVar[EntityDescriptor] bot_entity_descriptor: ClassVar[EntityDescriptor]
entity_metadata: ClassVar[EntityMetadata] entity_metadata: ClassVar[EntityMetadata]
id: int = Field( id: int = Field(primary_key=True, sa_type=BIGINT)
primary_key = True,
sa_type = BIGINT)
name: str
@classmethod @classmethod
@session_dep @session_dep
async def get(cls, *, async def get(cls, *, session: AsyncSession | None = None, id: int):
session: AsyncSession | None = None, return await session.get(cls, id, populate_existing=True)
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 @classmethod
@session_dep @session_dep
async def get_count(cls, *, async def get_count(
session: AsyncSession | None = None, cls,
filter: str = None) -> int: *,
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) select_statement = select(func.count()).select_from(cls)
if filter: if static_filter:
select_statement = select_statement.where(column("name").ilike(f"%{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) return await session.scalar(select_statement)
@classmethod @classmethod
@session_dep @session_dep
async def get_multi(cls, *, async def get_multi(
session: AsyncSession | None = None, cls,
order_by = None, *,
filter:str = None, session: AsyncSession | None = None,
skip: int = 0, order_by=None,
limit: int = 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) select_statement = select(cls).offset(skip)
if limit: if limit:
select_statement = select_statement.limit(limit) select_statement = select_statement.limit(limit)
if filter: if static_filter:
select_statement = select_statement.where(column("name").ilike(f"%{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: if order_by:
select_statement = select_statement.order_by(order_by) select_statement = select_statement.order_by(order_by)
return (await session.exec(select_statement)).all() 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 @classmethod
@session_dep @session_dep
async def create(cls, *, async def create(
session: AsyncSession | None = None, cls,
obj_in: CreateSchemaType, *,
commit: bool = False): session: AsyncSession | None = None,
obj_in: CreateSchemaType,
commit: bool = False,
):
if isinstance(obj_in, cls): if isinstance(obj_in, cls):
obj = obj_in obj = obj_in
else: else:
@@ -227,15 +382,16 @@ class BotEntity[CreateSchemaType: BaseModel,
await session.commit() await session.commit()
return obj return obj
@classmethod @classmethod
@session_dep @session_dep
async def update(cls, *, async def update(
session: AsyncSession | None = None, cls,
id: int, *,
obj_in: UpdateSchemaType, session: AsyncSession | None = None,
commit: bool = False): id: int,
obj_in: UpdateSchemaType,
commit: bool = False,
):
obj = await session.get(cls, id) obj = await session.get(cls, id)
if obj: if obj:
obj_data = obj.model_dump() obj_data = obj.model_dump()
@@ -249,13 +405,11 @@ class BotEntity[CreateSchemaType: BaseModel,
return obj return obj
return None return None
@classmethod @classmethod
@session_dep @session_dep
async def remove(cls, *, async def remove(
session: AsyncSession | None = None, cls, *, session: AsyncSession | None = None, id: int, commit: bool = False
id: int, ):
commit: bool = False):
obj = await session.get(cls, id) obj = await session.get(cls, id)
if obj: if obj:
await session.delete(obj) await session.delete(obj)
@@ -263,4 +417,3 @@ class BotEntity[CreateSchemaType: BaseModel,
await session.commit() await session.commit()
return obj return obj
return None return None

View File

@@ -4,30 +4,40 @@ from typing import Any, Self, overload
class BotEnumMetaclass(type): class BotEnumMetaclass(type):
def __new__(cls, name: str, bases: tuple[type], namespace: dict[str, Any]): def __new__(cls, name: str, bases: tuple[type], namespace: dict[str, Any]):
all_members = {} 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"] all_members = bases[0].__dict__["all_members"]
annotations = {} annotations = {}
for key, value in namespace.items(): for key, value in namespace.items():
if (key.isupper() and if key.isupper() and not key.startswith("__") and not key.endswith("__"):
not key.startswith("__") and
not key.endswith("__")):
if not isinstance(value, EnumMember): if not isinstance(value, EnumMember):
value = EnumMember(value, None) value = EnumMember(value, None)
if key in all_members.keys() and all_members[key].value != value.value: 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.") 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 if (
key not in all_members.keys()): 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]}") 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 namespace[key] = member
all_members[key] = member all_members[key] = member
@@ -46,22 +56,23 @@ class BotEnumMetaclass(type):
class EnumMember(object): class EnumMember(object):
@overload
def __init__(self, value: str) -> "EnumMember": ...
@overload @overload
def __init__(self, value: str) -> "EnumMember":... def __init__(self, value: "EnumMember") -> "EnumMember": ...
@overload @overload
def __init__(self, value: "EnumMember") -> "EnumMember":... def __init__(self, value: str, loc_obj: dict[str, str]) -> "EnumMember": ...
@overload def __init__(
def __init__(self, value: str, loc_obj: dict[str, str]) -> "EnumMember":... self,
value: str = None,
def __init__(self, loc_obj: dict[str, str] = None,
value: str = None, parent: type = None,
loc_obj: dict[str, str] = None, name: str = None,
parent: type = None, casting: bool = True,
name: str = None, ) -> "EnumMember":
casting: bool = True) -> "EnumMember":
if not casting: if not casting:
self._parent = parent self._parent = parent
self._name = name self._name = name
@@ -69,10 +80,9 @@ class EnumMember(object):
self.loc_obj = loc_obj self.loc_obj = loc_obj
@overload @overload
def __new__(cls: Self, *args, **kwargs) -> "EnumMember":... def __new__(cls: Self, *args, **kwargs) -> "EnumMember": ...
def __new__(cls, *args, casting: bool = True, **kwargs) -> "EnumMember": def __new__(cls, *args, casting: bool = True, **kwargs) -> "EnumMember":
if (cls.__name__ == "EnumMember") or not casting: if (cls.__name__ == "EnumMember") or not casting:
obj = super().__new__(cls) obj = super().__new__(cls)
kwargs["casting"] = False kwargs["casting"] = False
@@ -81,9 +91,13 @@ class EnumMember(object):
if args.__len__() == 0: if args.__len__() == 0:
return list(cls.all_members.values())[0] return list(cls.all_members.values())[0]
if args.__len__() == 1 and isinstance(args[0], str): if args.__len__() == 1 and isinstance(args[0], str):
return {member.value: member for key, member in cls.all_members.items()}[args[0]] return {member.value: member for key, member in cls.all_members.items()}[
args[0]
]
elif args.__len__() == 1: 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: else:
return args[0] return args[0]
@@ -91,24 +105,25 @@ class EnumMember(object):
return str_schema() return str_schema()
def __get__(self, instance, owner) -> Self: def __get__(self, instance, owner) -> Self:
# return {member.value: member for key, member in owner.all_members.items()}[self.value] return {
return {member.value: member for key, member in self._parent.all_members.items()}[self.value] member.value: member for key, member in self._parent.all_members.items()
}[self.value]
def __set__(self, instance, value): def __set__(self, instance, value):
instance.__dict__[self] = value instance.__dict__[self] = value
def __repr__(self): 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): def __str__(self):
return self.value return self.value
def __eq__(self, other : Self | str) -> bool: def __eq__(self, other: Self | str) -> bool:
if other is None: if other is None:
return False return False
if isinstance(other, str): if isinstance(other, str):
return self.value == other return self.value == other
return self.value == other.value return self.value == other.value
def __hash__(self): def __hash__(self):
return hash(self.value) return hash(self.value)
@@ -123,13 +138,11 @@ class EnumMember(object):
return self.value return self.value
class BotEnum(EnumMember, metaclass = BotEnumMetaclass): class BotEnum(EnumMember, metaclass=BotEnumMetaclass):
all_members: dict[str, EnumMember] all_members: dict[str, EnumMember]
class EnumType(TypeDecorator): class EnumType(TypeDecorator):
impl = String(256) impl = String(256)
def __init__(self, enum_type: BotEnum): def __init__(self, enum_type: BotEnum):

View File

@@ -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 babel.support import LazyProxy
from dataclasses import dataclass, field from dataclasses import dataclass, field
from sqlmodel.ext.asyncio.session import AsyncSession
from .role import RoleBase from .role import RoleBase
from . import EntityPermission from . import EntityPermission
from ..bot.handlers.context import ContextData
if TYPE_CHECKING: if TYPE_CHECKING:
from .bot_entity import BotEntity from .bot_entity import BotEntity
from ..main import QBotApp
from .user import UserBase
EntityCaptionCallable = Callable[["EntityDescriptor"], str] EntityCaptionCallable = Callable[["EntityDescriptor"], str]
EntityItemCaptionCallable = Callable[["EntityDescriptor", Any], str] EntityItemCaptionCallable = Callable[["EntityDescriptor", Any], str]
EntityFieldCaptionCallable = Callable[["EntityFieldDescriptor", Any, Any], str] EntityFieldCaptionCallable = Callable[["EntityFieldDescriptor", Any, Any], str]
@dataclass(kw_only = True) @dataclass
class _BaseEntityFieldDescriptor(): 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 icon: str = None
caption: str | LazyProxy | EntityFieldCaptionCallable | None = None caption: str | LazyProxy | EntityFieldCaptionCallable | None = None
description: str | LazyProxy | EntityFieldCaptionCallable | None = None description: str | LazyProxy | EntityFieldCaptionCallable | None = None
@@ -24,21 +89,25 @@ class _BaseEntityFieldDescriptor():
localizable: bool = False localizable: bool = False
bool_false_value: str | LazyProxy = "no" bool_false_value: str | LazyProxy = "no"
bool_true_value: str | LazyProxy = "yes" 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 default: Any = None
@dataclass(kw_only = True) @dataclass(kw_only=True)
class EntityField(_BaseEntityFieldDescriptor): class EntityField(_BaseEntityFieldDescriptor):
name: str | None = None name: str | None = None
sm_descriptor: Any = None sm_descriptor: Any = None
@dataclass(kw_only = True) @dataclass(kw_only=True)
class Setting(_BaseEntityFieldDescriptor): class Setting(_BaseEntityFieldDescriptor):
name: str | None = None name: str | None = None
@dataclass(kw_only = True) @dataclass(kw_only=True)
class EntityFieldDescriptor(_BaseEntityFieldDescriptor): class EntityFieldDescriptor(_BaseEntityFieldDescriptor):
name: str name: str
field_name: str field_name: str
@@ -52,42 +121,80 @@ class EntityFieldDescriptor(_BaseEntityFieldDescriptor):
return self.name.__hash__() return self.name.__hash__()
@dataclass(kw_only = True) @dataclass(kw_only=True)
class _BaseEntityDescriptor: class _BaseEntityDescriptor:
icon: str = "📘" icon: str = "📘"
caption: str | LazyProxy | EntityCaptionCallable | None = None full_name: str | LazyProxy | EntityCaptionCallable | None = None
caption_plural: str | LazyProxy | EntityCaptionCallable | None = None full_name_plural: str | LazyProxy | EntityCaptionCallable | None = None
description: 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 show_in_entities_menu: bool = True
field_sequence: list[str] = None ownership_fields: dict[RoleBase, str] = field(default_factory=dict[RoleBase, str])
edit_button_visible: bool = True permissions: dict[EntityPermission, list[RoleBase]] = field(
edit_buttons: list[list[str | tuple[str, str | LazyProxy | EntityFieldCaptionCallable]]] = None default_factory=lambda: {
permissions: dict[EntityPermission, list[RoleBase]] = field(default_factory = lambda: { EntityPermission.LIST: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.LIST: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], EntityPermission.READ: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.READ: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], EntityPermission.CREATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.CREATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], EntityPermission.UPDATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.UPDATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], EntityPermission.DELETE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.DELETE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], EntityPermission.LIST_ALL: [RoleBase.SUPER_USER],
EntityPermission.LIST_ALL: [RoleBase.SUPER_USER], EntityPermission.READ_ALL: [RoleBase.SUPER_USER],
EntityPermission.READ_ALL: [RoleBase.SUPER_USER], EntityPermission.CREATE_ALL: [RoleBase.SUPER_USER],
EntityPermission.CREATE_ALL: [RoleBase.SUPER_USER], EntityPermission.UPDATE_ALL: [RoleBase.SUPER_USER],
EntityPermission.UPDATE_ALL: [RoleBase.SUPER_USER], EntityPermission.DELETE_ALL: [RoleBase.SUPER_USER],
EntityPermission.DELETE_ALL: [RoleBase.SUPER_USER] }
}) )
@dataclass(kw_only = True) @dataclass(kw_only=True)
class Entity(_BaseEntityDescriptor): class Entity(_BaseEntityDescriptor):
name: str | None = None name: str | None = None
@dataclass @dataclass
class EntityDescriptor(_BaseEntityDescriptor): class EntityDescriptor(_BaseEntityDescriptor):
name: str name: str
class_name: str class_name: str
type_: type["BotEntity"] type_: type["BotEntity"]
fields_descriptors: dict[str, EntityFieldDescriptor] 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): ...

View File

@@ -2,7 +2,6 @@ from .descriptors import EntityDescriptor
from ._singleton import Singleton from ._singleton import Singleton
class EntityMetadata(metaclass = Singleton): class EntityMetadata(metaclass=Singleton):
def __init__(self): def __init__(self):
self.entity_descriptors: dict[str, EntityDescriptor] = {} self.entity_descriptors: dict[str, EntityDescriptor] = {}

View File

@@ -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]

View File

@@ -1,8 +1,7 @@
from sqlmodel import SQLModel, Field from sqlmodel import SQLModel, Field
class FSMStorage(SQLModel, table = True): class FSMStorage(SQLModel, table=True):
__tablename__ = "fsm_storage" __tablename__ = "fsm_storage"
key: str = Field(primary_key = True) key: str = Field(primary_key=True)
value: str | None = None value: str | None = None

View File

@@ -2,5 +2,4 @@ from .bot_enum import BotEnum, EnumMember
class LanguageBase(BotEnum): class LanguageBase(BotEnum):
EN = EnumMember("en", {"en": "🇬🇧 english"}) EN = EnumMember("en", {"en": "🇬🇧 english"})

View File

@@ -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]] = []

View File

@@ -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)

View File

@@ -2,6 +2,5 @@ from .bot_enum import BotEnum, EnumMember
class RoleBase(BotEnum): class RoleBase(BotEnum):
SUPER_USER = EnumMember("super_user") SUPER_USER = EnumMember("super_user")
DEFAULT_USER = EnumMember("default_user") DEFAULT_USER = EnumMember("default_user")

View File

@@ -7,57 +7,58 @@ from typing import Any, get_args, get_origin
from ..db import async_session from ..db import async_session
from .role import RoleBase from .role import RoleBase
from .descriptors import EntityFieldDescriptor, Setting from .descriptors import EntityFieldDescriptor, Setting
from ..utils import deserialize, serialize from ..utils.serialization import deserialize, serialize
import ujson as json import ujson as json
class DbSettings(SQLModel, table = True): class DbSettings(SQLModel, table=True):
__tablename__ = "settings" __tablename__ = "settings"
name: str = Field(primary_key = True) name: str = Field(primary_key=True)
value: str value: str
class SettingsMetaclass(type): class SettingsMetaclass(type):
def __new__(cls, class_name, base_classes, attributes): def __new__(cls, class_name, base_classes, attributes):
settings_descriptors = {} settings_descriptors = {}
if base_classes: 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"]: if annotation in ["_settings_descriptors", "_cache", "_cached_settings"]:
continue continue
attr_value = attributes.get(annotation) attr_value = attributes.get(annotation)
name = annotation name = annotation
type_ = attributes['__annotations__'][annotation] type_ = attributes["__annotations__"][annotation]
if isinstance(attr_value, Setting): if isinstance(attr_value, Setting):
descriptor_kwargs = attr_value.__dict__.copy() descriptor_kwargs = attr_value.__dict__.copy()
name = descriptor_kwargs.pop("name") or annotation name = descriptor_kwargs.pop("name") or annotation
attributes[annotation] = EntityFieldDescriptor( attributes[annotation] = EntityFieldDescriptor(
name = name, name=name,
field_name = annotation, field_name=annotation,
type_ = type_, type_=type_,
type_base = type_, type_base=type_,
**descriptor_kwargs) **descriptor_kwargs,
)
else: else:
attributes[annotation] = EntityFieldDescriptor( attributes[annotation] = EntityFieldDescriptor(
name = annotation, name=annotation,
field_name = annotation, field_name=annotation,
type_ = type_, type_=type_,
type_base = type_, type_base=type_,
default = attr_value) default=attr_value,
)
type_origin = get_origin(type_) type_origin = get_origin(type_)
if type_origin == list: if type_origin is list:
attributes[annotation].is_list = True attributes[annotation].is_list = True
attributes[annotation].type_base = type_ = get_args(type_)[0] attributes[annotation].type_base = type_ = get_args(type_)[0]
@@ -67,7 +68,11 @@ class SettingsMetaclass(type):
settings_descriptors[name] = attributes[annotation] 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]) setattr(base_classes[0], annotation, attributes[annotation])
attributes["__annotations__"] = {} attributes["__annotations__"] = {}
@@ -76,62 +81,119 @@ class SettingsMetaclass(type):
return super().__new__(cls, class_name, base_classes, attributes) return super().__new__(cls, class_name, base_classes, attributes)
class Settings(metaclass = SettingsMetaclass): class Settings(metaclass=SettingsMetaclass):
_cache: dict[str, Any] = dict[str, Any]() _cache: dict[str, Any] = dict[str, Any]()
_settings_descriptors: dict[str, EntityFieldDescriptor] = {} _settings_descriptors: dict[str, EntityFieldDescriptor] = {}
PAGE_SIZE: int = Setting(default = 10, ) PAGE_SIZE: int = Setting(
SECURITY_PARAMETERS_ROLES: list[RoleBase] = Setting(name = "SECPARAMS_ROLES", default = [RoleBase.SUPER_USER], is_visible = False) 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_WELCOME_P_NAME: str = Setting(
APP_STRINGS_GREETING_P_NAME: str = Setting(name = "AS_GREETING", default = "Hello, {name}", is_visible = False) name="AS_WELCOME", default="Welcome, {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_GREETING_P_NAME: str = Setting(
APP_STRINGS_FORBIDDEN: str = Setting(name = "AS_FORBIDDEN", default = "Forbidden", is_visible = False) name="AS_GREETING", default="Hello, {name}", 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_INTERNAL_ERROR_P_ERROR: str = Setting(
APP_STRINGS_REFERENCES: str = Setting(name = "AS_REFERENCES", default = "References", is_visible = False) name="AS_INTERNAL_ERROR", default="Internal error\n{error}", 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_USER_BLOCKED_P_NAME: str = Setting(
APP_STRINGS_SETTINGS_BTN: str = Setting(name = "AS_SETTINGS_BTN", default = "⚙️ Settings", is_visible = False) name="AS_USER_BLOCKED", default="User {name} is blocked", 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_FORBIDDEN: str = Setting(
APP_STRINGS_LANGUAGE: str = Setting(name = "AS_LANGUAGE", default = "Language", is_visible = False) name="AS_FORBIDDEN", default="Forbidden", 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_NOT_FOUND: str = Setting(
APP_STRINGS_DELETE_BTN: str = Setting(name = "AS_DELETE_BTN", default = "🗑️ Delete", is_visible = False) 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( APP_STRINGS_CONFIRM_DELETE_P_NAME: str = Setting(
name = "AS_CONFIRM_DEL", name="AS_CONFIRM_DEL",
default = "Are you sure you want to delete \"{name}\"?", default='Are you sure you want to delete "{name}"?',
is_visible = False) 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_EDIT_BTN: str = Setting(
APP_STRINGS_YES_BTN: str = Setting(name = "AS_YES_BTN", default = "✅ Yes", is_visible = False) name="AS_EDIT_BTN", default="✏️ Edit", 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_ADD_BTN: str = Setting(
APP_STRINGS_CLEAR_BTN: str = Setting(name = "AS_CLEAR_BTN", default = "⌫ Clear", is_visible = False) name="AS_ADD_BTN", default=" Add", 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_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( APP_STRINGS_FIELD_EDIT_PROMPT_TEMPLATE_P_NAME_VALUE: str = Setting(
name = "AS_FIELDEDIT_PROMPT", name="AS_FIELDEDIT_PROMPT",
default = "Enter new value for \"{name}\" (current value: {value})", default='Enter new value for "{name}" (current value: {value})',
is_visible = False) is_visible=False,
)
APP_STRINGS_FIELD_CREATE_PROMPT_TEMPLATE_P_NAME: str = Setting( APP_STRINGS_FIELD_CREATE_PROMPT_TEMPLATE_P_NAME: str = Setting(
name = "AS_FIELDCREATE_PROMPT", name="AS_FIELDCREATE_PROMPT",
default = "Enter new value for \"{name}\"", default='Enter new value for "{name}"',
is_visible = False) is_visible=False,
)
APP_STRINGS_STRING_EDITOR_LOCALE_TEMPLATE_P_NAME: str = Setting( APP_STRINGS_STRING_EDITOR_LOCALE_TEMPLATE_P_NAME: str = Setting(
name = "AS_STREDIT_LOC_TEMPLATE", name="AS_STREDIT_LOC_TEMPLATE", default='string for "{name}"', is_visible=False
default = "string for \"{name}\"", )
is_visible = False) APP_STRINGS_VIEW_FILTER_EDIT_PROMPT: str = Setting(
APP_STRINGS_VIEW_FILTER_EDIT_PROMPT: str = Setting(name = "AS_FILTEREDIT_PROMPT", default = "Enter filter value", is_visible = False) 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) )
APP_STRINGS_INVALID_INPUT: str = Setting(
name="AS_INVALID_INPUT", default="Invalid input", is_visible=False
)
@classmethod @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 name = param.field_name
if name not in cls._cache.keys(): if name not in cls._cache.keys():
@@ -144,73 +206,79 @@ class Settings(metaclass = SettingsMetaclass):
locale = get_i18n().current_locale locale = get_i18n().current_locale
try: try:
obj = json.loads(ret_val) obj = json.loads(ret_val)
except: except Exception:
return ret_val return ret_val
return obj.get(locale, obj[list(obj.keys())[0]]) return obj.get(locale, obj[list(obj.keys())[0]])
return ret_val return ret_val
@classmethod @classmethod
async def load_param(cls, param: EntityFieldDescriptor) -> Any: async def load_param(cls, param: EntityFieldDescriptor) -> Any:
async with async_session() as session: async with async_session() as session:
db_setting = (await session.exec( db_setting = (
select(DbSettings) await session.exec(
.where(DbSettings.name == param.field_name))).first() select(DbSettings).where(DbSettings.name == param.field_name)
)
).first()
if db_setting: if db_setting:
return await deserialize(session = session, return await deserialize(
type_ = param.type_, session=session, type_=param.type_, value=db_setting.value
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_())
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 @classmethod
async def load_params(cls): async def load_params(cls):
async with async_session() as session: async with async_session() as session:
db_settings = (await session.exec(select(DbSettings))).all() db_settings = (await session.exec(select(DbSettings))).all()
for db_setting in db_settings: for db_setting in db_settings:
if db_setting.name in cls.__dict__: if db_setting.name in cls.__dict__:
setting = cls.__dict__[db_setting.name] setting = cls.__dict__[db_setting.name] # type: EntityFieldDescriptor
cls._cache[db_setting.name] = await deserialize(session = session, cls._cache[db_setting.name] = await deserialize(
type_ = setting.type_, session=session,
value = db_setting.value, type_=setting.type_,
default = setting.default) value=db_setting.value,
default=setting.default,
)
cls._loaded = True cls._loaded = True
@classmethod @classmethod
async def set_param(cls, param: str | EntityFieldDescriptor, value) -> None: async def set_param(cls, param: str | EntityFieldDescriptor, value) -> None:
if isinstance(param, str): if isinstance(param, str):
param = cls._settings_descriptors[param] param = cls._settings_descriptors[param]
ser_value = serialize(value, param) ser_value = serialize(value, param)
async with async_session() as session: async with async_session() as session:
db_setting = (await session.exec( db_setting = (
select(DbSettings) await session.exec(
.where(DbSettings.name == param.field_name))).first() select(DbSettings).where(DbSettings.name == param.field_name)
)
).first()
if db_setting is None: 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) db_setting.value = str(ser_value)
session.add(db_setting) session.add(db_setting)
await session.commit() await session.commit()
cls._cache[param.field_name] = value cls._cache[param.field_name] = value
@classmethod @classmethod
def list_params(cls) -> dict[str, EntityFieldDescriptor]: def list_params(cls) -> dict[str, EntityFieldDescriptor]:
return cls._settings_descriptors return cls._settings_descriptors
@classmethod @classmethod
async def get_params(cls) -> dict[EntityFieldDescriptor, Any]: async def get_params(cls) -> dict[EntityFieldDescriptor, Any]:
params = cls.list_params() params = cls.list_params()
return {param: await cls.get(param, all_locales = True) for _, param in params.items()} return {
param: await cls.get(param, all_locales=True) for _, param in params.items()
}

View File

@@ -10,11 +10,12 @@ from .fsm_storage import FSMStorage as FSMStorage
from .view_setting import ViewSetting as ViewSetting from .view_setting import ViewSetting as ViewSetting
class UserBase(BotEntity, table = False): class UserBase(BotEntity, table=False):
__tablename__ = "user" __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 is_active: bool = True
roles: list[RoleBase] = Field(sa_type=ARRAY(EnumType(RoleBase)), default = [RoleBase.DEFAULT_USER]) roles: list[RoleBase] = Field(
sa_type=ARRAY(EnumType(RoleBase)), default=[RoleBase.DEFAULT_USER]
)

View File

@@ -4,36 +4,36 @@ from sqlalchemy.ext.asyncio.session import AsyncSession
from . import session_dep from . import session_dep
class ViewSetting(SQLModel, table = True): class ViewSetting(SQLModel, table=True):
__tablename__ = "view_setting" __tablename__ = "view_setting"
user_id: int = Field(sa_type = BIGINT, primary_key = True, foreign_key="user.id", ondelete="CASCADE") user_id: int = Field(
entity_name: str = Field(primary_key = True) sa_type=BIGINT, primary_key=True, foreign_key="user.id", ondelete="CASCADE"
)
entity_name: str = Field(primary_key=True)
filter: str | None = None filter: str | None = None
@classmethod @classmethod
@session_dep @session_dep
async def get_filter(cls, *, async def get_filter(
session: AsyncSession | None = None, cls, *, session: AsyncSession | None = None, user_id: int, entity_name: str
user_id: int, ):
entity_name: str):
setting = await session.get(cls, (user_id, entity_name)) setting = await session.get(cls, (user_id, entity_name))
return setting.filter if setting else None return setting.filter if setting else None
@classmethod @classmethod
@session_dep @session_dep
async def set_filter(cls, *, async def set_filter(
session: AsyncSession | None = None, cls,
user_id: int, *,
entity_name: str, session: AsyncSession | None = None,
filter: str): user_id: int,
entity_name: str,
setting = await session.get(cls, (user_id, entity_name)) filter: str,
if setting: ):
setting.filter = filter setting = await session.get(cls, (user_id, entity_name))
else: if setting:
setting = cls(user_id = user_id, entity_name = entity_name, filter = filter) setting.filter = filter
session.add(setting) else:
await session.commit() setting = cls(user_id=user_id, entity_name=entity_name, filter=filter)
session.add(setting)
await session.commit()

32
router.py Normal file
View File

@@ -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

199
utils/main.py Normal file
View File

@@ -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

View File

@@ -1,18 +1,14 @@
from datetime import datetime from datetime import datetime, time
from decimal import Decimal from decimal import Decimal
from types import NoneType, UnionType
from sqlmodel import select, column from sqlmodel import select, column
from sqlmodel.ext.asyncio.session import AsyncSession 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 import ujson as json
from ..model.bot_entity import BotEntity from ..model.bot_entity import BotEntity
from ..model.bot_enum import BotEnum from ..model.bot_enum import BotEnum
from ..model.descriptors import EntityFieldDescriptor, EntityDescriptor from ..model.descriptors import EntityFieldDescriptor
from ..model import EntityPermission
if TYPE_CHECKING:
from ..model.user import UserBase
async def deserialize[T](session: AsyncSession, type_: type[T], value: str = None) -> T: 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 is_optional = False
if type_origin in [UnionType, Union]: if type_origin in [UnionType, Union]:
args = get_args(type_) args = get_args(type_)
if args[1] == NoneType: if args[1] is NoneType:
type_ = args[0] type_ = args[0]
if value is None: if value is None:
return None return None
is_optional = True is_optional = True
if get_origin(type_) == list: if get_origin(type_) is list:
arg_type = None arg_type = None
args = get_args(type_) args = get_args(type_)
if args: if args:
@@ -34,7 +30,9 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non
if arg_type: if arg_type:
if issubclass(arg_type, BotEntity): if issubclass(arg_type, BotEntity):
ret = list[arg_type]() 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: for item in items:
ret.append(item) ret.append(item)
return ret return ret
@@ -52,13 +50,22 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non
if is_optional and not value: if is_optional and not value:
return None return None
return type_(value) return type_(value)
elif type_ == datetime: elif type_ is time:
if is_optional and not value: if is_optional and not value:
return None return None
return datetime.fromisoformat(value) return time.fromisoformat(value.replace("-", ":"))
elif type_ == bool: 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" return value == "True"
elif type_ == Decimal: elif type_ is Decimal:
if is_optional and not value: if is_optional and not value:
return None return None
return Decimal(value) return Decimal(value)
@@ -69,38 +76,17 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non
def serialize(value: Any, field_descriptor: EntityFieldDescriptor) -> str: def serialize(value: Any, field_descriptor: EntityFieldDescriptor) -> str:
if value is None: if value is None:
return "" return ""
type_ = field_descriptor.type_base type_ = field_descriptor.type_base
if field_descriptor.is_list: if field_descriptor.is_list:
if issubclass(type_, BotEntity): 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): 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: else:
return json.dumps(value, ensure_ascii = False) return json.dumps(value, ensure_ascii=False)
elif issubclass(type_, BotEntity): elif issubclass(type_, BotEntity):
return str(value.id) if value else "" return str(value.id) if value else ""
return str(value) 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]])