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

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

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

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

View File

@@ -1,67 +1,75 @@
from types import NoneType, UnionType
from typing import get_args, get_origin
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import Message, CallbackQuery
from decimal import Decimal
from datetime import datetime, time
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....model.bot_entity import BotEntity
from ....model.bot_enum import BotEnum
from ....model.descriptors import EntityFieldDescriptor
from ....model.settings import Settings
from ..context import ContextData, CallbackCommand, CommandContext
from ..navigation import get_navigation_context, pop_navigation_context
from ....model.user import UserBase
from ....utils.main import get_callable_str, get_value_repr
from ..context import ContextData, CommandContext
from .boolean import bool_editor
from .date import date_picker, time_picker
from .entity import entity_picker
from .string import string_editor
async def wrap_editor(keyboard_builder: InlineKeyboardBuilder,
field_descriptor: EntityFieldDescriptor,
callback_data: ContextData,
state_data: dict):
async def show_editor(message: Message | CallbackQuery, **kwargs):
field_descriptor: EntityFieldDescriptor = kwargs["field_descriptor"]
current_value = kwargs["current_value"]
user: UserBase = kwargs["user"]
callback_data: ContextData = kwargs.get("callback_data", None)
state_data: dict = kwargs["state_data"]
if callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT, CommandContext.ENTITY_FIELD_EDIT]:
value_type = field_descriptor.type_base
btns = []
entity_descriptor = field_descriptor.entity_descriptor
field_index = (entity_descriptor.field_sequence.index(field_descriptor.name)
if callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT]
else 0)
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)
stack, context = get_navigation_context(state_data = state_data)
context = pop_navigation_context(stack)
kwargs["edit_prompt"] = edit_prompt
if field_index > 0:
btns.append(InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = ContextData(
command = CallbackCommand.FIELD_EDITOR,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = entity_descriptor.field_sequence[field_index - 1]).pack()))
if value_type not in [int, float, Decimal, str]:
state_data.update({"context_data": callback_data.pack()})
if field_descriptor.is_optional:
btns.append(InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_SKIP_BTN)),
callback_data = ContextData(
command = CallbackCommand.FIELD_EDITOR_CALLBACK,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
data = "skip").pack()))
if value_type is bool:
await bool_editor(message=message, **kwargs)
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(
text = (await Settings.get(Settings.APP_STRINGS_CANCEL_BTN)),
callback_data = context.pack()))
elif value_type is time:
await time_picker(message=message, **kwargs)
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,
data = "cancel").pack()))
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}")

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime, time, timedelta
from aiogram import Router, F
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
@@ -6,10 +6,11 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from typing import TYPE_CHECKING
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....model.descriptors import EntityFieldDescriptor
from ....model.settings import Settings
from ..context import ContextData, CallbackCommand
from ..common import get_send_message, get_field_descriptor, get_entity_descriptor
from .common import wrap_editor
from ....utils.main import get_send_message, get_field_descriptor
from .wrapper import wrap_editor
if TYPE_CHECKING:
from ....main import QBotApp
@@ -19,77 +20,128 @@ logger = getLogger(__name__)
router = Router()
async def date_picker(message: Message | CallbackQuery,
@router.callback_query(ContextData.filter(F.command == CallbackCommand.TIME_PICKER))
async def time_picker_callback(
query: CallbackQuery, callback_data: ContextData, app: "QBotApp", **kwargs
):
if not callback_data.data:
return
field_descriptor = get_field_descriptor(app, callback_data)
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
kwargs["state_data"] = state_data
await time_picker(
query.message,
field_descriptor=field_descriptor,
callback_data=callback_data,
current_value=datetime.strptime(callback_data.data, "%Y-%m-%d %H-%M")
if len(callback_data.data) > 10
else time.fromisoformat(callback_data.data.replace("-", ":")),
**kwargs,
)
async def time_picker(
message: Message | CallbackQuery,
field_descriptor: EntityFieldDescriptor,
callback_data: ContextData,
current_value: datetime,
current_value: datetime | time,
state: FSMContext,
edit_prompt: str | None = None,
**kwargs):
if not current_value:
start_date = datetime.now()
else:
start_date = current_value
start_date = start_date.replace(day = 1)
previous_month = start_date - timedelta(days = 1)
next_month = start_date.replace(day = 28) + timedelta(days = 4)
**kwargs,
):
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"),
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,
data = current_day.strftime("%Y-%m-%d"),
save_state = True).pack()))
keyboard_builder.row(*buttons)
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,
await wrap_editor(
keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data = state_data)
state_data=state_data,
)
await state.set_data(state_data)
@@ -100,73 +152,211 @@ async def date_picker(message: Message | CallbackQuery,
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,
async def date_picker(
message: Message | CallbackQuery,
field_descriptor: EntityFieldDescriptor,
callback_data: ContextData,
app: "QBotApp",
current_value: datetime,
state: FSMContext,
**kwargs):
edit_prompt: str | None = None,
**kwargs,
):
if not current_value:
start_date = datetime.now()
else:
start_date = current_value
start_date = datetime.strptime(callback_data.data, "%Y-%m-%d")
start_date = current_value.replace(day=1)
state_data = await state.get_data()
kwargs["state_data"] = state_data
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_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"),
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 = current_date.strftime("%Y-%m-%d"),
save_state = True).pack()))
keyboard_builder.row(*buttons)
keyboard_builder.row(InlineKeyboardButton(text = "🔽",
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,
data = start_date.replace(year = start_date.year + 20).strftime("%Y-%m-%d")).pack()))
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,
await wrap_editor(
keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data = state_data)
state_data=state_data,
)
await query.message.edit_reply_markup(reply_markup=keyboard_builder.as_markup())
@router.callback_query(ContextData.filter(F.command == CallbackCommand.DATE_PICKER_MONTH))
async def date_picker_month(query: CallbackQuery, callback_data: ContextData, app: "QBotApp", **kwargs):
@router.callback_query(
ContextData.filter(F.command == CallbackCommand.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,
await date_picker(
query.message,
field_descriptor=field_descriptor,
callback_data=callback_data,
current_value = datetime.strptime(callback_data.data, "%Y-%m-%d"),
**kwargs)
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.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from sqlmodel import column
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import get_args, get_origin, TYPE_CHECKING
from typing import TYPE_CHECKING
from ....model.bot_entity import BotEntity
from ....model.owned_bot_entity import OwnedBotEntity
# from ....model.owned_bot_entity import OwnedBotEntity
from ....model.bot_enum import BotEnum
from ....model.settings import Settings
from ....model.user import UserBase
from ....model.view_setting import ViewSetting
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....model.descriptors import EntityFieldDescriptor, Filter
from ....model import EntityPermission
from ....utils import serialize, deserialize, get_user_permissions
from ....utils.main import (
get_user_permissions,
get_send_message,
get_field_descriptor,
get_callable_str,
)
from ....utils.serialization import serialize, deserialize
from ..context import ContextData, CallbackCommand
from ..common import (get_send_message, get_local_text, get_field_descriptor,
get_entity_descriptor, add_pagination_controls, add_filter_controls)
from .common import wrap_editor
from ..common.pagination import add_pagination_controls
from ..common.filtering import add_filter_controls
from .wrapper import wrap_editor
if TYPE_CHECKING:
from ....main import QBotApp
@@ -28,31 +35,38 @@ logger = getLogger(__name__)
router = Router()
async def entity_picker(message: Message | CallbackQuery,
async def entity_picker(
message: Message | CallbackQuery,
field_descriptor: EntityFieldDescriptor,
edit_prompt: str,
current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum],
**kwargs):
**kwargs,
):
state_data: dict = kwargs["state_data"]
state_data.update({"current_value": serialize(current_value, field_descriptor),
state_data.update(
{
"current_value": serialize(current_value, field_descriptor),
"value": serialize(current_value, field_descriptor),
"edit_prompt": edit_prompt})
"edit_prompt": edit_prompt,
}
)
await render_entity_picker(field_descriptor = field_descriptor,
await render_entity_picker(
field_descriptor=field_descriptor,
message=message,
current_value=current_value,
edit_prompt=edit_prompt,
**kwargs)
**kwargs,
)
def calc_total_pages(items_count: int, page_size: int) -> int:
return max(items_count // page_size + (1 if items_count % page_size else 0), 1)
async def render_entity_picker(*,
async def render_entity_picker(
*,
field_descriptor: EntityFieldDescriptor,
message: Message | CallbackQuery,
callback_data: ContextData,
@@ -62,25 +76,14 @@ async def render_entity_picker(*,
current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum],
edit_prompt: str,
page: int = 1,
**kwargs):
if callback_data.command in [CallbackCommand.ENTITY_PICKER_PAGE, CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM]:
**kwargs,
):
if callback_data.command in [
CallbackCommand.ENTITY_PICKER_PAGE,
CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM,
]:
page = int(callback_data.data.split("&")[0])
# is_list = False
# type_origin = get_origin(field_descriptor.type_)
# if type_origin == UnionType:
# type_ = get_args(field_descriptor.type_)[0]
# elif type_origin == list:
# type_ = get_args(field_descriptor.type_)[0]
# is_list = True
# else:
# type_ = field_descriptor.type_
type_ = field_descriptor.type_base
is_list = field_descriptor.is_list
@@ -88,89 +91,202 @@ async def render_entity_picker(*,
raise ValueError("Unsupported type")
page_size = await Settings.get(Settings.PAGE_SIZE)
form_list = None
if issubclass(type_, BotEnum):
items_count = len(type_.all_members)
total_pages = calc_total_pages(items_count, page_size)
page = min(page, total_pages)
enum_items = list(type_.all_members.values())[page_size * (page - 1):page_size * page]
items = [{"text": f"{"" if not is_list else "【✔︎】 " if item in (current_value or []) else "【 】 "}{item.localized(user.lang)}",
"value": item.value} for item in enum_items]
else:
enum_items = list(type_.all_members.values())[
page_size * (page - 1) : page_size * page
]
items = [
{
"text": f"{'' if not is_list else '【✔︎】 ' if item in (current_value or []) else '【 】 '}{item.localized(user.lang)}",
"value": item.value,
}
for item in enum_items
]
elif issubclass(type_, BotEntity):
form_name = field_descriptor.ep_form or "default"
form_list = type_.bot_entity_descriptor.lists.get(
form_name, type_.bot_entity_descriptor.default_list
)
permissions = get_user_permissions(user, type_.bot_entity_descriptor)
entity_filter = await ViewSetting.get_filter(session = db_session, user_id = user.id, entity_name = type_.bot_entity_descriptor.class_name)
if (EntityPermission.LIST_ALL in permissions or
(EntityPermission.LIST in permissions and
not issubclass(type_, OwnedBotEntity))):
items_count = await type_.get_count(session = db_session, filter = entity_filter)
if form_list.filtering:
entity_filter = await ViewSetting.get_filter(
session=db_session,
user_id=user.id,
entity_name=type_.bot_entity_descriptor.class_name,
)
else:
entity_filter = None
list_all = EntityPermission.LIST_ALL in permissions
if list_all or EntityPermission.LIST in permissions:
if (
field_descriptor.ep_parent_field
and field_descriptor.ep_parent_field
and callback_data.entity_id
):
entity = await field_descriptor.entity_descriptor.type_.get(
session=db_session, id=callback_data.entity_id
)
value = getattr(entity, field_descriptor.ep_parent_field)
ext_filter = column(field_descriptor.ep_child_field).__eq__(value)
else:
ext_filter = None
if form_list.pagination:
items_count = await type_.get_count(
session=db_session,
static_filter=(
[
Filter(
field_name=f.field_name,
operator=f.operator,
value_type="const",
value=f.value,
)
for f in form_list.static_filters
if f.value_type == "const"
]
if isinstance(form_list.static_filters, list)
else form_list.static_filters
),
ext_filter=ext_filter,
filter=entity_filter,
filter_fields=form_list.filtering_fields,
user=user if not list_all else None,
)
total_pages = calc_total_pages(items_count, page_size)
page = min(page, total_pages)
skip = page_size * (page - 1)
limit = page_size
else:
skip = 0
limit = None
entity_items = await type_.get_multi(
session = db_session, order_by = type_.name, filter = entity_filter,
skip = page_size * (page - 1), limit = page_size)
elif (EntityPermission.LIST in permissions and
issubclass(type_, OwnedBotEntity)):
items_count = await type_.get_count_by_user(session = db_session, user_id = user.id, filter = entity_filter)
total_pages = calc_total_pages(items_count, page_size)
page = min(page, total_pages)
entity_items = await type_.get_multi_by_user(
session = db_session, user_id = user.id, order_by = type_.name, filter = entity_filter,
skip = page_size * (page - 1), limit = page_size)
session=db_session,
order_by=form_list.order_by,
static_filter=(
[
Filter(
field_name=f.field_name,
operator=f.operator,
value_type="const",
value=f.value,
)
for f in form_list.static_filters
if f.value_type == "const"
]
if isinstance(form_list.static_filters, list)
else form_list.static_filters
),
ext_filter=ext_filter,
filter=entity_filter,
user=user if not list_all else None,
skip=skip,
limit=limit,
)
else:
items_count = 0
total_pages = 1
page = 1
entity_items = list[BotEntity]()
items = [{"text": f"{"" if not is_list else "【✔︎】 " if item in (current_value or []) else "【 】 "}{
type_.bot_entity_descriptor.item_caption(type_.bot_entity_descriptor, item) if type_.bot_entity_descriptor.item_caption
else get_local_text(item.name, user.lang) if type_.bot_entity_descriptor.fields_descriptors["name"].localizable else item.name}",
"value": str(item.id)} for item in entity_items]
# total_pages = items_count // page_size + (1 if items_count % page_size else 0)
items = [
{
"text": f"{
''
if not is_list
else '【✔︎】 '
if item in (current_value or [])
else '【 】 '
}{
type_.bot_entity_descriptor.item_repr(
type_.bot_entity_descriptor, item
)
if type_.bot_entity_descriptor.item_repr
else get_callable_str(
type_.bot_entity_descriptor.full_name,
type_.bot_entity_descriptor,
item,
)
if type_.bot_entity_descriptor.full_name
else f'{type_.bot_entity_descriptor.name}: {str(item.id)}'
}",
"value": str(item.id),
}
for item in entity_items
]
keyboard_builder = InlineKeyboardBuilder()
for item in items:
keyboard_builder.row(
InlineKeyboardButton(text = item["text"],
InlineKeyboardButton(
text=item["text"],
callback_data=ContextData(
command = CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM if is_list else CallbackCommand.FIELD_EDITOR_CALLBACK,
command=(
CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM
if is_list
else CallbackCommand.FIELD_EDITOR_CALLBACK
),
context=callback_data.context,
entity_name=callback_data.entity_name,
entity_id=callback_data.entity_id,
field_name=callback_data.field_name,
form_params=callback_data.form_params,
data=f"{page}&{item['value']}" if is_list else item["value"],
save_state = True).pack()))
).pack(),
)
)
add_pagination_controls(keyboard_builder = keyboard_builder,
if form_list and form_list.pagination:
add_pagination_controls(
keyboard_builder=keyboard_builder,
callback_data=callback_data,
total_pages=total_pages,
command=CallbackCommand.ENTITY_PICKER_PAGE,
page = page)
page=page,
)
if issubclass(type_, BotEntity):
add_filter_controls(keyboard_builder = keyboard_builder,
if (
issubclass(type_, BotEntity)
and form_list.filtering
and form_list.filtering_fields
):
add_filter_controls(
keyboard_builder=keyboard_builder,
entity_descriptor=type_.bot_entity_descriptor,
filter = entity_filter)
filter=entity_filter,
)
if is_list:
keyboard_builder.row(
InlineKeyboardButton(text = await Settings.get(Settings.APP_STRINGS_DONE_BTN),
InlineKeyboardButton(
text=await Settings.get(Settings.APP_STRINGS_DONE_BTN),
callback_data=ContextData(
command=CallbackCommand.FIELD_EDITOR_CALLBACK,
context=callback_data.context,
entity_name=callback_data.entity_name,
entity_id=callback_data.entity_id,
field_name=callback_data.field_name,
save_state = True).pack()))
form_params=callback_data.form_params,
).pack(),
)
)
state_data = kwargs["state_data"]
await wrap_editor(keyboard_builder = keyboard_builder,
await wrap_editor(
keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data = state_data)
state_data=state_data,
)
await state.set_data(state_data)
@@ -179,23 +295,30 @@ async def render_entity_picker(*,
await send_message(text=edit_prompt, reply_markup=keyboard_builder.as_markup())
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_PAGE))
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM))
async def entity_picker_callback(query: CallbackQuery,
@router.callback_query(
ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_PAGE)
)
@router.callback_query(
ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM)
)
async def entity_picker_callback(
query: CallbackQuery,
callback_data: ContextData,
db_session: AsyncSession,
app: "QBotApp",
state: FSMContext,
**kwargs):
**kwargs,
):
state_data = await state.get_data()
kwargs["state_data"] = state_data
field_descriptor = get_field_descriptor(app=app, callback_data=callback_data)
current_value = await deserialize(session = db_session, type_ = field_descriptor.type_, value = state_data["current_value"])
# current_value = await deserialize(session = db_session, type_ = field_descriptor.type_, value = state_data["current_value"])
edit_prompt = state_data["edit_prompt"]
value = await deserialize(session = db_session, type_ = field_descriptor.type_, value = state_data["value"])
value = await deserialize(
session=db_session, type_=field_descriptor.type_, value=state_data["value"]
)
if callback_data.command == CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM:
page, id_value = callback_data.data.split("&")
@@ -222,7 +345,8 @@ async def entity_picker_callback(query: CallbackQuery,
else:
raise ValueError("Unsupported command")
await render_entity_picker(field_descriptor = field_descriptor,
await render_entity_picker(
field_descriptor=field_descriptor,
message=query,
callback_data=callback_data,
current_value=value,
@@ -231,6 +355,5 @@ async def entity_picker_callback(query: CallbackQuery,
app=app,
state=state,
page=page,
**kwargs)
**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.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, CopyTextButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from typing import Any, get_args, get_origin
from typing import Any
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....model.descriptors import EntityFieldDescriptor
from ....model.language import LanguageBase
from ....model.settings import Settings
from ....utils import serialize
from ....utils.main import get_send_message, get_local_text
from ....utils.serialization import serialize
from ..context import ContextData, CallbackCommand
from ..common import get_send_message, get_local_text
from .common import wrap_editor
from .wrapper import wrap_editor
logger = getLogger(__name__)
router = Router()
async def string_editor(message: Message | CallbackQuery,
async def string_editor(
message: Message | CallbackQuery,
field_descriptor: EntityFieldDescriptor,
callback_data: ContextData,
current_value: Any,
edit_prompt: str,
state: FSMContext,
locale_index: int = 0,
**kwargs):
**kwargs,
):
keyboard_builder = InlineKeyboardBuilder()
state_data: dict = kwargs["state_data"]
_edit_prompt = edit_prompt
# type_ = field_descriptor.type_
# type_origin = get_origin(type_)
# if type_origin == UnionType:
# type_ = get_args(type_)[0]
if field_descriptor.type_base == str and field_descriptor.localizable:
current_locale = list(LanguageBase.all_members.values())[locale_index]
context_data = ContextData(
command=CallbackCommand.FIELD_EDITOR_CALLBACK,
context=callback_data.context,
entity_name=callback_data.entity_name,
entity_id=callback_data.entity_id,
field_name=callback_data.field_name,
save_state = True)
form_params=callback_data.form_params,
)
_edit_prompt = f"{edit_prompt}\n{(await Settings.get(
Settings.APP_STRINGS_STRING_EDITOR_LOCALE_TEMPLATE_P_NAME)).format(name = current_locale)}"
_current_value = get_local_text(current_value, current_locale) if current_value else None
if field_descriptor.type_base is str and field_descriptor.localizable:
current_locale = list(LanguageBase.all_members.values())[locale_index]
state_data.update({
_edit_prompt = f"{edit_prompt}\n{
(
await Settings.get(
Settings.APP_STRINGS_STRING_EDITOR_LOCALE_TEMPLATE_P_NAME
)
).format(name=current_locale)
}"
_current_value = (
get_local_text(current_value, current_locale) if current_value else None
)
state_data.update(
{
"context_data": context_data.pack(),
"edit_prompt": edit_prompt,
"locale_index": str(locale_index),
"current_value": current_value})
"current_value": current_value,
}
)
else:
context_data = ContextData(
command = CallbackCommand.FIELD_EDITOR_CALLBACK,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
save_state = True)
_current_value = serialize(current_value, field_descriptor)
state_data.update({
"context_data": context_data.pack()})
state_data.update({"context_data": context_data.pack()})
if _current_value:
_current_value_caption = f"{_current_value[:30]}..." if len(_current_value) > 30 else _current_value
keyboard_builder.row(InlineKeyboardButton(text = _current_value_caption,
copy_text = CopyTextButton(text = _current_value)))
_current_value_caption = (
f"{_current_value[:30]}..." if len(_current_value) > 30 else _current_value
)
keyboard_builder.row(
InlineKeyboardButton(
text=_current_value_caption,
copy_text=CopyTextButton(text=_current_value),
)
)
state_data = kwargs["state_data"]
await wrap_editor(keyboard_builder = keyboard_builder,
await wrap_editor(
keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data = state_data)
state_data=state_data,
)
await state.set_data(state_data)
send_message = get_send_message(message)
await send_message(text=_edit_prompt, reply_markup=keyboard_builder.as_markup())
# async def context_command_fiter(*args, **kwargs):
# print(args, kwargs)
# return True

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.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.i18n import I18n
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....model.bot_entity import BotEntity
from ....model.bot_enum import BotEnum
from ....model.owned_bot_entity import OwnedBotEntity
from ....model.descriptors import FieldEditButton, CommandButton
from ....model.settings import Settings
from ....model.user import UserBase
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....model import EntityPermission
from ....utils import serialize, deserialize, get_user_permissions
from ....utils.main import (
check_entity_permission,
get_send_message,
clear_state,
get_value_repr,
get_callable_str,
get_entity_descriptor,
)
from ..context import ContextData, CallbackCommand, CommandContext
from ..common import get_send_message, get_local_text, get_entity_descriptor, get_callable_str, get_value_repr
from ..navigation import (
pop_navigation_context,
save_navigation_context,
)
if TYPE_CHECKING:
from ....main import QBotApp
@@ -30,7 +34,6 @@ router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_ITEM))
async def entity_item_callback(query: CallbackQuery, **kwargs):
callback_data: ContextData = kwargs["callback_data"]
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
@@ -42,68 +45,79 @@ async def entity_item_callback(query: CallbackQuery, **kwargs):
await entity_item(query=query, navigation_stack=stack, **kwargs)
async def entity_item(query: CallbackQuery,
async def entity_item(
query: CallbackQuery,
callback_data: ContextData,
db_session: AsyncSession,
user: UserBase,
app: "QBotApp",
navigation_stack: list[ContextData],
**kwargs):
**kwargs,
):
entity_descriptor = get_entity_descriptor(app, callback_data)
user_permissions = get_user_permissions(user, entity_descriptor)
entity_type: BotEntity = entity_descriptor.type_
# user_permissions = get_user_permissions(user, entity_descriptor)
entity_type = entity_descriptor.type_
keyboard_builder = InlineKeyboardBuilder()
entity_item = await entity_type.get(session=db_session, id=callback_data.entity_id)
if not entity_item:
return await query.answer(text = (await Settings.get(Settings.APP_STRINGS_NOT_FOUND)))
return await query.answer(
text=(await Settings.get(Settings.APP_STRINGS_NOT_FOUND))
)
is_owned = issubclass(entity_type, OwnedBotEntity)
# is_owned = issubclass(entity_type, OwnedBotEntity)
if (EntityPermission.READ not in user_permissions and
EntityPermission.READ_ALL not in user_permissions):
if not check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.READ
):
return await query.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
)
return await query.answer(text = (await Settings.get(Settings.APP_STRINGS_FORBIDDEN)))
can_edit = check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.UPDATE
)
if (is_owned and
EntityPermission.READ_ALL not in user_permissions and
entity_item.user_id != user.id):
return await query.answer(text = (await Settings.get(Settings.APP_STRINGS_FORBIDDEN)))
can_edit = (EntityPermission.UPDATE_ALL in user_permissions or
(EntityPermission.UPDATE in user_permissions and not is_owned) or
(EntityPermission.UPDATE in user_permissions and is_owned and
entity_item.user_id == user.id))
can_delete = (EntityPermission.DELETE_ALL in user_permissions or
(EntityPermission.DELETE in user_permissions and not is_owned) or
(EntityPermission.DELETE in user_permissions and is_owned and
entity_item.user_id == user.id))
form = entity_descriptor.forms.get(
callback_data.form_params or "default", entity_descriptor.default_form
)
if can_edit:
for edit_buttons_row in entity_descriptor.edit_buttons:
for edit_buttons_row in form.form_buttons:
btn_row = []
for field_name in edit_buttons_row:
field_name, btn_caption = field_name if isinstance(field_name, tuple) else (field_name, None)
for button in edit_buttons_row:
if isinstance(button, FieldEditButton):
field_name = button.field_name
btn_caption = button.caption
if field_name in entity_descriptor.fields_descriptors:
field_descriptor = entity_descriptor.fields_descriptors[field_name]
# if field_descriptor.is_list and issubclass(field_descriptor.type_base, BotEntity):
# await field_descriptor.type_base.
field_descriptor = entity_descriptor.fields_descriptors[
field_name
]
field_value = getattr(entity_item, field_descriptor.field_name)
if btn_caption:
btn_text = get_callable_str(btn_caption, field_descriptor, entity_item, field_value)
btn_text = get_callable_str(
btn_caption, field_descriptor, entity_item, field_value
)
else:
if field_descriptor.type_base == bool:
btn_text = f"{"【✔︎】 " if field_value else "【 】 "}{
get_callable_str(field_descriptor.caption, field_descriptor, entity_item, field_value) if field_descriptor.caption
else field_name}"
if field_descriptor.type_base is bool:
btn_text = f"{'【✔︎】 ' if field_value else '【 】 '}{
get_callable_str(
field_descriptor.caption,
field_descriptor,
entity_item,
field_value,
)
if field_descriptor.caption
else field_name
}"
else:
btn_text = (f"✏️ {get_callable_str(field_descriptor.caption, field_descriptor, entity_item, field_value)}"
if field_descriptor.caption else f"✏️ {field_name}")
btn_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,
@@ -112,12 +126,39 @@ async def entity_item(query: CallbackQuery,
context=CommandContext.ENTITY_FIELD_EDIT,
entity_name=entity_descriptor.name,
entity_id=str(entity_item.id),
field_name = field_name).pack()))
field_name=field_name,
).pack(),
)
)
elif isinstance(button, CommandButton):
btn_caption = button.caption
if btn_caption:
btn_text = get_callable_str(
btn_caption, entity_descriptor, entity_item
)
else:
btn_text = button.command
btn_row.append(
InlineKeyboardButton(
text=btn_text,
callback_data=(
button.context_data.pack()
if button.context_data
else ContextData(
command=CallbackCommand.USER_COMMAND,
user_command=button.command,
data=str(entity_item.id),
).pack()
),
)
)
if btn_row:
keyboard_builder.row(*btn_row)
edit_delete_row = []
if can_edit and entity_descriptor.edit_button_visible:
if can_edit and form.show_edit_button:
edit_delete_row.append(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_EDIT_BTN)),
@@ -126,40 +167,74 @@ async def entity_item(query: CallbackQuery,
context=CommandContext.ENTITY_EDIT,
entity_name=entity_descriptor.name,
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(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_DELETE_BTN)),
callback_data=ContextData(
command=CallbackCommand.ENTITY_DELETE,
entity_name=entity_descriptor.name,
entity_id = str(entity_item.id)).pack()))
form_params=callback_data.form_params,
entity_id=str(entity_item.id),
).pack(),
)
)
if edit_delete_row:
keyboard_builder.row(*edit_delete_row)
entity_caption = get_callable_str(entity_descriptor.caption, entity_descriptor, entity_item)
entity_item_name = get_local_text(entity_item.name, user.lang) if entity_descriptor.fields_descriptors["name"].localizable else entity_item.name
if form.item_repr:
item_text = form.item_repr(entity_descriptor, entity_item)
else:
entity_caption = (
get_callable_str(
entity_descriptor.full_name, entity_descriptor, entity_item
)
if entity_descriptor.full_name
else entity_descriptor.name
)
item_text = f"<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)
)
item_text = f"<b><u><i>{entity_caption or entity_descriptor.name}:</i></u></b> <b>{entity_item_repr}</b>"
for field_descriptor in entity_descriptor.fields_descriptors.values():
if field_descriptor.name == "name" or not field_descriptor.is_visible:
continue
field_caption = get_callable_str(field_descriptor.caption, field_descriptor, entity_item)
value = get_value_repr(value = getattr(entity_item, field_descriptor.name),
if field_descriptor.is_visible:
field_caption = get_callable_str(
field_descriptor.caption, field_descriptor, entity_item
)
value = get_value_repr(
value=getattr(entity_item, field_descriptor.name),
field_descriptor=field_descriptor,
locale = user.lang)
item_text += f"\n{field_caption or field_descriptor.name}:{f" <b>{value}</b>" if value else ""}"
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)
if context:
keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = context.pack()))
callback_data=context.pack(),
)
)
state: FSMContext = kwargs["state"]
state_data = kwargs["state_data"]
@@ -168,66 +243,3 @@ async def entity_item(query: CallbackQuery,
send_message = get_send_message(query)
await send_message(text=item_text, reply_markup=keyboard_builder.as_markup())
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_DELETE))
async def entity_delete_callback(query: CallbackQuery, **kwargs):
callback_data: ContextData = kwargs["callback_data"]
user: UserBase = kwargs["user"]
db_session: AsyncSession = kwargs["db_session"]
app: "QBotApp" = kwargs["app"]
entity_descriptor = get_entity_descriptor(app = app, callback_data = callback_data)
user_permissions = get_user_permissions(user, entity_descriptor)
entity = await entity_descriptor.type_.get(session = db_session, id = int(callback_data.entity_id))
if not (EntityPermission.DELETE_ALL in user_permissions or
(EntityPermission.DELETE in user_permissions and not issubclass(entity_descriptor.type_, OwnedBotEntity)) or
(EntityPermission.DELETE in user_permissions and issubclass(entity_descriptor.type_, OwnedBotEntity) and
entity.user_id == user.id)):
return await query.answer(text = (await Settings.get(Settings.APP_STRINGS_FORBIDDEN)))
if callback_data.data == "yes":
await entity_descriptor.type_.remove(
session = db_session, id = int(callback_data.entity_id), commit = True)
await route_callback(message = query, **kwargs)
elif callback_data.data == "no":
await route_callback(message = query, back = False, **kwargs)
elif not callback_data.data:
field_descriptor = entity_descriptor.fields_descriptors["name"]
entity = await entity_descriptor.type_.get(session = db_session, id = int(callback_data.entity_id))
return await query.message.edit_text(
text = (await Settings.get(Settings.APP_STRINGS_CONFIRM_DELETE_P_NAME)).format(
name = get_value_repr(
value = getattr(entity, field_descriptor.name),
field_descriptor = field_descriptor,
locale = user.lang)),
reply_markup = InlineKeyboardBuilder().row(
InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_YES_BTN)),
callback_data = ContextData(
command = CallbackCommand.ENTITY_DELETE,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
data = "yes").pack()),
InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_NO_BTN)),
callback_data = ContextData(
command = CallbackCommand.ENTITY_ITEM,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
data = "no").pack())).as_markup())
from ..navigation import pop_navigation_context, save_navigation_context, clear_state, route_callback

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.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.i18n import I18n
from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....model.bot_entity import BotEntity
from ....model.bot_enum import BotEnum
from ....model.owned_bot_entity import OwnedBotEntity
from ....model.settings import Settings
from ....model.user import UserBase
from ....model.view_setting import ViewSetting
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....model.descriptors import EntityDescriptor, Filter
from ....model import EntityPermission
from ....utils import serialize, deserialize, get_user_permissions
from ....utils.main import (
get_user_permissions,
get_send_message,
clear_state,
get_entity_descriptor,
get_callable_str,
)
from ....utils.serialization import deserialize
from ..context import ContextData, CallbackCommand, CommandContext
from ..common import (add_pagination_controls, get_local_text, get_entity_descriptor,
get_callable_str, get_send_message, add_filter_controls)
from ..common.pagination import add_pagination_controls
from ..common.filtering import add_filter_controls
from ..navigation import pop_navigation_context, save_navigation_context
if TYPE_CHECKING:
from ....main import QBotApp
@@ -32,7 +35,6 @@ router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_LIST))
async def entity_list_callback(query: CallbackQuery, **kwargs):
callback_data: ContextData = kwargs["callback_data"]
if callback_data.data == "skip":
@@ -47,26 +49,73 @@ async def entity_list_callback(query: CallbackQuery, **kwargs):
await entity_list(message=query, navigation_stack=stack, **kwargs)
def calc_total_pages(items_count: int, page_size: int) -> int:
return max(items_count // page_size + (1 if items_count % page_size else 0), 1)
async def entity_list(message: CallbackQuery | Message,
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):
**kwargs,
):
page = int(callback_data.data or "1")
entity_descriptor = get_entity_descriptor(app, callback_data)
user_permissions = get_user_permissions(user, entity_descriptor)
entity_type = entity_descriptor.type_
form_params = (
callback_data.form_params.split("&") if callback_data.form_params else []
)
form_name = form_params.pop(0) if form_params else "default"
form_list = entity_descriptor.lists.get(
form_name or "default", entity_descriptor.default_list
)
form_item = entity_descriptor.forms.get(
form_list.item_form or "default", entity_descriptor.default_form
)
keyboard_builder = InlineKeyboardBuilder()
if EntityPermission.CREATE in user_permissions or EntityPermission.CREATE_ALL in user_permissions:
if (
EntityPermission.CREATE in user_permissions
or EntityPermission.CREATE_ALL in user_permissions
) and form_list.show_add_new_button:
keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_ADD_BTN)),
@@ -74,93 +123,138 @@ async def entity_list(message: CallbackQuery | Message,
command=CallbackCommand.FIELD_EDITOR,
context=CommandContext.ENTITY_CREATE,
entity_name=entity_descriptor.name,
field_name = entity_descriptor.field_sequence[0],
save_state = True).pack()))
field_name=form_item.edit_field_sequence[0],
form_params=form_list.item_form,
).pack(),
)
)
page_size = await Settings.get(Settings.PAGE_SIZE)
entity_filter = await ViewSetting.get_filter(session = db_session, user_id = user.id, entity_name = entity_descriptor.class_name)
if issubclass(entity_type, OwnedBotEntity):
if EntityPermission.READ_ALL in user_permissions or EntityPermission.LIST_ALL in user_permissions:
items_count = await entity_type.get_count(session = db_session, filter = entity_filter)
total_pages = calc_total_pages(items_count, page_size)
page = min(page, total_pages)
items = await entity_type.get_multi(
session = db_session, order_by = entity_type.name, filter = entity_filter,
skip = page_size * (page - 1), limit = page_size)
elif EntityPermission.READ in user_permissions or EntityPermission.LIST in user_permissions:
items_count = await entity_type.get_count_by_user(session = db_session, user_id = user.id, filter = entity_filter)
total_pages = calc_total_pages(items_count, page_size)
page = min(page, total_pages)
items = await entity_type.get_multi_by_user(
session = db_session, user_id = user.id, order_by = entity_type.name, filter = entity_filter,
skip = page_size * (page - 1), limit = page_size)
if form_list.filtering:
entity_filter = await ViewSetting.get_filter(
session=db_session,
user_id=user.id,
entity_name=entity_descriptor.class_name,
)
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)
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)
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)
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
items_count = 0
else:
raise ValueError(f"Unsupported entity type: {entity_type}")
for item in items:
if entity_descriptor.item_caption:
caption = entity_descriptor.item_caption(entity_descriptor, item)
elif entity_descriptor.fields_descriptors["name"].localizable:
caption = get_local_text(item.name, user.lang)
if form_list.item_repr:
caption = form_list.item_repr(entity_descriptor, item)
elif entity_descriptor.item_repr:
caption = entity_descriptor.item_repr(entity_descriptor, item)
elif entity_descriptor.full_name:
caption = f"{
get_callable_str(
callable_str=entity_descriptor.full_name,
descriptor=entity_descriptor,
entity=item,
)
}: {item.id}"
else:
caption = item.name
caption = f"{entity_descriptor.name}: {item.id}"
keyboard_builder.row(
InlineKeyboardButton(
text=caption,
callback_data=ContextData(
command=CallbackCommand.ENTITY_ITEM,
entity_name=entity_descriptor.name,
entity_id = str(item.id)).pack()))
form_params=form_list.item_form,
entity_id=str(item.id),
).pack(),
)
)
add_pagination_controls(keyboard_builder = keyboard_builder,
if form_list.pagination:
add_pagination_controls(
keyboard_builder=keyboard_builder,
callback_data=callback_data,
total_pages=total_pages,
command=CallbackCommand.ENTITY_LIST,
page = page)
page=page,
)
add_filter_controls(keyboard_builder = keyboard_builder,
if form_list.filtering and form_list.filtering_fields:
add_filter_controls(
keyboard_builder=keyboard_builder,
entity_descriptor=entity_descriptor,
filter = entity_filter)
filter=entity_filter,
)
context = pop_navigation_context(navigation_stack)
if context:
keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = context.pack()))
callback_data=context.pack(),
)
)
if entity_descriptor.caption:
entity_text = get_callable_str(entity_descriptor.caption_plural, entity_descriptor)
if form_list.caption:
entity_text = get_callable_str(form_list.caption, entity_descriptor)
else:
if entity_descriptor.full_name_plural:
entity_text = get_callable_str(
entity_descriptor.full_name_plural, entity_descriptor
)
else:
entity_text = entity_descriptor.name
if entity_descriptor.description:
entity_desciption = get_callable_str(entity_descriptor.description, entity_descriptor)
else:
entity_desciption = None
entity_text = f"{entity_text} {get_callable_str(entity_descriptor.description, entity_descriptor)}"
state: FSMContext = kwargs["state"]
state_data = kwargs["state_data"]
@@ -168,8 +262,4 @@ async def entity_list(message: CallbackQuery | Message,
send_message = get_send_message(message)
await send_message(text = f"{entity_text}{f"\n{entity_desciption}" if entity_desciption else ""}",
reply_markup = keyboard_builder.as_markup())
from ..navigation import pop_navigation_context, save_navigation_context, clear_state
await send_message(text=entity_text, reply_markup=keyboard_builder.as_markup())

View File

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

View File

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

View File

@@ -1,93 +1,88 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand
from ..common import get_send_message
from ....utils.main import get_send_message
from ..navigation import save_navigation_context, pop_navigation_context
import qbot.bot.handlers.menu.entities as entities
import qbot.bot.handlers.menu.settings as settings
import qbot.bot.handlers.menu.parameters as parameters
import qbot.bot.handlers.menu.language as language
import qbot.bot.handlers.editors.main as editor
import qbot.bot.handlers.editors.main_callbacks as editor_callbacks
import qbot.bot.handlers.forms.entity_list as entity_list
import qbot.bot.handlers.forms.entity_form as entity_form
import qbot.bot.handlers.forms.entity_form_callbacks as entity_form_callbacks
import qbot.bot.handlers.common.filtering_callbacks as filtering_callbacks
import qbot.bot.handlers.user_handlers as user_handlers
logger = getLogger(__name__)
router = Router()
# @router.message(Command("menu"))
# async def command_menu(message: Message, **kwargs):
# await clear_state(state = kwargs["state"], clear_nav = True)
# callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_MAIN)
# stack = await save_navigation_context(callback_data = callback_data, state = kwargs["state"])
# kwargs.update({"navigation_stack": stack, "callback_data": callback_data})
# await main_menu(message, **kwargs)
# @router.callback_query(CallbackData.filter(F.command == CallbackCommand.MENU_ENTRY))
# async def menu_entry(query: CallbackQuery, callback_data: CallbackData, user: UserBase, db_session: AsyncSession, app: QBotApp):
# pass
@router.callback_query(ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_MAIN))
async def menu_entry_main(message: CallbackQuery, **kwargs):
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)
async def main_menu(message: Message | CallbackQuery, navigation_stack: list[ContextData], **kwargs):
async def main_menu(
message: Message | CallbackQuery, navigation_stack: list[ContextData], **kwargs
):
keyboard_builder = InlineKeyboardBuilder()
keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_REFERENCES_BTN)),
callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_ENTITIES).pack()))
callback_data=ContextData(
command=CallbackCommand.MENU_ENTRY_ENTITIES
).pack(),
)
)
keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_SETTINGS_BTN)),
callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_SETTINGS).pack()))
callback_data=ContextData(
command=CallbackCommand.MENU_ENTRY_SETTINGS
).pack(),
)
)
context = pop_navigation_context(navigation_stack)
if context:
keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = context.pack()))
callback_data=context.pack(),
)
)
send_message = get_send_message(message)
await send_message(text = (await Settings.get(Settings.APP_STRINGS_MAIN_NENU)),
reply_markup = keyboard_builder.as_markup())
from .entities import router as entities_router
from .settings import router as settings_router
from .parameters import router as parameters_router
from .language import router as language_router
from ..editors import router as editors_router
from ..forms.entity_list import router as entity_list_router
from ..forms.entity_form import router as entity_form_router
from ..common import router as common_router
from ..user_handlers import router as user_handlers_router
router.include_routers(
entities_router,
settings_router,
parameters_router,
language_router,
editors_router,
entity_list_router,
entity_form_router,
common_router,
user_handlers_router
await send_message(
text=(await Settings.get(Settings.APP_STRINGS_MAIN_NENU)),
reply_markup=keyboard_builder.as_markup(),
)
from ..navigation import save_navigation_context, pop_navigation_context, clear_state
router.include_routers(
entities.router,
settings.router,
parameters.router,
language.router,
editor.router,
editor_callbacks.router,
entity_list.router,
entity_form.router,
entity_form_callbacks.router,
filtering_callbacks.router,
user_handlers.router,
)

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery
from .context import ContextData, CallbackCommand
def save_navigation_context(callback_data: ContextData, state_data: dict) -> list[ContextData]:
stack = [ContextData.unpack(item) for item in state_data.get("navigation_stack", [])]
def save_navigation_context(
callback_data: ContextData, state_data: dict
) -> list[ContextData]:
stack = [
ContextData.unpack(item) for item in state_data.get("navigation_stack", [])
]
data_nc = state_data.get("navigation_context")
navigation_context = ContextData.unpack(data_nc) if data_nc else None
if callback_data.back:
@@ -13,9 +14,12 @@ def save_navigation_context(callback_data: ContextData, state_data: dict) -> lis
if stack:
stack.pop()
else:
if (stack and navigation_context and
navigation_context.command == callback_data.command and
navigation_context.command != CallbackCommand.USER_COMMAND):
if (
stack
and navigation_context
and navigation_context.command == callback_data.command
and navigation_context.command != CallbackCommand.USER_COMMAND
):
navigation_context = callback_data
elif navigation_context:
stack.append(navigation_context)
@@ -33,63 +37,12 @@ def pop_navigation_context(stack: list[ContextData]) -> ContextData | None:
return data
def get_navigation_context(state_data: dict) -> tuple[list[ContextData], ContextData | None]:
def get_navigation_context(
state_data: dict,
) -> tuple[list[ContextData], ContextData | None]:
data_nc = state_data.get("navigation_context")
context = ContextData.unpack(data_nc) if data_nc else None
return ([ContextData.unpack(item) for item in state_data.get("navigation_stack", [])],
context)
def clear_state(state_data: dict, clear_nav: bool = False):
if clear_nav:
state_data.clear()
else:
stack = state_data.get("navigation_stack")
context = state_data.get("navigation_context")
state_data.clear()
if stack:
state_data["navigation_stack"] = stack
if context:
state_data["navigation_context"] = context
async def route_callback(message: Message | CallbackQuery, back: bool = True, **kwargs):
state_data = kwargs["state_data"]
stack, context = get_navigation_context(state_data)
if back:
context = pop_navigation_context(stack)
stack = save_navigation_context(callback_data = context, state_data = state_data)
kwargs.update({"callback_data": context, "navigation_stack": stack})
if context:
if context.command == CallbackCommand.MENU_ENTRY_MAIN:
await main_menu(message, **kwargs)
elif context.command == CallbackCommand.MENU_ENTRY_SETTINGS:
await settings_menu(message, **kwargs)
elif context.command == CallbackCommand.MENU_ENTRY_PARAMETERS:
await parameters_menu(message, **kwargs)
elif context.command == CallbackCommand.MENU_ENTRY_LANGUAGE:
await language_menu(message, **kwargs)
elif context.command == CallbackCommand.MENU_ENTRY_ENTITIES:
await entities_menu(message, **kwargs)
elif context.command == CallbackCommand.ENTITY_LIST:
await entity_list(message, **kwargs)
elif context.command == CallbackCommand.ENTITY_ITEM:
await entity_item(message, **kwargs)
elif context.command == CallbackCommand.FIELD_EDITOR:
await field_editor(message, **kwargs)
else:
raise ValueError(f"Unknown command {context.command}")
else:
raise ValueError("No navigation context")
from .menu.main import main_menu
from .menu.settings import settings_menu
from .menu.parameters import parameters_menu
from .menu.language import language_menu
from .menu.entities import entities_menu
from .forms.entity_list import entity_list
from .forms.entity_form import entity_item
from .editors import field_editor
return (
[ContextData.unpack(item) for item in state_data.get("navigation_stack", [])],
context,
)

View File

@@ -8,7 +8,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ...main import QBotApp
from ...model.settings import Settings
from ...model.language import LanguageBase
from .navigation import clear_state
from ...utils.main import clear_state
logger = getLogger(__name__)
@@ -16,8 +16,9 @@ router = Router()
@router.message(CommandStart())
async def start(message: Message, db_session: AsyncSession, app: QBotApp, state: FSMContext):
async def start(
message: Message, db_session: AsyncSession, app: QBotApp, state: FSMContext
):
state_data = await state.get_data()
clear_state(state_data=state_data, clear_nav=True)
@@ -26,30 +27,45 @@ async def start(message: Message, db_session: AsyncSession, app: QBotApp, state:
user = await User.get(session=db_session, id=message.from_user.id)
if not user:
msg_text = (await Settings.get(Settings.APP_STRINGS_WELCOME_P_NAME)).format(name = message.from_user.full_name)
msg_text = (await Settings.get(Settings.APP_STRINGS_WELCOME_P_NAME)).format(
name=message.from_user.full_name
)
try:
if message.from_user.language_code in [item.value for item in LanguageBase.all_members.values()]:
if message.from_user.language_code in [
item.value for item in LanguageBase.all_members.values()
]:
lang = LanguageBase(message.from_user.language_code)
user = await User.create(session = db_session,
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:
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)))
message.answer(
(
await Settings.get(Settings.APP_STRINGS_INTERNAL_ERROR_P_ERROR)
).format(error=str(e))
)
return
else:
if user.is_active:
msg_text = (await Settings.get(Settings.APP_STRINGS_GREETING_P_NAME)).format(name = user.name)
msg_text = (
await Settings.get(Settings.APP_STRINGS_GREETING_P_NAME)
).format(name=user.name)
else:
msg_text = (await Settings.get(Settings.APP_STRINGS_USER_BLOCKED_P_NAME)).format(name = user.name)
msg_text = (
await Settings.get(Settings.APP_STRINGS_USER_BLOCKED_P_NAME)
).format(name=user.name)
await message.answer(msg_text)

View File

@@ -1,14 +1,16 @@
from dataclasses import dataclass, field
from typing import Any, Callable, TYPE_CHECKING
from typing import TYPE_CHECKING
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from aiogram.utils.i18n import I18n
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlmodel.ext.asyncio.session import AsyncSession
from ..context import ContextData, CallbackCommand
from ....model.user import UserBase
from ....model.settings import Settings
from ....utils.main import get_send_message, clear_state
from ....model.descriptors import CommandCallbackContext
from ..navigation import (
save_navigation_context,
get_navigation_context,
pop_navigation_context,
)
if TYPE_CHECKING:
@@ -18,51 +20,22 @@ if TYPE_CHECKING:
router = Router()
@dataclass(kw_only = True)
class CommandCallbackContext[UT: UserBase]:
keyboard_builder: InlineKeyboardBuilder = field(default_factory = InlineKeyboardBuilder)
message_text: str | None = None
register_navigation: bool = True
message: Message | CallbackQuery
callback_data: ContextData
db_session: AsyncSession
user: UT
app: "QBotApp"
state_data: dict[str, Any]
state: FSMContext
i18n: I18n
kwargs: dict[str, Any] = field(default_factory = dict)
@dataclass(kw_only = True)
class Command:
name: str
handler: Callable[[CommandCallbackContext], None]
caption: str | dict[str, str] | None = None
register_navigation: bool = True
clear_navigation: bool = False
clear_state: bool = True
@router.message(F.text.startswith("/"))
async def command_text(message: Message, **kwargs):
str_command = message.text.lstrip("/")
callback_data = ContextData(command = CallbackCommand.USER_COMMAND,
user_command = str_command)
callback_data = ContextData(
command=CallbackCommand.USER_COMMAND, user_command=str_command
)
await command_handler(message=message, callback_data=callback_data, **kwargs)
@router.callback_query(ContextData.filter(F.command == CallbackCommand.USER_COMMAND))
async def command_callback(message: CallbackQuery, **kwargs):
await command_handler(message=message, **kwargs)
async def command_handler(message: Message | CallbackQuery, **kwargs):
callback_data: ContextData = kwargs.pop("callback_data")
str_command = callback_data.user_command
app: "QBotApp" = kwargs.pop("app")
@@ -82,7 +55,9 @@ async def command_handler(message: Message | CallbackQuery, **kwargs):
state_data.pop("navigation_context", None)
if command.register_navigation:
stack = save_navigation_context(callback_data = callback_data, state_data = state_data)
stack = save_navigation_context(
callback_data=callback_data, state_data=state_data
)
callback_context = CommandCallbackContext[app.user_class](
message=message,
@@ -93,22 +68,23 @@ async def command_handler(message: Message | CallbackQuery, **kwargs):
state_data=state_data,
state=state,
i18n=kwargs.pop("i18n"),
kwargs = kwargs)
kwargs=kwargs,
)
await command.handler(callback_context)
await state.set_data(state_data)
if command.register_navigation:
stack, navigation_context = get_navigation_context(state_data=state_data)
back_callback_data = pop_navigation_context(stack=stack)
if back_callback_data:
callback_context.keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = back_callback_data.pack()))
callback_data=back_callback_data.pack(),
)
)
send_message = get_send_message(message)
@@ -116,10 +92,11 @@ async def command_handler(message: Message | CallbackQuery, **kwargs):
message = message.message
if callback_context.message_text:
await send_message(text = callback_context.message_text,
reply_markup = callback_context.keyboard_builder.as_markup())
await send_message(
text=callback_context.message_text,
reply_markup=callback_context.keyboard_builder.as_markup(),
)
else:
await message.edit_reply_markup(reply_markup = callback_context.keyboard_builder.as_markup())
from ..common import get_send_message
from ..navigation import save_navigation_context, get_navigation_context, clear_state, pop_navigation_context
await message.edit_reply_markup(
reply_markup=callback_context.keyboard_builder.as_markup()
)

View File

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

View File

@@ -4,15 +4,16 @@ from sqlalchemy.orm import sessionmaker
from ..config import config
import logging
logger = logging.getLogger('sqlalchemy.engine')
logger.setLevel(logging.DEBUG)
# import logging
# logger = logging.getLogger('sqlalchemy.engine')
# logger.setLevel(logging.DEBUG)
async_engine = create_async_engine(config.DATABASE_URI)
async_session = sessionmaker[AsyncSession](
async_engine, class_=AsyncSession, expire_on_commit=False
)
async def get_db() -> AsyncSession: # type: ignore
async with async_session() as session:
yield session

View File

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

View File

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

91
main.py
View File

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

View File

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

View File

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

View File

@@ -5,10 +5,6 @@ from ...model.user import UserBase
class I18nMiddleware(SimpleI18nMiddleware):
"""
This middleware stores locale in the FSM storage
"""
def __init__[UserType: UserBase](
self,
user_class: type[UserType],

View File

@@ -1,30 +1,26 @@
from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
from aiogram.fsm.context import FSMContext
from aiogram.utils.i18n import gettext as _
# from typing import Any, Awaitable, Callable, Dict
# from aiogram import BaseMiddleware
# from aiogram.types import TelegramObject
# from aiogram.fsm.context import FSMContext
# from aiogram.utils.i18n import gettext as _
from ...bot.handlers.context import ContextData
# from ...bot.handlers.context import ContextData
class ResetStateMiddleware(BaseMiddleware):
async def __call__(self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]) -> Any:
save_state = False
callback_data = data.get("callback_data")
if isinstance(callback_data, ContextData):
save_state = callback_data.save_state
if not save_state:
state = data.get("state")
if isinstance(state, FSMContext):
await state.clear()
return await handler(event, data)
# class ResetStateMiddleware(BaseMiddleware):
# async def __call__(self,
# handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
# event: TelegramObject,
# data: Dict[str, Any]) -> Any:
# save_state = False
# callback_data = data.get("callback_data")
# if isinstance(callback_data, ContextData):
# save_state = callback_data.save_state
# 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
class EntityPermission(BotEnum):
LIST = EnumMember("list")
READ = EnumMember("read")
CREATE = EnumMember("create")
@@ -25,7 +23,6 @@ class EntityPermission(BotEnum):
def session_dep(func):
@wraps(func)
async def wrapper(cls, *args, **kwargs):
if "session" in kwargs and kwargs["session"]:
return await func(cls, *args, **kwargs)

View File

@@ -1,5 +1,6 @@
class Singleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
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 typing import ClassVar, ForwardRef, Optional, Union, cast, get_args, get_origin
from typing import (
Any,
ClassVar,
ForwardRef,
Optional,
Self,
Union,
get_args,
get_origin,
TYPE_CHECKING,
)
from pydantic import BaseModel
from sqlmodel import SQLModel, BIGINT, Field, select, func, column
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import SelectOfScalar
from sqlmodel.main import SQLModelMetaclass, RelationshipInfo
from .descriptors import EntityDescriptor, EntityField, EntityFieldDescriptor
from .descriptors import EntityDescriptor, EntityField, EntityFieldDescriptor, Filter
from .entity_metadata import EntityMetadata
from . import session_dep
if TYPE_CHECKING:
from .user import UserBase
class BotEntityMetaclass(SQLModelMetaclass):
__future_references__ = {}
def __new__(mcs, name, bases, namespace, **kwargs):
bot_fields_descriptors = {}
if bases:
bot_entity_descriptor = bases[0].__dict__.get('bot_entity_descriptor')
bot_fields_descriptors = {key: EntityFieldDescriptor(**value.__dict__.copy())
for key, value in bot_entity_descriptor.fields_descriptors.items()} if bot_entity_descriptor else {}
if '__annotations__' in namespace:
for annotation in namespace['__annotations__']:
bot_entity_descriptor = bases[0].__dict__.get("bot_entity_descriptor")
bot_fields_descriptors = (
{
key: EntityFieldDescriptor(**value.__dict__.copy())
for key, value in bot_entity_descriptor.fields_descriptors.items()
}
if bot_entity_descriptor
else {}
)
if "__annotations__" in namespace:
for annotation in namespace["__annotations__"]:
if annotation in ["bot_entity_descriptor", "entity_metadata"]:
continue
@@ -40,7 +56,6 @@ class BotEntityMetaclass(SQLModelMetaclass):
descriptor_name = annotation
if attribute_value:
if isinstance(attribute_value, EntityField):
descriptor_kwargs = attribute_value.__dict__.copy()
sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None)
@@ -52,7 +67,7 @@ class BotEntityMetaclass(SQLModelMetaclass):
descriptor_name = descriptor_kwargs.pop("name") or annotation
type_ = namespace['__annotations__'][annotation]
type_ = namespace["__annotations__"][annotation]
type_origin = get_origin(type_)
@@ -61,16 +76,19 @@ class BotEntityMetaclass(SQLModelMetaclass):
field_name=annotation,
type_=type_,
type_base=type_,
**descriptor_kwargs)
**descriptor_kwargs,
)
is_list = False
if type_origin == list:
if type_origin is list:
field_descriptor.is_list = is_list = True
field_descriptor.type_base = type_ = get_args(type_)[0]
if type_origin == Union and isinstance(get_args(type_)[0], ForwardRef):
field_descriptor.is_optional = True
field_descriptor.type_base = type_ = get_args(type_)[0].__forward_arg__
field_descriptor.type_base = type_ = get_args(type_)[
0
].__forward_arg__
if type_origin == UnionType and get_args(type_)[1] == NoneType:
field_descriptor.is_optional = True
@@ -78,12 +96,26 @@ class BotEntityMetaclass(SQLModelMetaclass):
if isinstance(type_, str):
type_not_found = True
for entity_descriptor in EntityMetadata().entity_descriptors.values():
for (
entity_descriptor
) in EntityMetadata().entity_descriptors.values():
if type_ == entity_descriptor.class_name:
field_descriptor.type_ = (list[entity_descriptor.type_] if is_list
else Optional[entity_descriptor.type_] if type_origin == Optional
else entity_descriptor.type_ | None if (type_origin == UnionType and get_args(type_)[1] == NoneType)
else entity_descriptor.type_)
field_descriptor.type_ = (
list[entity_descriptor.type_]
if is_list
else (
Optional[entity_descriptor.type_]
if type_origin == Optional
else (
entity_descriptor.type_ | None
if (
type_origin == UnionType
and get_args(type_)[1] == NoneType
)
else entity_descriptor.type_
)
)
)
type_not_found = False
break
if type_not_found:
@@ -101,27 +133,38 @@ class BotEntityMetaclass(SQLModelMetaclass):
descriptor_kwargs: dict = entity_descriptor.__dict__.copy()
descriptor_name = descriptor_kwargs.pop("name", None)
descriptor_name = descriptor_name or name.lower()
descriptor_fields_sequence = descriptor_kwargs.pop("field_sequence", None)
if not descriptor_fields_sequence:
descriptor_fields_sequence = list(bot_fields_descriptors.keys())
descriptor_fields_sequence.remove("id")
namespace["bot_entity_descriptor"] = EntityDescriptor(
name=descriptor_name,
class_name=name,
type_=name,
fields_descriptors=bot_fields_descriptors,
field_sequence = descriptor_fields_sequence,
**descriptor_kwargs)
**descriptor_kwargs,
)
else:
descriptor_fields_sequence = list(bot_fields_descriptors.keys())
descriptor_fields_sequence.remove("id")
descriptor_name = name.lower()
namespace["bot_entity_descriptor"] = EntityDescriptor(
name=descriptor_name,
class_name=name,
type_=name,
fields_descriptors=bot_fields_descriptors,
field_sequence = descriptor_fields_sequence)
)
descriptor_fields_sequence = [
key
for key, val in bot_fields_descriptors.items()
if not (val.is_optional or val.name == "id")
]
entity_descriptor: EntityDescriptor = namespace["bot_entity_descriptor"]
if entity_descriptor.default_form.edit_field_sequence is None:
entity_descriptor.default_form.edit_field_sequence = (
descriptor_fields_sequence
)
for form in entity_descriptor.forms.values():
if form.edit_field_sequence is None:
form.edit_field_sequence = descriptor_fields_sequence
for field_descriptor in bot_fields_descriptors.values():
field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"]
@@ -129,14 +172,20 @@ class BotEntityMetaclass(SQLModelMetaclass):
if "table" not in kwargs:
kwargs["table"] = True
if kwargs["table"] == True:
if kwargs["table"]:
entity_metadata = EntityMetadata()
entity_metadata.entity_descriptors[descriptor_name] = namespace["bot_entity_descriptor"]
entity_metadata.entity_descriptors[descriptor_name] = namespace[
"bot_entity_descriptor"
]
if "__annotations__" in namespace:
namespace["__annotations__"]["entity_metadata"] = ClassVar[EntityMetadata]
namespace["__annotations__"]["entity_metadata"] = ClassVar[
EntityMetadata
]
else:
namespace["__annotations__"] = {"entity_metadata": ClassVar[EntityMetadata]}
namespace["__annotations__"] = {
"entity_metadata": ClassVar[EntityMetadata]
}
namespace["entity_metadata"] = entity_metadata
@@ -146,78 +195,184 @@ class BotEntityMetaclass(SQLModelMetaclass):
for field_descriptor in mcs.__future_references__[name]:
type_origin = get_origin(field_descriptor.type_)
field_descriptor.type_base = type_
field_descriptor.type_ = (list[type_] if get_origin(field_descriptor.type_) == list else
Optional[type_] if type_origin == Union and isinstance(get_args(field_descriptor.type_)[0], ForwardRef) else
type_ | None if type_origin == UnionType else
type_)
field_descriptor.type_ = (
list[type_]
if get_origin(field_descriptor.type_) is list
else (
Optional[type_]
if type_origin == Union
and isinstance(get_args(field_descriptor.type_)[0], ForwardRef)
else type_ | None
if type_origin == UnionType
else type_
)
)
setattr(namespace["bot_entity_descriptor"], "type_", type_)
return type_
class BotEntity[CreateSchemaType: BaseModel,
UpdateSchemaType: BaseModel](SQLModel,
metaclass = BotEntityMetaclass,
table = False):
class BotEntity[CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel](
SQLModel, metaclass=BotEntityMetaclass, table=False
):
bot_entity_descriptor: ClassVar[EntityDescriptor]
entity_metadata: ClassVar[EntityMetadata]
id: int = Field(
primary_key = True,
sa_type = BIGINT)
name: str
id: int = Field(primary_key=True, sa_type=BIGINT)
@classmethod
@session_dep
async def get(cls, *,
session: AsyncSession | None = None,
id: int):
async def get(cls, *, session: AsyncSession | None = None, id: int):
return await session.get(cls, id, populate_existing=True)
@classmethod
def _static_fiter_condition(
cls, select_statement: SelectOfScalar[Self], static_filter: list[Filter]
):
for sfilt in static_filter:
if sfilt.operator == "==":
condition = column(sfilt.field_name).__eq__(sfilt.value)
elif sfilt.operator == "!=":
condition = column(sfilt.field_name).__ne__(sfilt.value)
elif sfilt.operator == "<":
condition = column(sfilt.field_name).__lt__(sfilt.value)
elif sfilt.operator == "<=":
condition = column(sfilt.field_name).__le__(sfilt.value)
elif sfilt.operator == ">":
condition = column(sfilt.field_name).__gt__(sfilt.value)
elif sfilt.operator == ">=":
condition = column(sfilt.field_name).__ge__(sfilt.value)
elif sfilt.operator == "ilike":
condition = column(sfilt.field_name).ilike(f"%{sfilt.value}%")
elif sfilt.operator == "like":
condition = column(sfilt.field_name).like(f"%{sfilt.value}%")
elif sfilt.operator == "in":
condition = column(sfilt.field_name).in_(sfilt.value)
elif sfilt.operator == "not in":
condition = column(sfilt.field_name).notin_(sfilt.value)
elif sfilt.operator == "is":
condition = column(sfilt.field_name).is_(None)
elif sfilt.operator == "is not":
condition = column(sfilt.field_name).isnot(None)
else:
condition = None
if condition:
select_statement = select_statement.where(condition)
return select_statement
@classmethod
def _filter_condition(
cls,
select_statement: SelectOfScalar[Self],
filter: str,
filter_fields: list[str],
):
condition = None
for field in filter_fields:
if condition is not None:
condition = condition | (column(field).ilike(f"%{filter}%"))
else:
condition = column(field).ilike(f"%{filter}%")
return select_statement.where(condition)
@classmethod
@session_dep
async def get_count(cls, *,
async def get_count(
cls,
*,
session: AsyncSession | None = None,
filter: str = None) -> int:
static_filter: list[Filter] | Any = None,
filter: str = None,
filter_fields: list[str] = None,
ext_filter: Any = None,
user: "UserBase" = None,
) -> int:
select_statement = select(func.count()).select_from(cls)
if filter:
select_statement = select_statement.where(column("name").ilike(f"%{filter}%"))
if static_filter:
if isinstance(static_filter, list):
select_statement = cls._static_fiter_condition(
select_statement, static_filter
)
else:
select_statement = select_statement.where(static_filter)
if filter and filter_fields:
select_statement = cls._filter_condition(
select_statement, filter, filter_fields
)
if ext_filter:
select_statement = select_statement.where(ext_filter)
if user:
select_statement = cls._ownership_condition(select_statement, user)
return await session.scalar(select_statement)
@classmethod
@session_dep
async def get_multi(cls, *,
async def get_multi(
cls,
*,
session: AsyncSession | None = None,
order_by=None,
static_filter: list[Filter] | Any = None,
filter: str = None,
filter_fields: list[str] = None,
ext_filter: Any = None,
user: "UserBase" = None,
skip: int = 0,
limit: int = None):
limit: int = None,
):
select_statement = select(cls).offset(skip)
if limit:
select_statement = select_statement.limit(limit)
if filter:
select_statement = select_statement.where(column("name").ilike(f"%{filter}%"))
if static_filter:
if isinstance(static_filter, list):
select_statement = cls._static_fiter_condition(
select_statement, static_filter
)
else:
select_statement = select_statement.where(static_filter)
if filter and filter_fields:
select_statement = cls._filter_condition(
select_statement, filter, filter_fields
)
if ext_filter:
select_statement = select_statement.where(ext_filter)
if user:
select_statement = cls._ownership_condition(select_statement, user)
if order_by:
select_statement = select_statement.order_by(order_by)
return (await session.exec(select_statement)).all()
@classmethod
def _ownership_condition(
cls, select_statement: SelectOfScalar[Self], user: "UserBase"
):
if cls.bot_entity_descriptor.ownership_fields:
condition = None
for role in user.roles:
if role in cls.bot_entity_descriptor.ownership_fields:
owner_col = column(cls.bot_entity_descriptor.ownership_fields[role])
if condition is not None:
condition = condition | (owner_col == user.id)
else:
condition = owner_col == user.id
else:
condition = None
break
if condition is not None:
return select_statement.where(condition)
return select_statement
@classmethod
@session_dep
async def create(cls, *,
async def create(
cls,
*,
session: AsyncSession | None = None,
obj_in: CreateSchemaType,
commit: bool = False):
commit: bool = False,
):
if isinstance(obj_in, cls):
obj = obj_in
else:
@@ -227,15 +382,16 @@ class BotEntity[CreateSchemaType: BaseModel,
await session.commit()
return obj
@classmethod
@session_dep
async def update(cls, *,
async def update(
cls,
*,
session: AsyncSession | None = None,
id: int,
obj_in: UpdateSchemaType,
commit: bool = False):
commit: bool = False,
):
obj = await session.get(cls, id)
if obj:
obj_data = obj.model_dump()
@@ -249,13 +405,11 @@ class BotEntity[CreateSchemaType: BaseModel,
return obj
return None
@classmethod
@session_dep
async def remove(cls, *,
session: AsyncSession | None = None,
id: int,
commit: bool = False):
async def remove(
cls, *, session: AsyncSession | None = None, id: int, commit: bool = False
):
obj = await session.get(cls, id)
if obj:
await session.delete(obj)
@@ -263,4 +417,3 @@ class BotEntity[CreateSchemaType: BaseModel,
await session.commit()
return obj
return None

View File

@@ -4,30 +4,40 @@ from typing import Any, Self, overload
class BotEnumMetaclass(type):
def __new__(cls, name: str, bases: tuple[type], namespace: dict[str, Any]):
all_members = {}
if bases and bases[0].__name__ != "BotEnum" and "all_members" in bases[0].__dict__:
if (
bases
and bases[0].__name__ != "BotEnum"
and "all_members" in bases[0].__dict__
):
all_members = bases[0].__dict__["all_members"]
annotations = {}
for key, value in namespace.items():
if (key.isupper() and
not key.startswith("__") and
not key.endswith("__")):
if key.isupper() and not key.startswith("__") and not key.endswith("__"):
if not isinstance(value, EnumMember):
value = EnumMember(value, None)
if key in all_members.keys() and all_members[key].value != value.value:
raise ValueError(f"Enum member {key} already exists with different value. Use same value to extend it.")
raise ValueError(
f"Enum member {key} already exists with different value. Use same value to extend it."
)
if (value.value in [member.value for member in all_members.values()] and
key not in all_members.keys()):
if (
value.value in [member.value for member in all_members.values()]
and key not in all_members.keys()
):
raise ValueError(f"Duplicate enum value {value[0]}")
member = EnumMember(value = value.value, loc_obj = value.loc_obj, parent = None, name = key, casting = False)
member = EnumMember(
value=value.value,
loc_obj=value.loc_obj,
parent=None,
name=key,
casting=False,
)
namespace[key] = member
all_members[key] = member
@@ -46,7 +56,6 @@ class BotEnumMetaclass(type):
class EnumMember(object):
@overload
def __init__(self, value: str) -> "EnumMember": ...
@@ -56,12 +65,14 @@ class EnumMember(object):
@overload
def __init__(self, value: str, loc_obj: dict[str, str]) -> "EnumMember": ...
def __init__(self,
def __init__(
self,
value: str = None,
loc_obj: dict[str, str] = None,
parent: type = None,
name: str = None,
casting: bool = True) -> "EnumMember":
casting: bool = True,
) -> "EnumMember":
if not casting:
self._parent = parent
self._name = name
@@ -72,7 +83,6 @@ class EnumMember(object):
def __new__(cls: Self, *args, **kwargs) -> "EnumMember": ...
def __new__(cls, *args, casting: bool = True, **kwargs) -> "EnumMember":
if (cls.__name__ == "EnumMember") or not casting:
obj = super().__new__(cls)
kwargs["casting"] = False
@@ -81,9 +91,13 @@ class EnumMember(object):
if args.__len__() == 0:
return list(cls.all_members.values())[0]
if args.__len__() == 1 and isinstance(args[0], str):
return {member.value: member for key, member in cls.all_members.items()}[args[0]]
return {member.value: member for key, member in cls.all_members.items()}[
args[0]
]
elif args.__len__() == 1:
return {member.value: member for key, member in cls.all_members.items()}[args[0].value]
return {member.value: member for key, member in cls.all_members.items()}[
args[0].value
]
else:
return args[0]
@@ -91,14 +105,15 @@ class EnumMember(object):
return str_schema()
def __get__(self, instance, owner) -> Self:
# return {member.value: member for key, member in owner.all_members.items()}[self.value]
return {member.value: member for key, member in self._parent.all_members.items()}[self.value]
return {
member.value: member for key, member in self._parent.all_members.items()
}[self.value]
def __set__(self, instance, value):
instance.__dict__[self] = value
def __repr__(self):
return f"<{self._parent.__name__ if self._parent else "EnumMember"}.{self._name}: '{self.value}'>"
return f"<{self._parent.__name__ if self._parent else 'EnumMember'}.{self._name}: '{self.value}'>"
def __str__(self):
return self.value
@@ -124,12 +139,10 @@ class EnumMember(object):
class BotEnum(EnumMember, metaclass=BotEnumMetaclass):
all_members: dict[str, EnumMember]
class EnumType(TypeDecorator):
impl = String(256)
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 dataclasses import dataclass, field
from sqlmodel.ext.asyncio.session import AsyncSession
from .role import RoleBase
from . import EntityPermission
from ..bot.handlers.context import ContextData
if TYPE_CHECKING:
from .bot_entity import BotEntity
from ..main import QBotApp
from .user import UserBase
EntityCaptionCallable = Callable[["EntityDescriptor"], str]
EntityItemCaptionCallable = Callable[["EntityDescriptor", Any], str]
EntityFieldCaptionCallable = Callable[["EntityFieldDescriptor", Any, Any], str]
@dataclass
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():
class _BaseEntityFieldDescriptor:
icon: str = None
caption: str | LazyProxy | EntityFieldCaptionCallable | None = None
description: str | LazyProxy | EntityFieldCaptionCallable | None = None
@@ -24,6 +89,10 @@ class _BaseEntityFieldDescriptor():
localizable: bool = False
bool_false_value: str | LazyProxy = "no"
bool_true_value: str | LazyProxy = "yes"
ep_form: str | None = None
ep_parent_field: str | None = None
ep_child_field: str | None = None
dt_type: Literal["date", "datetime"] = "date"
default: Any = None
@@ -54,17 +123,19 @@ class EntityFieldDescriptor(_BaseEntityFieldDescriptor):
@dataclass(kw_only=True)
class _BaseEntityDescriptor:
icon: str = "📘"
caption: str | LazyProxy | EntityCaptionCallable | None = None
caption_plural: str | LazyProxy | EntityCaptionCallable | None = None
full_name: str | LazyProxy | EntityCaptionCallable | None = None
full_name_plural: str | LazyProxy | EntityCaptionCallable | None = None
description: str | LazyProxy | EntityCaptionCallable | None = None
item_caption: EntityItemCaptionCallable | None = None
item_repr: EntityItemCaptionCallable | None = None
default_list: EntityList = field(default_factory=EntityList)
default_form: EntityForm = field(default_factory=EntityForm)
lists: dict[str, EntityList] = field(default_factory=dict[str, EntityList])
forms: dict[str, EntityForm] = field(default_factory=dict[str, EntityForm])
show_in_entities_menu: bool = True
field_sequence: list[str] = None
edit_button_visible: bool = True
edit_buttons: list[list[str | tuple[str, str | LazyProxy | EntityFieldCaptionCallable]]] = None
permissions: dict[EntityPermission, list[RoleBase]] = field(default_factory = lambda: {
ownership_fields: dict[RoleBase, str] = field(default_factory=dict[RoleBase, str])
permissions: dict[EntityPermission, list[RoleBase]] = field(
default_factory=lambda: {
EntityPermission.LIST: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.READ: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.CREATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
@@ -74,20 +145,56 @@ class _BaseEntityDescriptor:
EntityPermission.READ_ALL: [RoleBase.SUPER_USER],
EntityPermission.CREATE_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)
class Entity(_BaseEntityDescriptor):
name: str | None = None
@dataclass
class EntityDescriptor(_BaseEntityDescriptor):
name: str
class_name: str
type_: type["BotEntity"]
fields_descriptors: dict[str, EntityFieldDescriptor]
@dataclass(kw_only=True)
class CommandCallbackContext[UT: UserBase]:
keyboard_builder: InlineKeyboardBuilder = field(
default_factory=InlineKeyboardBuilder
)
message_text: str | None = None
register_navigation: bool = True
message: Message | CallbackQuery
callback_data: ContextData
db_session: AsyncSession
user: UT
app: "QBotApp"
state_data: dict[str, Any]
state: FSMContext
i18n: I18n
kwargs: dict[str, Any] = field(default_factory=dict)
@dataclass(kw_only=True)
class _BotCommand:
name: str
caption: str | dict[str, str] | None = None
show_in_bot_commands: bool = False
register_navigation: bool = True
clear_navigation: bool = False
clear_state: bool = True
@dataclass(kw_only=True)
class BotCommand(_BotCommand):
handler: Callable[[CommandCallbackContext], None]
@dataclass(kw_only=True)
class Command(_BotCommand): ...

View File

@@ -3,6 +3,5 @@ from ._singleton import Singleton
class EntityMetadata(metaclass=Singleton):
def __init__(self):
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

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

View File

@@ -2,5 +2,4 @@ from .bot_enum import BotEnum, EnumMember
class LanguageBase(BotEnum):
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):
SUPER_USER = EnumMember("super_user")
DEFAULT_USER = EnumMember("default_user")

View File

@@ -7,7 +7,7 @@ from typing import Any, get_args, get_origin
from ..db import async_session
from .role import RoleBase
from .descriptors import EntityFieldDescriptor, Setting
from ..utils import deserialize, serialize
from ..utils.serialization import deserialize, serialize
import ujson as json
@@ -19,23 +19,22 @@ class DbSettings(SQLModel, table = True):
class SettingsMetaclass(type):
def __new__(cls, class_name, base_classes, attributes):
settings_descriptors = {}
if base_classes:
settings_descriptors = base_classes[0].__dict__.get("_settings_descriptors", {})
for annotation in attributes.get('__annotations__', {}):
settings_descriptors = base_classes[0].__dict__.get(
"_settings_descriptors", {}
)
for annotation in attributes.get("__annotations__", {}):
if annotation in ["_settings_descriptors", "_cache", "_cached_settings"]:
continue
attr_value = attributes.get(annotation)
name = annotation
type_ = attributes['__annotations__'][annotation]
type_ = attributes["__annotations__"][annotation]
if isinstance(attr_value, Setting):
descriptor_kwargs = attr_value.__dict__.copy()
@@ -45,7 +44,8 @@ class SettingsMetaclass(type):
field_name=annotation,
type_=type_,
type_base=type_,
**descriptor_kwargs)
**descriptor_kwargs,
)
else:
attributes[annotation] = EntityFieldDescriptor(
@@ -53,11 +53,12 @@ class SettingsMetaclass(type):
field_name=annotation,
type_=type_,
type_base=type_,
default = attr_value)
default=attr_value,
)
type_origin = get_origin(type_)
if type_origin == list:
if type_origin is list:
attributes[annotation].is_list = True
attributes[annotation].type_base = type_ = get_args(type_)[0]
@@ -67,7 +68,11 @@ class SettingsMetaclass(type):
settings_descriptors[name] = attributes[annotation]
if base_classes and base_classes[0].__name__ == "Settings" and hasattr(base_classes[0], annotation):
if (
base_classes
and base_classes[0].__name__ == "Settings"
and hasattr(base_classes[0], annotation)
):
setattr(base_classes[0], annotation, attributes[annotation])
attributes["__annotations__"] = {}
@@ -77,61 +82,118 @@ class SettingsMetaclass(type):
class Settings(metaclass=SettingsMetaclass):
_cache: dict[str, Any] = dict[str, Any]()
_settings_descriptors: dict[str, EntityFieldDescriptor] = {}
PAGE_SIZE: int = Setting(default = 10, )
SECURITY_PARAMETERS_ROLES: list[RoleBase] = Setting(name = "SECPARAMS_ROLES", default = [RoleBase.SUPER_USER], is_visible = False)
PAGE_SIZE: int = Setting(
default=10,
)
SECURITY_PARAMETERS_ROLES: list[RoleBase] = Setting(
name="SECPARAMS_ROLES", default=[RoleBase.SUPER_USER], is_visible=False
)
APP_STRINGS_WELCOME_P_NAME: str = Setting(name = "AS_WELCOME", default = "Welcome, {name}", is_visible = False)
APP_STRINGS_GREETING_P_NAME: str = Setting(name = "AS_GREETING", default = "Hello, {name}", is_visible = False)
APP_STRINGS_INTERNAL_ERROR_P_ERROR: str = Setting(name = "AS_INTERNAL_ERROR", default = "Internal error\n{error}", is_visible = False)
APP_STRINGS_USER_BLOCKED_P_NAME: str = Setting(name = "AS_USER_BLOCKED", default = "User {name} is blocked", is_visible = False)
APP_STRINGS_FORBIDDEN: str = Setting(name = "AS_FORBIDDEN", default = "Forbidden", is_visible = False)
APP_STRINGS_NOT_FOUND: str = Setting(name = "AS_NOT_FOUND", default = "Object not found", is_visible = False)
APP_STRINGS_MAIN_NENU: str = Setting(name = "AS_MAIN_MENU", default = "Main menu", is_visible = False)
APP_STRINGS_REFERENCES: str = Setting(name = "AS_REFERENCES", default = "References", is_visible = False)
APP_STRINGS_REFERENCES_BTN: str = Setting(name = "AS_REFERENCES_BTN", default = "📚 References", is_visible = False)
APP_STRINGS_SETTINGS: str = Setting(name = "AS_SETTINGS", default = "Settings", is_visible = False)
APP_STRINGS_SETTINGS_BTN: str = Setting(name = "AS_SETTINGS_BTN", default = "⚙️ Settings", is_visible = False)
APP_STRINGS_PARAMETERS: str = Setting(name = "AS_PARAMETERS", default = "Parameters", is_visible = False)
APP_STRINGS_PARAMETERS_BTN: str = Setting(name = "AS_PARAMETERS_BTN", default = "🎛️ Parameters", is_visible = False)
APP_STRINGS_LANGUAGE: str = Setting(name = "AS_LANGUAGE", default = "Language", is_visible = False)
APP_STRINGS_LANGUAGE_BTN: str = Setting(name = "AS_LANGUAGE_BTN", default = "🗣️ Language", is_visible = False)
APP_STRINGS_BACK_BTN: str = Setting(name = "AS_BACK_BTN", default = "⬅️ Back", is_visible = False)
APP_STRINGS_DELETE_BTN: str = Setting(name = "AS_DELETE_BTN", default = "🗑️ Delete", is_visible = False)
APP_STRINGS_WELCOME_P_NAME: str = Setting(
name="AS_WELCOME", default="Welcome, {name}", is_visible=False
)
APP_STRINGS_GREETING_P_NAME: str = Setting(
name="AS_GREETING", default="Hello, {name}", is_visible=False
)
APP_STRINGS_INTERNAL_ERROR_P_ERROR: str = Setting(
name="AS_INTERNAL_ERROR", default="Internal error\n{error}", is_visible=False
)
APP_STRINGS_USER_BLOCKED_P_NAME: str = Setting(
name="AS_USER_BLOCKED", default="User {name} is blocked", is_visible=False
)
APP_STRINGS_FORBIDDEN: str = Setting(
name="AS_FORBIDDEN", default="Forbidden", is_visible=False
)
APP_STRINGS_NOT_FOUND: str = Setting(
name="AS_NOT_FOUND", default="Object not found", is_visible=False
)
APP_STRINGS_MAIN_NENU: str = Setting(
name="AS_MAIN_MENU", default="Main menu", is_visible=False
)
APP_STRINGS_REFERENCES: str = Setting(
name="AS_REFERENCES", default="References", is_visible=False
)
APP_STRINGS_REFERENCES_BTN: str = Setting(
name="AS_REFERENCES_BTN", default="📚 References", is_visible=False
)
APP_STRINGS_SETTINGS: str = Setting(
name="AS_SETTINGS", default="Settings", is_visible=False
)
APP_STRINGS_SETTINGS_BTN: str = Setting(
name="AS_SETTINGS_BTN", default="⚙️ Settings", is_visible=False
)
APP_STRINGS_PARAMETERS: str = Setting(
name="AS_PARAMETERS", default="Parameters", is_visible=False
)
APP_STRINGS_PARAMETERS_BTN: str = Setting(
name="AS_PARAMETERS_BTN", default="🎛️ Parameters", is_visible=False
)
APP_STRINGS_LANGUAGE: str = Setting(
name="AS_LANGUAGE", default="Language", is_visible=False
)
APP_STRINGS_LANGUAGE_BTN: str = Setting(
name="AS_LANGUAGE_BTN", default="🗣️ Language", is_visible=False
)
APP_STRINGS_BACK_BTN: str = Setting(
name="AS_BACK_BTN", default="⬅️ Back", is_visible=False
)
APP_STRINGS_DELETE_BTN: str = Setting(
name="AS_DELETE_BTN", default="🗑️ Delete", is_visible=False
)
APP_STRINGS_CONFIRM_DELETE_P_NAME: str = Setting(
name="AS_CONFIRM_DEL",
default = "Are you sure you want to delete \"{name}\"?",
is_visible = False)
APP_STRINGS_EDIT_BTN: str = Setting(name = "AS_EDIT_BTN", default = "✏️ Edit", is_visible = False)
APP_STRINGS_ADD_BTN: str = Setting(name = "AS_ADD_BTN", default = " Add", is_visible = False)
APP_STRINGS_YES_BTN: str = Setting(name = "AS_YES_BTN", default = "✅ Yes", is_visible = False)
APP_STRINGS_NO_BTN: str = Setting(name = "AS_NO_BTN", default = "❌ No", is_visible = False)
APP_STRINGS_CANCEL_BTN: str = Setting(name = "AS_CANCEL_BTN", default = "❌ Cancel", is_visible = False)
APP_STRINGS_CLEAR_BTN: str = Setting(name = "AS_CLEAR_BTN", default = "⌫ Clear", is_visible = False)
APP_STRINGS_DONE_BTN: str = Setting(name = "AS_DONE_BTN", default = "✅ Done", is_visible = False)
APP_STRINGS_SKIP_BTN: str = Setting(name = "AS_SKIP_BTN", default = "⏩️ Skip", is_visible = False)
default='Are you sure you want to delete "{name}"?',
is_visible=False,
)
APP_STRINGS_EDIT_BTN: str = Setting(
name="AS_EDIT_BTN", default="✏️ Edit", is_visible=False
)
APP_STRINGS_ADD_BTN: str = Setting(
name="AS_ADD_BTN", default=" Add", is_visible=False
)
APP_STRINGS_YES_BTN: str = Setting(
name="AS_YES_BTN", default="✅ Yes", is_visible=False
)
APP_STRINGS_NO_BTN: str = Setting(
name="AS_NO_BTN", default="❌ No", is_visible=False
)
APP_STRINGS_CANCEL_BTN: str = Setting(
name="AS_CANCEL_BTN", default="❌ Cancel", is_visible=False
)
APP_STRINGS_CLEAR_BTN: str = Setting(
name="AS_CLEAR_BTN", default="⌫ Clear", is_visible=False
)
APP_STRINGS_DONE_BTN: str = Setting(
name="AS_DONE_BTN", default="✅ Done", is_visible=False
)
APP_STRINGS_SKIP_BTN: str = Setting(
name="AS_SKIP_BTN", default="⏩️ Skip", is_visible=False
)
APP_STRINGS_FIELD_EDIT_PROMPT_TEMPLATE_P_NAME_VALUE: str = Setting(
name="AS_FIELDEDIT_PROMPT",
default = "Enter new value for \"{name}\" (current value: {value})",
is_visible = False)
default='Enter new value for "{name}" (current value: {value})',
is_visible=False,
)
APP_STRINGS_FIELD_CREATE_PROMPT_TEMPLATE_P_NAME: str = Setting(
name="AS_FIELDCREATE_PROMPT",
default = "Enter new value for \"{name}\"",
is_visible = False)
default='Enter new value for "{name}"',
is_visible=False,
)
APP_STRINGS_STRING_EDITOR_LOCALE_TEMPLATE_P_NAME: str = Setting(
name = "AS_STREDIT_LOC_TEMPLATE",
default = "string for \"{name}\"",
is_visible = False)
APP_STRINGS_VIEW_FILTER_EDIT_PROMPT: str = Setting(name = "AS_FILTEREDIT_PROMPT", default = "Enter filter value", is_visible = False)
APP_STRINGS_INVALID_INPUT: str = Setting(name = "AS_INVALID_INPUT", default = "Invalid input", is_visible = False)
name="AS_STREDIT_LOC_TEMPLATE", default='string for "{name}"', is_visible=False
)
APP_STRINGS_VIEW_FILTER_EDIT_PROMPT: str = Setting(
name="AS_FILTEREDIT_PROMPT", default="Enter filter value", is_visible=False
)
APP_STRINGS_INVALID_INPUT: str = Setting(
name="AS_INVALID_INPUT", default="Invalid input", is_visible=False
)
@classmethod
async def get[T](cls, param: T, all_locales=False, locale: str = None) -> T:
name = param.field_name
if name not in cls._cache.keys():
@@ -144,58 +206,65 @@ class Settings(metaclass = SettingsMetaclass):
locale = get_i18n().current_locale
try:
obj = json.loads(ret_val)
except:
except Exception:
return ret_val
return obj.get(locale, obj[list(obj.keys())[0]])
return ret_val
@classmethod
async def load_param(cls, param: EntityFieldDescriptor) -> Any:
async with async_session() as session:
db_setting = (await session.exec(
select(DbSettings)
.where(DbSettings.name == param.field_name))).first()
db_setting = (
await session.exec(
select(DbSettings).where(DbSettings.name == param.field_name)
)
).first()
if db_setting:
return await deserialize(session = session,
type_ = param.type_,
value = db_setting.value)
return (param.default if param.default else
[] if (get_origin(param.type_) is list or param.type_ == list) else
datetime(2000, 1, 1) if param.type_ == datetime else
param.type_())
return await deserialize(
session=session, type_=param.type_, value=db_setting.value
)
return (
param.default
if param.default
else (
[]
if (get_origin(param.type_) is list or param.type_ is list)
else datetime(2000, 1, 1)
if param.type_ == datetime
else param.type_()
)
)
@classmethod
async def load_params(cls):
async with async_session() as session:
db_settings = (await session.exec(select(DbSettings))).all()
for db_setting in db_settings:
if db_setting.name in cls.__dict__:
setting = cls.__dict__[db_setting.name]
cls._cache[db_setting.name] = await deserialize(session = session,
setting = cls.__dict__[db_setting.name] # type: EntityFieldDescriptor
cls._cache[db_setting.name] = await deserialize(
session=session,
type_=setting.type_,
value=db_setting.value,
default = setting.default)
default=setting.default,
)
cls._loaded = True
@classmethod
async def set_param(cls, param: str | EntityFieldDescriptor, value) -> None:
if isinstance(param, str):
param = cls._settings_descriptors[param]
ser_value = serialize(value, param)
async with async_session() as session:
db_setting = (await session.exec(
select(DbSettings)
.where(DbSettings.name == param.field_name))).first()
db_setting = (
await session.exec(
select(DbSettings).where(DbSettings.name == param.field_name)
)
).first()
if db_setting is None:
db_setting = DbSettings(name=param.field_name)
db_setting.value = str(ser_value)
@@ -203,14 +272,13 @@ class Settings(metaclass = SettingsMetaclass):
await session.commit()
cls._cache[param.field_name] = value
@classmethod
def list_params(cls) -> dict[str, EntityFieldDescriptor]:
return cls._settings_descriptors
@classmethod
async def get_params(cls) -> dict[EntityFieldDescriptor, Any]:
params = cls.list_params()
return {param: await cls.get(param, all_locales = True) for _, param in params.items()}
return {
param: await cls.get(param, all_locales=True) for _, param in params.items()
}

View File

@@ -11,10 +11,11 @@ from .view_setting import ViewSetting as ViewSetting
class UserBase(BotEntity, table=False):
__tablename__ = "user"
lang: LanguageBase = Field(sa_type=EnumType(LanguageBase), default=LanguageBase.EN)
is_active: bool = True
roles: list[RoleBase] = Field(sa_type=ARRAY(EnumType(RoleBase)), default = [RoleBase.DEFAULT_USER])
roles: list[RoleBase] = Field(
sa_type=ARRAY(EnumType(RoleBase)), default=[RoleBase.DEFAULT_USER]
)

View File

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

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 types import NoneType, UnionType
from sqlmodel import select, column
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any, Union, get_origin, get_args, TYPE_CHECKING
from typing import Any, Union, get_origin, get_args
from types import UnionType, NoneType
import ujson as json
from ..model.bot_entity import BotEntity
from ..model.bot_enum import BotEnum
from ..model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ..model import EntityPermission
if TYPE_CHECKING:
from ..model.user import UserBase
from ..model.descriptors import EntityFieldDescriptor
async def deserialize[T](session: AsyncSession, type_: type[T], value: str = None) -> T:
@@ -20,12 +16,12 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non
is_optional = False
if type_origin in [UnionType, Union]:
args = get_args(type_)
if args[1] == NoneType:
if args[1] is NoneType:
type_ = args[0]
if value is None:
return None
is_optional = True
if get_origin(type_) == list:
if get_origin(type_) is list:
arg_type = None
args = get_args(type_)
if args:
@@ -34,7 +30,9 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non
if arg_type:
if issubclass(arg_type, BotEntity):
ret = list[arg_type]()
items = (await session.exec(select(arg_type).where(column("id").in_(values)))).all()
items = (
await session.exec(select(arg_type).where(column("id").in_(values)))
).all()
for item in items:
ret.append(item)
return ret
@@ -52,13 +50,22 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non
if is_optional and not value:
return None
return type_(value)
elif type_ == datetime:
elif type_ is time:
if is_optional and not value:
return None
return datetime.fromisoformat(value)
elif type_ == bool:
return time.fromisoformat(value.replace("-", ":"))
elif type_ is datetime:
if is_optional and not value:
return None
if value[-3] == ":":
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
elif value[-3] == "-":
return datetime.strptime(value, "%Y-%m-%d %H-%M")
else:
raise ValueError("Invalid datetime format")
elif type_ is bool:
return value == "True"
elif type_ == Decimal:
elif type_ is Decimal:
if is_optional and not value:
return None
return Decimal(value)
@@ -69,7 +76,6 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non
def serialize(value: Any, field_descriptor: EntityFieldDescriptor) -> str:
if value is None:
return ""
type_ = field_descriptor.type_base
@@ -84,23 +90,3 @@ def serialize(value: Any, field_descriptor: EntityFieldDescriptor) -> str:
elif issubclass(type_, BotEntity):
return str(value.id) if value else ""
return str(value)
def get_user_permissions(user: "UserBase", entity_descriptor: EntityDescriptor) -> list[EntityPermission]:
permissions = list[EntityPermission]()
for permission, roles in entity_descriptor.permissions.items():
for role in roles:
if role in user.roles:
permissions.append(permission)
break
return permissions
def get_local_text(text: str, locale: str) -> str:
try:
obj = json.loads(text) #@IgnoreException
except:
return text
else:
return obj.get(locale, obj[list(obj.keys())[0]])