This commit is contained in:
Alexander Kalinovsky
2025-01-04 12:00:12 +01:00
commit 6dbe0536ca
94 changed files with 3467 additions and 0 deletions

Binary file not shown.

View File

@@ -0,0 +1,29 @@
from aiogram.filters import Filter
from aiogram.fsm.context import FSMContext
from .handlers.context import CallbackCommand, ContextData
from logging import getLogger
logger = getLogger(__name__)
class CallbackCommandFilter(Filter):
def __init__(self, command: CallbackCommand):
self.command = command
async def __call__(self, *args, **kwargs):
state: FSMContext = kwargs.get("state")
state_data = await state.get_data()
context_data = state_data.get("context_data")
if context_data:
try:
context_data = ContextData.unpack(context_data)
except Exception as e:
logger.error(f"Error unpacking context data", exc_info = True)
return False
else:
return context_data.command == self.command
return False

0
bot/handlers/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,195 @@
from types import NoneType, UnionType
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 sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any, get_args, get_origin
import ujson as json
from ..context import ContextData, CallbackCommand, CommandContext
from ....model.user import UserBase
from ....model.settings import Settings
from ....main import QBotApp
from ....model.bot_entity import BotEntity
from ....model.bot_enum import BotEnum
from ....model.descriptors import (EntityFieldDescriptor,
EntityDescriptor,
EntityCaptionCallable,
EntityItemCaptionCallable,
EntityFieldCaptionCallable)
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_
origin = get_origin(type_)
if value is None:
return ""
if origin == UnionType:
args = get_args(type_)
if args[1] == NoneType:
type_ = args[0]
if isinstance(value, bool):
return "【✔︎】" if value else "【 】"
elif origin == list:
arg_type = None
args = get_args(type_)
if args:
arg_type = args[0]
if arg_type and issubclass(arg_type, BotEntity):
if locale and arg_type.bot_entity_descriptor.fields_descriptors["name"].localizable:
return "[" + ", ".join([get_local_text(value = item.name, locale = locale) for item in value]) + "]"
else:
return "[" + ", ".join([str(item.name) for item in value]) + "]"
elif arg_type and issubclass(arg_type, BotEnum):
return "[" + ", ".join(item.localized(locale) for item in value) + "]"
elif arg_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(value = 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(value, 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):
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_SETTINGS_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]:
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)

36
bot/handlers/context.py Normal file
View File

@@ -0,0 +1,36 @@
from aiogram.filters.callback_data import CallbackData as BaseCallbackData
from enum import StrEnum
class CallbackCommand(StrEnum):
FIELD_EDITOR = "fe"
FIELD_EDITOR_CALLBACK = "fc"
ENTITY_LIST = "el"
ENTITY_ITEM = "ei"
ENTITY_DELETE = "ed"
MENU_ENTRY_MAIN = "mm"
MENU_ENTRY_SETTINGS = "ms"
MENU_ENTRY_ENTITIES = "me"
MENU_ENTRY_PARAMETERS = "mp"
MENU_ENTRY_LANGUAGE = "ml"
SET_LANGUAGE = "ls"
DATE_PICKER_MONTH = "dm"
DATE_PICKER_YEAR = "dy"
STRING_EDITOR_LOCALE = "sl"
ENTITY_PICKER_PAGE = "ep"
ENTITY_PICKER_TOGGLE_ITEM = "et"
class CommandContext(StrEnum):
SETTING_EDIT = "se"
ENTITY_CREATE = "ec"
ENTITY_EDIT = "ee"
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
data: str | None = None
back: bool = False

View File

@@ -0,0 +1,371 @@
from datetime import datetime
from decimal import Decimal
from types import NoneType, UnionType
from typing import get_args, get_origin
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 ....main import QBotApp
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 ..common import (get_value_repr, authorize_command, get_callable_str,
get_entity_descriptor, get_field_descriptor)
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
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 settings_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)
await state.clear()
await state.update_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 = await get_navigation_context(state = state)
return await parameters_menu(message = message,
navigation_stack = stack,
**kwargs)
current_value = await Settings.get(field_descriptor)
else:
entity_descriptor = get_entity_descriptor(app, callback_data)
field_descriptor = get_field_descriptor(app, callback_data)
current_value = None
if not entity_data and callback_data.context == CommandContext.ENTITY_EDIT:
if (EntityPermission.READ_ALL in get_user_permissions(user, entity_descriptor) or
(EntityPermission.READ in get_user_permissions(user, entity_descriptor) and
not issubclass(entity_descriptor.type_, OwnedBotEntity)) or
(EntityPermission.READ in get_user_permissions(user, entity_descriptor) and
issubclass(entity_descriptor.type_, OwnedBotEntity) and
entity_data.user_id == user.id)):
entity = await entity_descriptor.type_.get(session = kwargs["db_session"], id = int(callback_data.entity_id))
if entity:
entity_data = {key: serialize(getattr(entity, key), entity_descriptor.fields_descriptors[key]) for key in entity_descriptor.field_sequence}
await state.update_data({"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))
await show_editor(message = message,
field_descriptor = field_descriptor,
entity_descriptor = entity_descriptor,
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"]
value_type = field_descriptor.type_
if field_descriptor.edit_prompt:
edit_prompt = get_callable_str(field_descriptor.edit_prompt, field_descriptor, None, current_value)
else:
if field_descriptor.caption_str:
caption_str = get_callable_str(field_descriptor.caption_str, 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 == UnionType:
args = get_args(value_type)
if args[1] == NoneType:
value_type = args[0]
if value_type not in [int, float, Decimal, str]:
await state.update_data({"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):
callback_data: ContextData = kwargs.get("callback_data", None)
app: QBotApp = kwargs["app"]
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
if isinstance(message, Message):
context_data = state_data.get("context_data")
if context_data:
context_data = ContextData.unpack(context_data)
callback_data = context_data
value = message.text
field_descriptor = get_field_descriptor(app, callback_data)
base_type = field_descriptor.type_
if get_origin(base_type) == UnionType:
args = get_args(base_type)
if args[1] == NoneType:
base_type = args[0]
if base_type == 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)
await state.update_data({"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)
elif (base_type in [int, float, Decimal]):
try:
_ = base_type(value) #@IgnoreException
except:
return await message.answer(text = (await Settings.get(Settings.APP_STRINGS_INVALID_INPUT)))
else:
if callback_data.data:
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"]
value = kwargs["value"]
field_descriptor: EntityFieldDescriptor = kwargs["field_descriptor"]
if callback_data.context == CommandContext.SETTING_EDIT:
await clear_state(state = state)
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 = await get_navigation_context(state = state)
return await parameters_menu(message = message,
navigation_stack = stack,
**kwargs)
elif callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_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)
state_data = await state.get_data()
entity_data = state_data.get("entity_data", {})
if current_index < len(field_sequence) - 1:
entity_data[field_descriptor.field_name] = value
await state.update_data({"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 == CommandContext.ENTITY_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)
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 == CommandContext.ENTITY_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()
await clear_state(state = state)
await route_callback(message = message, back = False, **kwargs)
from ..navigation import get_navigation_context, route_callback, clear_state, save_navigation_context

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,71 @@
from aiogram import Router
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 logging import getLogger
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ..context import ContextData, CallbackCommand
from ..common import get_send_message
from .common import wrap_editor
logger = getLogger(__name__)
router = Router()
async def bool_editor(message: Message | CallbackQuery,
edit_prompt: str,
entity_descriptor: EntityDescriptor,
field_descriptor: EntityFieldDescriptor,
callback_data: ContextData,
**kwargs):
keyboard_builder = InlineKeyboardBuilder()
if isinstance(field_descriptor.bool_true_value_btn, LazyProxy):
true_caption = field_descriptor.bool_true_value_btn.value
else:
true_caption = field_descriptor.bool_true_value_btn
if isinstance(field_descriptor.bool_false_value_btn, LazyProxy):
false_caption = field_descriptor.bool_false_value_btn.value
else:
false_caption = field_descriptor.bool_false_value_btn
keyboard_builder.row(
InlineKeyboardButton(text = true_caption,
callback_data = ContextData(
command = CallbackCommand.FIELD_EDITOR_CALLBACK,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
data = str(True),
save_state = True).pack()),
InlineKeyboardButton(text = false_caption,
callback_data = ContextData(
command = CallbackCommand.FIELD_EDITOR_CALLBACK,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
data = str(False),
save_state = True).pack())
)
await wrap_editor(keyboard_builder = keyboard_builder,
field_descriptor = field_descriptor,
entity_descriptor = entity_descriptor,
callback_data = callback_data,
state = kwargs["state"])
send_message = get_send_message(message)
await send_message(text = edit_prompt, reply_markup = keyboard_builder.as_markup())

View File

@@ -0,0 +1,67 @@
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 ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....model.settings import Settings
from ..context import ContextData, CallbackCommand, CommandContext
from ..navigation import get_navigation_context
async def wrap_editor(keyboard_builder: InlineKeyboardBuilder,
field_descriptor: EntityFieldDescriptor,
entity_descriptor: EntityDescriptor,
callback_data: ContextData,
state: FSMContext):
if callback_data.context in [CommandContext.ENTITY_CREATE, CommandContext.ENTITY_EDIT]:
btns = []
field_index = entity_descriptor.field_sequence.index(field_descriptor.name)
stack, context = await get_navigation_context(state)
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],
save_state = True).pack()))
if get_origin(field_descriptor.type_) == UnionType:
args = get_args(field_descriptor.type_)
if args[1] == NoneType:
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,
save_state = True).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,
data = "cancel").pack()))

View File

@@ -0,0 +1,167 @@
from datetime import datetime, timedelta
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 ....main import QBotApp
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ..context import ContextData, CallbackCommand
from ..common import get_send_message, get_field_descriptor, get_entity_descriptor
from .common import wrap_editor
logger = getLogger(__name__)
router = Router()
async def date_picker(message: Message | CallbackQuery,
field_descriptor: EntityFieldDescriptor,
entity_descriptor: EntityDescriptor,
callback_data: ContextData,
current_value: datetime,
state: FSMContext,
edit_prompt: str | None = None,
**kwargs):
if not current_value:
start_date = datetime.now()
else:
start_date = current_value
start_date = start_date.replace(day = 1)
previous_month = start_date - timedelta(days = 1)
next_month = start_date.replace(day = 28) + timedelta(days = 4)
keyboard_builder = InlineKeyboardBuilder()
keyboard_builder.row(InlineKeyboardButton(text = "◀️",
callback_data = ContextData(
command = CallbackCommand.DATE_PICKER_MONTH,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
data = previous_month.strftime("%Y-%m-%d"),
save_state = True).pack()),
InlineKeyboardButton(text = start_date.strftime("%b %Y"),
callback_data = ContextData(
command = CallbackCommand.DATE_PICKER_YEAR,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
data = start_date.strftime("%Y-%m-%d"),
save_state = True).pack()),
InlineKeyboardButton(text = "▶️",
callback_data = ContextData(
command = CallbackCommand.DATE_PICKER_MONTH,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
data = next_month.strftime("%Y-%m-%d"),
save_state = True).pack()))
first_day = start_date - timedelta(days = start_date.weekday())
weeks = (((start_date.replace(day = 28) + timedelta(days = 4)).replace(day = 1) - first_day).days - 1) // 7 + 1
for week in range(weeks):
buttons = []
for day in range(7):
current_day = first_day + timedelta(days = week * 7 + day)
buttons.append(InlineKeyboardButton(text = current_day.strftime("%d"),
callback_data = ContextData(
command = CallbackCommand.FIELD_EDITOR_CALLBACK,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
data = current_day.strftime("%Y-%m-%d"),
save_state = True).pack()))
keyboard_builder.row(*buttons)
await wrap_editor(keyboard_builder = keyboard_builder,
field_descriptor = field_descriptor,
entity_descriptor = entity_descriptor,
callback_data = callback_data,
state = state)
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")
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"),
save_state = True).pack()))
for r in range(4):
buttons = []
for c in range(5):
current_date = start_date.replace(year = start_date.year + r * 5 + c - 10)
buttons.append(InlineKeyboardButton(text = current_date.strftime("%Y"),
callback_data = ContextData(
command = CallbackCommand.DATE_PICKER_MONTH,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
data = current_date.strftime("%Y-%m-%d"),
save_state = True).pack()))
keyboard_builder.row(*buttons)
keyboard_builder.row(InlineKeyboardButton(text = "🔽",
callback_data = ContextData(
command = CallbackCommand.DATE_PICKER_YEAR,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
data = start_date.replace(year = start_date.year + 20).strftime("%Y-%m-%d"),
save_state = True).pack()))
field_descriptor = get_field_descriptor(app, callback_data)
entity_descriptor = get_entity_descriptor(app, callback_data)
await wrap_editor(keyboard_builder = keyboard_builder,
field_descriptor = field_descriptor,
entity_descriptor = entity_descriptor,
callback_data = callback_data,
state = state)
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):
entity_descriptor = get_entity_descriptor(app, callback_data)
field_descriptor = get_field_descriptor(app, callback_data)
await date_picker(query.message,
field_descriptor = field_descriptor,
entity_descriptor = entity_descriptor,
callback_data = callback_data,
current_value = datetime.strptime(callback_data.data, "%Y-%m-%d"),
**kwargs)

View File

@@ -0,0 +1,193 @@
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.ext.asyncio.session import AsyncSession
from typing import get_args, get_origin
from ....main import QBotApp
from ....model.bot_entity import BotEntity
from ....model.bot_enum import BotEnum
from ....model.settings import Settings
from ....model.user import UserBase
from ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....utils 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)
from .common import wrap_editor
logger = getLogger(__name__)
router = Router()
async def entity_picker(message: Message | CallbackQuery,
field_descriptor: EntityFieldDescriptor,
edit_prompt: str,
current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum],
state: FSMContext,
**kwargs):
await state.update_data({"current_value": serialize(current_value, field_descriptor),
"value": serialize(current_value, field_descriptor),
"edit_prompt": edit_prompt})
await render_entity_picker(field_descriptor = field_descriptor,
message = message,
state = state,
current_value = current_value,
edit_prompt = edit_prompt,
**kwargs)
async def render_entity_picker(*,
field_descriptor: EntityFieldDescriptor,
entity_descriptor: EntityDescriptor,
message: Message | CallbackQuery,
callback_data: ContextData,
user: UserBase,
db_session: AsyncSession,
state: FSMContext,
current_value: BotEntity | BotEnum | list[BotEntity] | list[BotEnum],
edit_prompt: str,
page: int = 1,
**kwargs):
if callback_data.command in [CallbackCommand.ENTITY_PICKER_PAGE, CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM]:
page = int(callback_data.data.split("&")[0])
is_list = False
type_origin = get_origin(field_descriptor.type_)
if type_origin == UnionType:
type_ = get_args(field_descriptor.type_)[0]
elif type_origin == list:
type_ = get_args(field_descriptor.type_)[0]
is_list = True
else:
type_ = field_descriptor.type_
if not issubclass(type_, BotEntity) and not issubclass(type_, BotEnum):
raise ValueError("Unsupported type")
page_size = await Settings.get(Settings.PAGE_SIZE)
if issubclass(type_, BotEnum):
items_count = len(type_.all_members)
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:
items_count = await type_.get_count(session = db_session)
entity_items = await type_.get_multi(session = db_session, order_by = type_.name, skip = page_size * (page - 1), limit = page_size)
items = [{"text": f"{"" if not is_list else "【✔︎】 " if item in (current_value or []) else "【 】 "}{
type_.bot_entity_descriptor.item_caption_btn(type_.bot_entity_descriptor, item) if type_.bot_entity_descriptor.item_caption_btn
else get_local_text(item.name, user.lang) if field_descriptor.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)
keyboard_builder = InlineKeyboardBuilder()
for item in items:
keyboard_builder.row(
InlineKeyboardButton(text = item["text"],
callback_data = ContextData(
command = CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM if is_list else CallbackCommand.FIELD_EDITOR_CALLBACK,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
data = f"{page}&{item['value']}" if is_list else item["value"],
save_state = True).pack()))
add_pagination_controls(keyboard_builder = keyboard_builder,
callback_data = callback_data,
total_pages = total_pages,
command = CallbackCommand.ENTITY_PICKER_PAGE,
page = page)
if is_list:
keyboard_builder.row(
InlineKeyboardButton(text = await Settings.get(Settings.APP_STRINGS_DONE_BTN),
callback_data = ContextData(
command = CallbackCommand.FIELD_EDITOR_CALLBACK,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
save_state = True).pack()))
await wrap_editor(keyboard_builder = keyboard_builder,
field_descriptor = field_descriptor,
entity_descriptor = entity_descriptor,
callback_data = callback_data,
state = state)
send_message = get_send_message(message)
await send_message(text = edit_prompt, reply_markup = keyboard_builder.as_markup())
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_PAGE))
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM))
async def entity_picker_callback(query: CallbackQuery,
callback_data: ContextData,
db_session: AsyncSession,
app: QBotApp,
state: FSMContext,
**kwargs):
state_data = await state.get_data()
field_descriptor = get_field_descriptor(app = app, callback_data = callback_data)
entity_descriptor = get_entity_descriptor(app = app, callback_data = callback_data)
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"])
if callback_data.command == CallbackCommand.ENTITY_PICKER_TOGGLE_ITEM:
page, id_value = callback_data.data.split("&")
page = int(page)
type_ = get_args(field_descriptor.type_)[0]
if issubclass(type_, BotEnum):
item = type_(id_value)
if item in value:
value.remove(item)
else:
value.append(item)
else:
item = await type_.get(session = db_session, id = int(id_value))
if item in value:
value.remove(item)
else:
value.append(item)
await state.update_data({"value": serialize(value, field_descriptor)})
elif callback_data.command == CallbackCommand.ENTITY_PICKER_PAGE:
if callback_data.data == "skip":
return
page = int(callback_data.data)
else:
raise ValueError("Unsupported command")
await render_entity_picker(field_descriptor = field_descriptor,
entity_descriptor = entity_descriptor,
message = query,
callback_data = callback_data,
current_value = value,
edit_prompt = edit_prompt,
db_session = db_session,
app = app,
state = state,
page = page,
**kwargs)

View File

@@ -0,0 +1,94 @@
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 ....model.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....model.language import LanguageBase
from ....model.settings import Settings
from ....utils import serialize
from ..context import ContextData, CallbackCommand
from ..common import get_send_message, get_local_text
from .common import wrap_editor
logger = getLogger(__name__)
router = Router()
async def string_editor(message: Message | CallbackQuery,
field_descriptor: EntityFieldDescriptor,
entity_descriptor: EntityDescriptor,
callback_data: ContextData,
current_value: Any,
edit_prompt: str,
state: FSMContext,
locale_index: int = 0,
**kwargs):
keyboard_builder = InlineKeyboardBuilder()
_edit_prompt = edit_prompt
type_ = field_descriptor.type_
type_origin = get_origin(type_)
if type_origin == UnionType:
type_ = get_args(type_)[0]
if type_ == str and field_descriptor.localizable:
current_locale = list(LanguageBase.all_members.values())[locale_index]
context_data = ContextData(
command = CallbackCommand.FIELD_EDITOR_CALLBACK,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
save_state = True)
_edit_prompt = f"{edit_prompt}\n{(await Settings.get(
Settings.APP_STRINGS_STRING_EDITOR_LOCALE_TEMPLATE_P_NAME)).format(name = current_locale)}"
_current_value = get_local_text(current_value, current_locale)
await state.update_data({
"context_data": context_data.pack(),
"edit_prompt": edit_prompt,
"locale_index": str(locale_index),
"current_value": current_value})
else:
context_data = ContextData(
command = CallbackCommand.FIELD_EDITOR_CALLBACK,
context = callback_data.context,
entity_name = callback_data.entity_name,
entity_id = callback_data.entity_id,
field_name = callback_data.field_name,
save_state = True)
_current_value = serialize(current_value, field_descriptor)
await state.update_data({
"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)))
await wrap_editor(keyboard_builder = keyboard_builder,
field_descriptor = field_descriptor,
entity_descriptor = entity_descriptor,
callback_data = callback_data,
state = state)
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,191 @@
from typing import get_args, get_origin
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 ....main import QBotApp
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.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....model import EntityPermission
from ....utils import serialize, deserialize, get_user_permissions
from ..context import ContextData, CallbackCommand, CommandContext
from ..common import get_send_message, get_local_text, get_entity_descriptor, get_callable_str, get_value_repr
logger = getLogger(__name__)
router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_ITEM))
async def entity_item_callback(query: CallbackQuery, callback_data: ContextData, **kwargs):
await clear_state(state = kwargs["state"])
stack = await save_navigation_context(callback_data = callback_data, state = kwargs["state"])
await entity_item(query = query, callback_data = callback_data, navigation_stack = stack, **kwargs)
async def entity_item(query: CallbackQuery,
callback_data: ContextData,
db_session: AsyncSession,
user: UserBase,
app: QBotApp,
navigation_stack: list[ContextData],
**kwargs):
entity_descriptor = get_entity_descriptor(app, callback_data)
user_permissions = get_user_permissions(user, entity_descriptor)
entity_type: BotEntity = 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)))
is_owned = issubclass(entity_type, OwnedBotEntity)
if (EntityPermission.READ not in user_permissions and
EntityPermission.READ_ALL not in user_permissions):
return await query.answer(text = (await Settings.get(Settings.APP_STRINGS_FORBIDDEN)))
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))
edit_delete_row = []
if can_edit:
edit_delete_row.append(
InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_EDIT_BTN)),
callback_data = ContextData(
command = CallbackCommand.FIELD_EDITOR,
context = CommandContext.ENTITY_EDIT,
entity_name = entity_descriptor.name,
entity_id = str(entity_item.id),
field_name = entity_descriptor.field_sequence[0],
save_state = True).pack()))
if can_delete:
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()))
if edit_delete_row:
keyboard_builder.row(*edit_delete_row)
entity_caption = get_callable_str(entity_descriptor.caption_msg, 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
item_text = f"<b><u><i>{entity_caption or entity_descriptor.name}:</i></u></b> <b>{entity_item_name}</b>"
for field_descriptor in entity_descriptor.fields_descriptors.values():
if field_descriptor.name in ["name", "id"] or not field_descriptor.is_visible:
continue
field_caption = get_callable_str(field_descriptor.caption_str, 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 ""}"
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()))
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 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())
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)
if callback_data.data == "no":
await route_callback(message = query, back = False, **kwargs)
from ..navigation import pop_navigation_context, save_navigation_context, clear_state, route_callback

View File

@@ -0,0 +1,143 @@
from typing import get_args, get_origin
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 ....main import QBotApp
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.descriptors import EntityFieldDescriptor, EntityDescriptor
from ....model import EntityPermission
from ....utils import serialize, deserialize, get_user_permissions
from ..context import ContextData, CallbackCommand, CommandContext
from ..common import (add_pagination_controls, get_local_text, get_entity_descriptor,
get_callable_str, get_send_message)
logger = getLogger(__name__)
router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.ENTITY_LIST))
async def entity_list_callback(query: CallbackQuery, callback_data: ContextData, **kwargs):
if callback_data.data == "skip":
return
await clear_state(state = kwargs["state"])
stack = await save_navigation_context(callback_data = callback_data, state = kwargs["state"])
await entity_list(message = query, callback_data = callback_data, navigation_stack = stack, **kwargs)
async def entity_list(message: CallbackQuery | Message,
callback_data: ContextData,
db_session: AsyncSession,
user: UserBase,
app: QBotApp,
navigation_stack: list[ContextData],
**kwargs):
page = int(callback_data.data or "1")
entity_descriptor = get_entity_descriptor(app, callback_data)
user_permissions = get_user_permissions(user, entity_descriptor)
entity_type = entity_descriptor.type_
keyboard_builder = InlineKeyboardBuilder()
if EntityPermission.CREATE in user_permissions or EntityPermission.CREATE_ALL in user_permissions:
keyboard_builder.row(
InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_ADD_BTN)),
callback_data = ContextData(
command = CallbackCommand.FIELD_EDITOR,
context = CommandContext.ENTITY_CREATE,
entity_name = entity_descriptor.name,
field_name = entity_descriptor.field_sequence[0],
save_state = True).pack()))
page_size = await Settings.get(Settings.PAGE_SIZE)
if issubclass(entity_type, OwnedBotEntity):
if EntityPermission.READ_ALL in user_permissions or EntityPermission.LIST_ALL in user_permissions:
items = await entity_type.get_multi(
session = db_session, order_by = entity_type.name,
skip = page_size * (page - 1), limit = page_size)
items_count = await entity_type.get_count(session = db_session)
elif EntityPermission.READ in user_permissions or EntityPermission.LIST in user_permissions:
items = await entity_type.get_multi_by_user(
session = db_session, user_id = user.id, order_by = entity_type.name,
skip = page_size * (page - 1), limit = page_size)
items_count = await entity_type.get_count_by_user(session = db_session, user_id = user.id)
else:
items = list[OwnedBotEntity]()
items_count = 0
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 = await entity_type.get_multi(
session = db_session, order_by = entity_type.name,
skip = page_size * (page - 1), limit = page_size)
items_count = await entity_type.get_count(session = db_session)
else:
items = list[BotEntity]()
items_count = 0
else:
raise ValueError(f"Unsupported entity type: {entity_type}")
total_pages = items_count // page_size + (1 if items_count % page_size else 0)
for item in items:
if entity_descriptor.item_caption_btn:
caption = entity_descriptor.item_caption_btn(entity_descriptor, item)
elif entity_descriptor.fields_descriptors["name"].localizable:
caption = get_local_text(item.name, user.lang)
else:
caption = item.name
keyboard_builder.row(
InlineKeyboardButton(
text = caption,
callback_data = ContextData(
command = CallbackCommand.ENTITY_ITEM,
entity_name = entity_descriptor.name,
entity_id = str(item.id)).pack()))
add_pagination_controls(keyboard_builder = keyboard_builder,
callback_data = callback_data,
total_pages = total_pages,
command = CallbackCommand.ENTITY_LIST,
page = page)
context = pop_navigation_context(navigation_stack)
if context:
keyboard_builder.row(
InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = context.pack()))
if entity_descriptor.caption_msg:
entity_text = get_callable_str(entity_descriptor.caption_msg, 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
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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,67 @@
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 logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....main import QBotApp
from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand
from ..common import get_send_message
from ....model.descriptors import EntityCaptionCallable
logger = getLogger(__name__)
router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_ENTITIES))
async def menu_entry_entities(message: CallbackQuery, **kwargs):
stack = await save_navigation_context(
callback_data = kwargs["callback_data"],
state = kwargs["state"])
await entities_menu(message = message, navigation_stack = stack, **kwargs)
async def entities_menu(message: Message | CallbackQuery,
callback_data: ContextData,
app: QBotApp,
state: FSMContext,
navigation_stack: list[ContextData],
**kwargs):
keyboard_builder = InlineKeyboardBuilder()
entity_metadata = app.entity_metadata
for entity in entity_metadata.entity_descriptors.values():
if entity.caption_btn.__class__ == EntityCaptionCallable:
caption = entity.caption_btn(entity) or entity.name
elif entity.caption_btn.__class__ == LazyProxy:
caption = f"{f"{entity.icon} " if entity.icon else ""}{entity.caption_btn.value or entity.name}"
else:
caption = f"{f"{entity.icon} " if entity.icon else ""}{entity.caption_btn or entity.name}"
keyboard_builder.row(
InlineKeyboardButton(
text = caption,
callback_data = ContextData(command = CallbackCommand.ENTITY_LIST, entity_name = entity.name).pack()))
context = pop_navigation_context(navigation_stack)
if context:
keyboard_builder.row(
InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = context.pack()))
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

View File

@@ -0,0 +1,62 @@
from aiogram import Router, F
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....main import QBotApp
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 .settings import settings_menu
from ..common import get_send_message
logger = getLogger(__name__)
router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.MENU_ENTRY_LANGUAGE))
async def menu_entry_language(message: CallbackQuery, **kwargs):
stack = await save_navigation_context(callback_data = kwargs["callback_data"],
state = kwargs["state"])
await language_menu(message, navigation_stack = stack, **kwargs)
async def language_menu(message: Message | CallbackQuery,
navigation_stack: list[ContextData],
user: UserBase,
**kwargs):
send_message = get_send_message(message)
inline_keyboard = [
[InlineKeyboardButton(text = locale.localized(user.lang),
callback_data = ContextData(command = CallbackCommand.SET_LANGUAGE,
data = str(locale)).pack())]
for locale in LanguageBase.all_members.values()]
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())])
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, user: UserBase, callback_data: ContextData, db_session: AsyncSession, **kwargs):
user.lang = callback_data.data
await db_session.commit()
await route_callback(message, callback_data = callback_data, user = user, db_session = db_session, **kwargs)
from ..navigation import pop_navigation_context, save_navigation_context

91
bot/handlers/menu/main.py Normal file
View File

@@ -0,0 +1,91 @@
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 ....main import QBotApp
from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand
from ..common import get_send_message
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"])
await main_menu(message, navigation_stack = stack, **kwargs)
async def main_menu(message: Message | CallbackQuery, navigation_stack: list[ContextData], **kwargs):
keyboard_builder = InlineKeyboardBuilder()
keyboard_builder.row(
InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_REFERENCES_BTN)),
callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_ENTITIES).pack()))
keyboard_builder.row(
InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_SETTINGS_BTN)),
callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_SETTINGS).pack()))
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()))
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
router.include_routers(
entities_router,
settings_router,
parameters_router,
language_router,
editors_router,
entity_list_router,
entity_form_router
)
from ..navigation import save_navigation_context, pop_navigation_context, clear_state

View File

@@ -0,0 +1,80 @@
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 ....main import QBotApp
from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand, CommandContext
from ..common import get_send_message, get_value_repr, get_callable_str, 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_PARAMETERS))
async def menu_entry_parameters(message: CallbackQuery, **kwargs):
await clear_state(state = kwargs["state"])
stack = await save_navigation_context(callback_data = kwargs["callback_data"], state = kwargs["state"])
await parameters_menu(message = message, navigation_stack = stack, **kwargs)
async def parameters_menu(message: Message | CallbackQuery,
user: UserBase,
callback_data: ContextData,
navigation_stack: list[ContextData],
**kwargs):
if not await authorize_command(user = user, callback_data = callback_data):
await message.answer(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
settings = await Settings.get_params()
keyboard_builder = InlineKeyboardBuilder()
for key, value in settings.items():
if not key.is_visible:
continue
if key.caption_value_btn:
caption = get_callable_str(callable_str = key.caption_value_btn, descriptor = key, entity = None, value = value)
else:
if key.caption_btn:
caption = get_callable_str(callable_str = key.caption_btn, descriptor = key, entity = None, value = value)
else:
caption = key.name
if key.type_ == bool:
caption = f"{"【✔︎】" if value else "【 】"} {caption}"
else:
caption = f"{caption}: {get_value_repr(value = value, field_descriptor = key, locale = user.lang)}"
keyboard_builder.row(InlineKeyboardButton(text = caption,
callback_data = ContextData(
command = CallbackCommand.FIELD_EDITOR,
context = CommandContext.SETTING_EDIT,
field_name = key.name).pack()))
context = pop_navigation_context(navigation_stack)
if context:
keyboard_builder.row(InlineKeyboardButton(text = (await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data = context.pack()))
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

View File

@@ -0,0 +1,58 @@
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 ....main import QBotApp
from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand
from ..common import get_send_message, 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))
async def menu_entry_settings(message: CallbackQuery, **kwargs):
stack = await save_navigation_context(callback_data = kwargs["callback_data"], state = kwargs["state"])
await settings_menu(message, navigation_stack = stack, **kwargs)
async def settings_menu(message: Message | CallbackQuery,
user: UserBase,
navigation_stack: list[ContextData],
**kwargs):
keyboard_builder = InlineKeyboardBuilder()
if await authorize_command(user = user, callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_PARAMETERS)):
keyboard_builder.row(
InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_PARAMETERS_BTN)),
callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_PARAMETERS).pack()))
keyboard_builder.row(
InlineKeyboardButton(
text = (await Settings.get(Settings.APP_STRINGS_LANGUAGE_BTN)),
callback_data = ContextData(command = CallbackCommand.MENU_ENTRY_LANGUAGE).pack()))
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()))
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

View File

@@ -0,0 +1,93 @@
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery
from .context import ContextData, CallbackCommand
async def save_navigation_context(callback_data: ContextData, state: FSMContext) -> list[ContextData]:
data = await state.get_data()
stack = [ContextData.unpack(item) for item in data.get("navigation_stack", [])]
data_nc = data.get("navigation_context")
navigation_context = ContextData.unpack(data_nc) if data_nc else None
if callback_data.back:
callback_data.back = False
if stack:
stack.pop()
else:
if stack and navigation_context and navigation_context.command == callback_data.command:
navigation_context = callback_data
elif navigation_context:
stack.append(navigation_context)
await state.update_data({"navigation_stack": [item.pack() for item in stack],
"navigation_context": callback_data.pack()})
return stack
def pop_navigation_context(stack: list[ContextData]) -> ContextData | None:
if stack:
data = stack[-1]
data.back = True
return data
async def get_navigation_context(state: FSMContext) -> tuple[list[ContextData], ContextData | None]:
data = await state.get_data()
data_nc = data.get("navigation_context")
context = ContextData.unpack(data_nc) if data_nc else None
return ([ContextData.unpack(item) for item in data.get("navigation_stack", [])],
context)
async def clear_state(state: FSMContext, clear_nav: bool = False):
if clear_nav:
await state.clear()
else:
state_data = await state.get_data()
stack = state_data.get("navigation_stack")
context = state_data.get("navigation_context")
update_data = {}
if stack:
update_data["navigation_stack"] = stack
if context:
update_data["navigation_context"] = context
await state.clear()
await state.update_data(update_data)
async def route_callback(message: Message | CallbackQuery, back: bool = True, **kwargs):
stack, context = await get_navigation_context(kwargs["state"])
if back:
context = pop_navigation_context(stack)
stack = await save_navigation_context(callback_data = context, state = kwargs["state"])
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)
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

54
bot/handlers/start.py Normal file
View File

@@ -0,0 +1,54 @@
from aiogram import Router
from aiogram.filters import CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
from logging import getLogger
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
logger = getLogger(__name__)
router = Router()
@router.message(CommandStart())
async def start(message: Message, db_session: AsyncSession, app: QBotApp, state: FSMContext):
await clear_state(state = state, clear_nav = True)
User = app.user_class
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)
try:
if message.from_user.language_code in [item.value for item in LanguageBase.all_members.values()]:
lang = LanguageBase(message.from_user.language_code)
user = await User.create(session = db_session,
obj_in = User(
id = message.from_user.id,
name = message.from_user.full_name,
lang = lang,
is_active = True),
commit = True)
except Exception as e:
logger.error("Error creating user", exc_info = True)
message.answer((await Settings.get(Settings.APP_STRINGS_INTERNAL_ERROR_P_ERROR)).format(error = str(e)))
return
else:
if user.is_active:
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)
await message.answer(msg_text)