add visibility delegates for fields in edit form
Some checks failed
Build Docs / changes (push) Failing after 2s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped

This commit is contained in:
Alexander Kalinovsky
2025-04-22 20:30:33 +07:00
parent a3357a2924
commit a134194852
14 changed files with 270 additions and 112 deletions

View File

@@ -5,7 +5,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from babel.support import LazyProxy
from logging import getLogger
from ....model.descriptors import FieldDescriptor
from ....model.descriptors import BotContext, FieldDescriptor
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand
from ....utils.main import get_send_message
@@ -67,12 +67,20 @@ async def bool_editor(
state_data = kwargs["state_data"]
context = BotContext(
db_session=kwargs["db_session"],
app=kwargs["app"],
app_state=kwargs["app_state"],
message=message,
)
await wrap_editor(
keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
context=context,
)
state: FSMContext = kwargs["state"]

View File

@@ -6,7 +6,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from typing import TYPE_CHECKING
from ....model.descriptors import FieldDescriptor
from ....model.descriptors import BotContext, FieldDescriptor
from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand
@@ -158,6 +158,12 @@ async def time_picker(
)
state_data = kwargs["state_data"]
context = BotContext(
db_session=kwargs["db_session"],
app=kwargs["app"],
app_state=kwargs["app_state"],
message=message,
)
await wrap_editor(
keyboard_builder=keyboard_builder,
@@ -165,6 +171,7 @@ async def time_picker(
callback_data=callback_data,
state_data=state_data,
user=user,
context=context,
)
await state.set_data(state_data)
@@ -272,12 +279,20 @@ async def date_picker(
state_data = kwargs["state_data"]
context = BotContext(
db_session=kwargs["db_session"],
app=kwargs["app"],
app_state=kwargs["app_state"],
message=message,
)
await wrap_editor(
keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
context=context,
)
await state.set_data(state_data)
@@ -295,11 +310,11 @@ async def date_picker(
async def date_picker_year(
query: CallbackQuery,
callback_data: ContextData,
app: "QBotApp",
state: FSMContext,
user: UserBase,
**kwargs,
):
app: "QBotApp" = kwargs["app"]
start_date = datetime.strptime(callback_data.data, "%Y-%m-%d %H-%M")
state_data = await state.get_data()
@@ -366,12 +381,20 @@ async def date_picker_year(
field_descriptor = get_field_descriptor(app, callback_data)
context = BotContext(
db_session=kwargs["db_session"],
app=app,
app_state=kwargs["app_state"],
message=query,
)
await wrap_editor(
keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
context=context,
)
await query.message.edit_reply_markup(reply_markup=keyboard_builder.as_markup())
@@ -380,9 +403,8 @@ async def date_picker_year(
@router.callback_query(
ContextData.filter(F.command == CallbackCommand.DATE_PICKER_MONTH)
)
async def date_picker_month(
query: CallbackQuery, callback_data: ContextData, app: "QBotApp", **kwargs
):
async def date_picker_month(query: CallbackQuery, callback_data: ContextData, **kwargs):
app: "QBotApp" = kwargs["app"]
field_descriptor = get_field_descriptor(app, callback_data)
state: FSMContext = kwargs["state"]
state_data = await state.get_data()

View File

@@ -293,12 +293,20 @@ async def render_entity_picker(
state_data = kwargs["state_data"]
context = BotContext(
db_session=db_session,
app=kwargs["app"],
app_state=kwargs["app_state"],
message=message,
)
await wrap_editor(
keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
context=context,
)
await state.set_data(state_data)

View File

@@ -5,12 +5,13 @@ from aiogram.types import Message, CallbackQuery
from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from quickbot.model.descriptors import EntityForm
from quickbot.model.descriptors import BotContext, EntityForm
from ....model import EntityPermission
from ....model.settings import Settings
from ....model.user import UserBase
from ....utils.main import (
build_field_sequence,
check_entity_permission,
get_field_descriptor,
)
@@ -134,6 +135,22 @@ async def field_editor(message: Message | CallbackQuery, **kwargs):
form: EntityForm = entity_descriptor.forms.get(
form_name, entity_descriptor.default_form
)
if form.edit_field_sequence:
field_sequence = form.edit_field_sequence
else:
context = BotContext(
db_session=db_session,
app=app,
app_state=kwargs["app_state"],
message=message,
)
field_sequence = await build_field_sequence(
entity_descriptor=entity_descriptor,
user=user,
callback_data=callback_data,
state_data=state_data,
context=context,
)
entity_data = {
key: serialize(
getattr(
@@ -143,7 +160,7 @@ async def field_editor(message: Message | CallbackQuery, **kwargs):
entity_descriptor.fields_descriptors[key],
)
for key in (
form.edit_field_sequence
field_sequence
if callback_data.context == CommandContext.ENTITY_EDIT
else [callback_data.field_name]
)

View File

@@ -3,7 +3,7 @@ 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 typing import TYPE_CHECKING, Any
from decimal import Decimal
import json
@@ -39,6 +39,26 @@ if TYPE_CHECKING:
router = Router()
async def _validate_value(
field_descriptor: FieldDescriptor,
value: Any,
message: Message | CallbackQuery,
**kwargs: Any,
) -> bool | str:
if field_descriptor.validator:
context = BotContext(
db_session=kwargs["db_session"],
app=kwargs["app"],
app_state=kwargs["app_state"],
message=message,
)
if iscoroutinefunction(field_descriptor.validator):
return await field_descriptor.validator(value, context)
else:
return field_descriptor.validator(value, context)
return True
@router.message(CallbackCommandFilter(CallbackCommand.FIELD_EDITOR_CALLBACK))
@router.callback_query(
ContextData.filter(F.command == CallbackCommand.FIELD_EDITOR_CALLBACK)
@@ -59,6 +79,39 @@ async def field_editor_callback(message: Message | CallbackQuery, **kwargs):
field_descriptor = get_field_descriptor(app, callback_data)
type_base = field_descriptor.type_base
if type_base in [int, float, Decimal]:
try:
val = type_base(value) # @IgnoreException
except Exception:
return await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_INVALID_INPUT))
)
elif type_base is str and field_descriptor.localizable:
locale_index = int(state_data.get("locale_index"))
val = {
list(LanguageBase.all_members.values())[
locale_index
].value: message.text
}
else:
val = value
validation_result = await _validate_value(
field_descriptor=field_descriptor,
value=val,
message=message,
**kwargs,
)
if isinstance(validation_result, str):
return await message.answer(
text=f"{await Settings.get(Settings.APP_STRINGS_INVALID_INPUT)}\n{validation_result}"
)
elif not validation_result:
return await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_INVALID_INPUT))
)
if type_base is str and field_descriptor.localizable:
locale_index = int(state_data.get("locale_index"))
@@ -90,13 +143,6 @@ async def field_editor_callback(message: Message | CallbackQuery, **kwargs):
**kwargs,
)
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:
@@ -150,6 +196,9 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
app: "QBotApp" = kwargs["app"]
entity_descriptor = get_entity_descriptor(app, callback_data)
entity_data = state_data.get("entity_data", {})
entity_data[field_descriptor.field_name] = value
if callback_data.context == CommandContext.COMMAND_FORM:
field_sequence = list(field_descriptor.command.param_form.keys())
current_index = field_sequence.index(callback_data.field_name)
@@ -167,11 +216,18 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
if form.edit_field_sequence:
field_sequence = form.edit_field_sequence
else:
field_sequence = build_field_sequence(
context = BotContext(
db_session=kwargs["db_session"],
app=kwargs["app"],
app_state=kwargs["app_state"],
message=message,
)
field_sequence = await build_field_sequence(
entity_descriptor=entity_descriptor,
user=user,
callback_data=callback_data,
state_data=state_data,
context=context,
)
current_index = (
@@ -182,8 +238,6 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
)
field_descriptors = entity_descriptor.fields_descriptors
entity_data = state_data.get("entity_data", {})
if callback_data.context == CommandContext.ENTITY_CREATE:
stack = state_data.get("navigation_stack", [])
prev_callback_data = ContextData.unpack(stack[-1]) if stack else None
@@ -224,7 +278,7 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
]
and current_index < len(field_sequence) - 1
):
entity_data[field_descriptor.field_name] = value
# entity_data[field_descriptor.field_name] = value
state_data.update({"entity_data": entity_data})
next_field_name = field_sequence[current_index + 1]
@@ -252,7 +306,7 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
)
else:
entity_data[field_descriptor.name] = value
# 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

View File

@@ -5,7 +5,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from typing import Any
from ....model.descriptors import FieldDescriptor
from ....model.descriptors import BotContext, FieldDescriptor
from ....model.language import LanguageBase
from ....model.settings import Settings
from ....model.user import UserBase
@@ -87,12 +87,20 @@ async def string_editor(
state_data = kwargs["state_data"]
context = BotContext(
db_session=kwargs["db_session"],
app=kwargs["app"],
app_state=kwargs["app_state"],
message=message,
)
await wrap_editor(
keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
context=context,
)
await state.set_data(state_data)

View File

@@ -2,7 +2,7 @@ from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ....model.settings import Settings
from ....model.descriptors import EntityForm, FieldDescriptor
from ....model.descriptors import BotContext, EntityForm, FieldDescriptor
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand, CommandContext
from ....utils.navigation import get_navigation_context, pop_navigation_context
@@ -15,6 +15,7 @@ async def wrap_editor(
callback_data: ContextData,
state_data: dict,
user: UserBase,
context: BotContext,
):
if callback_data.context in [
CommandContext.ENTITY_CREATE,
@@ -42,11 +43,12 @@ async def wrap_editor(
if form.edit_field_sequence:
field_sequence = form.edit_field_sequence
else:
field_sequence = build_field_sequence(
field_sequence = await build_field_sequence(
entity_descriptor=field_descriptor.entity_descriptor,
user=user,
callback_data=callback_data,
state_data=state_data,
context=context,
)
field_index = (
field_sequence.index(field_descriptor.name)

View File

@@ -208,11 +208,12 @@ async def entity_item(
if form.edit_field_sequence:
field_sequence = form.edit_field_sequence
else:
field_sequence = build_field_sequence(
field_sequence = await build_field_sequence(
entity_descriptor=entity_descriptor,
user=user,
callback_data=callback_data,
state_data=state_data,
context=context,
)
edit_delete_row.append(
InlineKeyboardButton(
@@ -314,6 +315,16 @@ async def entity_item(
if skip:
continue
if field_descriptor.caption_value:
item_text += f"\n{
await get_callable_str(
callable_str=field_descriptor.caption_value,
context=context,
descriptor=field_descriptor,
entity=entity_item,
)
}"
else:
field_caption = (
await get_callable_str(
callable_str=field_descriptor.caption,
@@ -323,14 +334,6 @@ async def entity_item(
if field_descriptor.caption
else field_descriptor.field_name
)
if field_descriptor.caption_value:
value = await get_callable_str(
callable_str=field_descriptor.caption_value,
context=context,
descriptor=field_descriptor,
entity=entity_item,
)
else:
value = await get_value_repr(
value=getattr(entity_item, field_descriptor.field_name),
field_descriptor=field_descriptor,

View File

@@ -118,6 +118,12 @@ async def entity_list(
)
keyboard_builder = InlineKeyboardBuilder()
context = BotContext(
db_session=db_session,
app=app,
app_state=kwargs["app_state"],
message=message,
)
if (
EntityPermission.CREATE in user_permissions
@@ -126,11 +132,12 @@ async def entity_list(
if form_item.edit_field_sequence:
field_sequence = form_item.edit_field_sequence
else:
field_sequence = build_field_sequence(
field_sequence = await build_field_sequence(
entity_descriptor=entity_descriptor,
user=user,
callback_data=callback_data,
state_data=kwargs["state_data"],
context=context,
)
keyboard_builder.row(
InlineKeyboardButton(
@@ -206,13 +213,6 @@ async def entity_list(
total_pages = 1
page = 1
context = BotContext(
db_session=db_session,
app=app,
app_state=kwargs["app_state"],
message=message,
)
for item in items:
caption = None

View File

@@ -98,6 +98,7 @@ async def cammand_handler(message: Message | CallbackQuery, cmd: BotCommand, **k
db_session=kwargs["db_session"],
user=kwargs["user"],
app=app,
app_state=kwargs["app_state"],
state_data=state_data,
state=state,
i18n=kwargs["i18n"],

View File

@@ -161,6 +161,7 @@ class QBotApp(Generic[UserType, ConfigType], FastAPI):
)
else:
for locale, description in command.caption.items():
locale = "default" if locale == "en" else locale
if locale not in commands_captions:
commands_captions[locale] = []
commands_captions[locale].append((command_name, description))

View File

@@ -31,7 +31,7 @@ class FieldEditButton:
@dataclass
class CommandButton:
command: ContextData | Callable[[ContextData, Any], ContextData] | str
command: ContextData | Callable[["BotEntity", "BotContext"], ContextData] | str
caption: str | LazyProxy | Callable[["BotEntity", "BotContext"], str] | None = None
visibility: Callable[["BotEntity", "BotContext"], bool] | None = None
@@ -113,6 +113,12 @@ class _BaseFieldDescriptor:
is_visible: (
bool | Callable[["FieldDescriptor", "BotEntity", "BotContext"], bool] | None
) = None
is_visible_in_edit_form: (
bool
| Callable[["FieldDescriptor", Union["BotEntity", Any], "BotContext"], bool]
| None
) = None
validator: Callable[[Any, "BotContext"], Union[bool, str]] | None = None
localizable: bool = False
bool_false_value: str | LazyProxy = "no"
bool_true_value: str | LazyProxy = "yes"
@@ -219,6 +225,7 @@ class CommandCallbackContext[UT: UserBase]:
db_session: AsyncSession
user: UT
app: "QBotApp"
app_state: State
state_data: dict[str, Any]
state: FSMContext
form_data: dict[str, Any]

View File

@@ -2,6 +2,7 @@ from types import NoneType, UnionType
from aiogram.utils.i18n.context import get_i18n
from datetime import datetime
from sqlmodel import SQLModel, Field, select
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any, get_args, get_origin
from ..db import async_session
@@ -193,11 +194,23 @@ class Settings(metaclass=SettingsMetaclass):
)
@classmethod
async def get[T](cls, param: T, all_locales=False, locale: str = None) -> T:
async def get[T](
cls,
param: T,
session: AsyncSession = None,
all_locales=False,
locale: str = None,
) -> T:
name = param.field_name
if name not in cls._cache.keys():
cls._cache[name] = await cls.load_param(param)
if session is None:
async with async_session() as session:
cls._cache[name] = await cls.load_param(
session=session, param=param
)
else:
cls._cache[name] = await cls.load_param(session=session, param=param)
ret_val = cls._cache[name]
@@ -213,8 +226,7 @@ class Settings(metaclass=SettingsMetaclass):
return ret_val
@classmethod
async def load_param(cls, param: FieldDescriptor) -> Any:
async with async_session() as session:
async def load_param(cls, session: AsyncSession, param: FieldDescriptor) -> Any:
db_setting = (
await session.exec(
select(DbSettings).where(DbSettings.name == param.field_name)
@@ -240,20 +252,20 @@ class Settings(metaclass=SettingsMetaclass):
)
)
@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] # type: FieldDescriptor
cls._cache[db_setting.name] = await deserialize(
session=session,
type_=setting.type_,
value=db_setting.value,
)
# @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] # type: FieldDescriptor
# cls._cache[db_setting.name] = await deserialize(
# session=session,
# type_=setting.type_,
# value=db_setting.value,
# )
cls._loaded = True
# cls._loaded = True
@classmethod
async def set_param(cls, param: str | FieldDescriptor, value) -> None:

View File

@@ -15,6 +15,7 @@ from ..model.descriptors import (
FieldDescriptor,
EntityDescriptor,
EntityPermission,
_BaseFieldDescriptor,
)
from ..bot.handlers.context import CallbackCommand, ContextData, CommandContext
@@ -198,10 +199,13 @@ async def get_callable_str(
if len(args) == 3:
return await callable_str(descriptor, entity, context)
else:
if issubclass(args[0].annotation, BotEntity):
return await callable_str(entity, context)
else:
param = args[next(iter(args))]
if not isinstance(param.annotation, str) and issubclass(
param.annotation, _BaseFieldDescriptor
):
return await callable_str(descriptor, context)
else:
return await callable_str(entity, context)
else:
if len(args) == 3:
return callable_str(descriptor, entity, context)
@@ -245,11 +249,12 @@ def get_field_descriptor(
return None
def build_field_sequence(
async def build_field_sequence(
entity_descriptor: EntityDescriptor,
user: "UserBase",
callback_data: ContextData,
state_data: dict,
context: BotContext,
):
prev_form_list = None
@@ -269,10 +274,20 @@ def build_field_sequence(
prev_form_name, entity_descriptor.default_list
)
entity_data = state_data.get("entity_data", {})
field_sequence = list[str]()
# exclude ownership fields from edit if user has no CREATE_ALL/UPDATE_ALL permission
user_permissions = get_user_permissions(user, entity_descriptor)
for fd in entity_descriptor.fields_descriptors.values():
if isinstance(fd.is_visible_in_edit_form, bool):
skip = not fd.is_visible_in_edit_form
elif callable(fd.is_visible_in_edit_form):
if iscoroutinefunction(fd.is_visible_in_edit_form):
skip = not await fd.is_visible_in_edit_form(fd, entity_data, context)
else:
skip = not fd.is_visible_in_edit_form(fd, entity_data, context)
else:
skip = False
if (
fd.is_optional