Compare commits

...

2 Commits

Author SHA1 Message Date
Alexander Kalinovsky
fe0380f9f3 minor fixes and updates
All checks were successful
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-19 14:46:23 +07:00
Alexander Kalinovsky
f0db2b2830 upd defult editing field sequences generation when ownership fields defined 2025-03-13 16:52:03 +07:00
19 changed files with 258 additions and 102 deletions

View File

@@ -31,7 +31,11 @@ async def telegram_webhook(
return Response(status_code=400)
try:
await app.dp.feed_webhook_update(
app.bot, update, db_session=db_session, app=app
app.bot,
update,
db_session=db_session,
app=app,
**(request.state if request.state else {}),
)
except Exception:
logger.error("Error processing update", exc_info=True)

View File

@@ -6,6 +6,7 @@ from babel.support import LazyProxy
from logging import getLogger
from ....model.descriptors import FieldDescriptor
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand
from ....utils.main import get_send_message
from .wrapper import wrap_editor
@@ -20,6 +21,7 @@ async def bool_editor(
edit_prompt: str,
field_descriptor: FieldDescriptor,
callback_data: ContextData,
user: UserBase,
**kwargs,
):
keyboard_builder = InlineKeyboardBuilder()
@@ -70,6 +72,7 @@ async def bool_editor(
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
)
state: FSMContext = kwargs["state"]

View File

@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from ....model.descriptors import FieldDescriptor
from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand
from ....utils.main import get_send_message, get_field_descriptor
from .wrapper import wrap_editor
@@ -49,6 +50,7 @@ async def time_picker(
callback_data: ContextData,
current_value: datetime | time,
state: FSMContext,
user: UserBase,
edit_prompt: str | None = None,
**kwargs,
):
@@ -162,6 +164,7 @@ async def time_picker(
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
)
await state.set_data(state_data)
@@ -179,6 +182,7 @@ async def date_picker(
callback_data: ContextData,
current_value: datetime,
state: FSMContext,
user: UserBase,
edit_prompt: str | None = None,
**kwargs,
):
@@ -273,6 +277,7 @@ async def date_picker(
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
)
await state.set_data(state_data)
@@ -292,6 +297,7 @@ async def date_picker_year(
callback_data: ContextData,
app: "QBotApp",
state: FSMContext,
user: UserBase,
**kwargs,
):
start_date = datetime.strptime(callback_data.data, "%Y-%m-%d %H-%M")
@@ -365,6 +371,7 @@ async def date_picker_year(
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
)
await query.message.edit_reply_markup(reply_markup=keyboard_builder.as_markup())

View File

@@ -289,6 +289,7 @@ async def render_entity_picker(
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
)
await state.set_data(state_data)

View File

@@ -107,6 +107,8 @@ async def field_editor(message: Message | CallbackQuery, **kwargs):
await db_session.commit()
stack, context = get_navigation_context(state_data=state_data)
kwargs.update({"callback_data": context})
return await entity_item(
query=message, navigation_stack=stack, **kwargs
)

View File

@@ -22,6 +22,7 @@ from ....utils.main import (
clear_state,
get_entity_descriptor,
get_field_descriptor,
build_field_sequence,
)
from ....utils.serialization import deserialize
from ..common.routing import route_callback
@@ -168,7 +169,15 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
form_name, entity_descriptor.default_form
)
if form.edit_field_sequence:
field_sequence = form.edit_field_sequence
else:
field_sequence = build_field_sequence(
entity_descriptor=entity_descriptor,
user=user,
callback_data=callback_data,
)
current_index = (
field_sequence.index(callback_data.field_name)
if callback_data.context
@@ -251,10 +260,14 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
# 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
user_permissions = get_user_permissions(user, entity_descriptor)
# 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
for role in user.roles:
if (
role in entity_descriptor.ownership_fields
and EntityPermission.CREATE_ALL not in user_permissions
):
entity_data[entity_descriptor.ownership_fields[role]] = user.id
deser_entity_data = {
key: await deserialize(
@@ -284,9 +297,19 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
if entity_descriptor.on_created:
if iscoroutinefunction(entity_descriptor.on_created):
await entity_descriptor.on_created(new_entity, EntityEventContext(db_session=db_session, app=app))
await entity_descriptor.on_created(
new_entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
)
else:
entity_descriptor.on_created(new_entity, EntityEventContext(db_session=db_session, app=app))
entity_descriptor.on_created(
new_entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
)
form_name = (
callback_data.form_params.split("&")[0]
@@ -336,9 +359,19 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
if entity_descriptor.on_updated:
if iscoroutinefunction(entity_descriptor.on_updated):
await entity_descriptor.on_updated(entity, EntityEventContext(db_session=db_session, app=app))
await entity_descriptor.on_updated(
entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
)
else:
entity_descriptor.on_updated(entity, EntityEventContext(db_session=db_session, app=app))
entity_descriptor.on_updated(
entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
)
elif callback_data.context == CommandContext.COMMAND_FORM:
clear_state(state_data=state_data)

View File

@@ -8,6 +8,7 @@ from typing import Any
from ....model.descriptors import FieldDescriptor
from ....model.language import LanguageBase
from ....model.settings import Settings
from ....model.user import UserBase
from ....utils.main import get_send_message, get_local_text
from ....utils.serialization import serialize
from ..context import ContextData, CallbackCommand
@@ -25,6 +26,7 @@ async def string_editor(
current_value: Any,
edit_prompt: str,
state: FSMContext,
user: UserBase,
locale_index: int = 0,
**kwargs,
):
@@ -90,6 +92,7 @@ async def string_editor(
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
)
await state.set_data(state_data)

View File

@@ -3,8 +3,10 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from ....model.settings import Settings
from ....model.descriptors import FieldDescriptor
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand, CommandContext
from ....utils.navigation import get_navigation_context, pop_navigation_context
from ....utils.main import build_field_sequence
async def wrap_editor(
@@ -12,6 +14,7 @@ async def wrap_editor(
field_descriptor: FieldDescriptor,
callback_data: ContextData,
state_data: dict,
user: UserBase,
):
if callback_data.context in [
CommandContext.ENTITY_CREATE,
@@ -36,7 +39,14 @@ async def wrap_editor(
form = field_descriptor.entity_descriptor.forms.get(
form_name, field_descriptor.entity_descriptor.default_form
)
if form.edit_field_sequence:
field_sequence = form.edit_field_sequence
else:
field_sequence = build_field_sequence(
entity_descriptor=field_descriptor.entity_descriptor,
user=user,
callback_data=callback_data,
)
field_index = (
field_sequence.index(field_descriptor.name)
if callback_data.context

View File

@@ -1,3 +1,4 @@
from inspect import iscoroutinefunction
from typing import TYPE_CHECKING
from aiogram import Router, F
from aiogram.fsm.context import FSMContext
@@ -6,7 +7,12 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....model.descriptors import FieldEditButton, CommandButton, InlineButton
from ....model.descriptors import (
FieldEditButton,
CommandButton,
InlineButton,
EntityEventContext,
)
from ....model.settings import Settings
from ....model.user import UserBase
from ....model import EntityPermission
@@ -17,6 +23,8 @@ from ....utils.main import (
get_value_repr,
get_callable_str,
get_entity_descriptor,
build_field_sequence,
get_user_permissions,
)
from ..context import ContextData, CallbackCommand, CommandContext
from ....utils.navigation import (
@@ -89,10 +97,11 @@ async def entity_item(
)
if form.form_buttons:
context = EntityEventContext(db_session=db_session, app=app, message=query)
for edit_buttons_row in form.form_buttons:
btn_row = []
for button in edit_buttons_row:
if button.visibility and not button.visibility(entity_item):
if button.visibility and not button.visibility(entity_item, context):
continue
if isinstance(button, FieldEditButton) and can_edit:
@@ -157,7 +166,10 @@ async def entity_item(
if isinstance(button.command, ContextData):
btn_cdata = button.command
elif callable(button.command):
btn_cdata = button.command(callback_data, entity_item)
if iscoroutinefunction(button.command):
btn_cdata = await button.command(entity_item, context)
else:
btn_cdata = button.command(entity_item, context)
elif isinstance(button.command, str):
btn_cdata = ContextData(
command=CallbackCommand.USER_COMMAND,
@@ -175,13 +187,26 @@ async def entity_item(
if isinstance(button.inline_button, InlineKeyboardButton):
btn_row.append(button.inline_button)
elif callable(button.inline_button):
btn_row.append(button.inline_button(entity_item))
if iscoroutinefunction(button.inline_button):
btn_row.append(
await button.inline_button(entity_item, context)
)
else:
btn_row.append(button.inline_button(entity_item, context))
if btn_row:
keyboard_builder.row(*btn_row)
edit_delete_row = []
if can_edit and form.show_edit_button:
if form.edit_field_sequence:
field_sequence = form.edit_field_sequence
else:
field_sequence = build_field_sequence(
entity_descriptor=entity_descriptor,
user=user,
callback_data=callback_data,
)
edit_delete_row.append(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_EDIT_BTN)),
@@ -191,7 +216,7 @@ async def entity_item(
entity_name=entity_descriptor.name,
entity_id=str(entity_item.id),
form_params=callback_data.form_params,
field_name=form.edit_field_sequence[0],
field_name=field_sequence[0],
).pack(),
)
)
@@ -238,8 +263,30 @@ async def entity_item(
item_text = f"<b><u><i>{entity_caption or entity_descriptor.name}:</i></u></b> <b>{entity_item_repr}</b>"
user_permissions = get_user_permissions(user, entity_descriptor)
for field_descriptor in entity_descriptor.fields_descriptors.values():
if field_descriptor.is_visible:
if (
field_descriptor.is_visible is not None
and not field_descriptor.is_visible
):
continue
skip = False
for own_field in entity_descriptor.ownership_fields.items():
if (
own_field[1].rstrip("_id")
== field_descriptor.field_name.rstrip("_id")
and own_field[0] in user.roles
and EntityPermission.READ_ALL not in user_permissions
):
skip = True
break
if skip:
continue
field_caption = await get_callable_str(
field_descriptor.caption, field_descriptor, entity_item
)

View File

@@ -50,17 +50,21 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
)
if callback_data.data == "yes":
entity = await entity_descriptor.type_.remove(
session=db_session, id=int(callback_data.entity_id), commit=True
)
if entity_descriptor.on_deleted:
if iscoroutinefunction(entity_descriptor.on_created):
await entity_descriptor.on_deleted(entity, EntityEventContext(db_session=db_session, app=app))
await entity_descriptor.on_deleted(
entity,
EntityEventContext(db_session=db_session, app=app, message=query),
)
else:
entity_descriptor.on_deleted(entity, EntityEventContext(db_session=db_session, app=app))
entity_descriptor.on_deleted(
entity,
EntityEventContext(db_session=db_session, app=app, message=query),
)
await route_callback(message=query, **kwargs)

View File

@@ -18,6 +18,7 @@ from ....utils.main import (
clear_state,
get_entity_descriptor,
get_callable_str,
build_field_sequence,
)
from ....utils.serialization import deserialize
from ..context import ContextData, CallbackCommand, CommandContext
@@ -116,6 +117,14 @@ async def entity_list(
EntityPermission.CREATE in user_permissions
or EntityPermission.CREATE_ALL in user_permissions
) and form_list.show_add_new_button:
if form_item.edit_field_sequence:
field_sequence = form_item.edit_field_sequence
else:
field_sequence = build_field_sequence(
entity_descriptor=entity_descriptor,
user=user,
callback_data=callback_data,
)
keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_ADD_BTN)),
@@ -123,7 +132,7 @@ async def entity_list(
command=CallbackCommand.FIELD_EDITOR,
context=CommandContext.ENTITY_CREATE,
entity_name=entity_descriptor.name,
field_name=form_item.edit_field_sequence[0],
field_name=field_sequence[0],
form_params=form_list.item_form,
).pack(),
)

View File

@@ -21,9 +21,7 @@ async def start(message: Message, **kwargs):
app: QBotApp = kwargs["app"]
if app.start_handler:
await app.start_handler(
default_start_handler, message, **kwargs
)
await app.start_handler(default_start_handler, message, **kwargs)
else:
await default_start_handler(message, **kwargs)

View File

@@ -24,26 +24,11 @@ class Config(BaseSettings):
def DATABASE_URI(self) -> str:
return f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
DOMAIN: str = "localhost"
@computed_field
@property
def API_DOMAIN(self) -> str:
if self.ENVIRONMENT == "local":
return self.DOMAIN
return f"{self.DOMAIN}"
@computed_field
@property
def API_URL(self) -> str:
if self.USE_NGROK:
return self.NGROK_URL
return (
f"https://{self.API_DOMAIN}"
)
API_PORT: int = 8000
TELEGRAM_WEBHOOK_URL: str = "http://localhost:8000"
TELEGRAM_BOT_SERVER: str = "https://api.telegram.org"
TELEGRAM_BOT_SERVER_IS_LOCAL: bool = False
TELEGRAM_BOT_TOKEN: str = "changethis"
ADMIN_TELEGRAM_ID: int

View File

@@ -1,6 +1,8 @@
from contextlib import asynccontextmanager
from typing import Callable, Any
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.client.default import DefaultBotProperties
from aiogram.types import Message, BotCommand as AiogramBotCommand
from aiogram.utils.callback_answer import CallbackAnswerMiddleware
@@ -21,12 +23,12 @@ from .router import Router
logger = getLogger(__name__)
@asynccontextmanager
async def default_lifespan(app: "QBotApp"):
logger.debug("starting qbot app")
if app.lifespan_bot_init:
if app.config.USE_NGROK:
app.ngrok_init()
@@ -35,8 +37,8 @@ async def default_lifespan(app: "QBotApp"):
logger.info("qbot app started")
if app.lifespan:
async with app.lifespan(app):
yield
async with app.lifespan(app) as state:
yield state
else:
yield
@@ -88,8 +90,14 @@ class QBotApp[UserType: UserBase](FastAPI):
self.entity_metadata: EntityMetadata = user_class.entity_metadata
self.config = config
self.lifespan = lifespan
api_server = TelegramAPIServer.from_base(
self.config.TELEGRAM_BOT_SERVER,
is_local=self.config.TELEGRAM_BOT_SERVER_IS_LOCAL,
)
session = AiohttpSession(api=api_server)
self.bot = Bot(
token=self.config.TELEGRAM_BOT_TOKEN,
session=session,
default=DefaultBotProperties(parse_mode="HTML"),
)
@@ -124,18 +132,16 @@ class QBotApp[UserType: UserBase](FastAPI):
from .api_route.telegram import router as telegram_router
self.include_router(telegram_router, prefix="/api/telegram", tags=["telegram"])
self.include_router(telegram_router, prefix="/telegram", tags=["telegram"])
self.root_router = Router()
self.root_router._commands = self.bot_commands
self.command = self.root_router.command
def register_routers(self, *routers: Router):
for router in routers:
for command_name, command in router._commands.items():
self.bot_commands[command_name] = command
def ngrok_init(self):
try:
from pyngrok import ngrok
@@ -151,7 +157,6 @@ class QBotApp[UserType: UserBase](FastAPI):
)
self.config.NGROK_URL = tunnel.public_url
def ngrok_stop(self):
try:
from pyngrok import ngrok
@@ -163,10 +168,7 @@ class QBotApp[UserType: UserBase](FastAPI):
ngrok.disconnect(self.config.NGROK_URL)
ngrok.kill()
async def bot_init(self):
commands_captions = dict[str, list[tuple[str, str]]]()
for command_name, command in self.bot_commands.items():
@@ -183,6 +185,13 @@ class QBotApp[UserType: UserBase](FastAPI):
commands_captions[locale] = []
commands_captions[locale].append((command_name, description))
await self.bot.set_webhook(
url=f"{self.config.TELEGRAM_WEBHOOK_URL}/telegram/webhook",
drop_pending_updates=True,
allowed_updates=self.allowed_updates,
secret_token=self.bot_auth_token,
)
for locale, commands in commands_captions.items():
await self.bot.set_my_commands(
[
@@ -192,14 +201,7 @@ class QBotApp[UserType: UserBase](FastAPI):
language_code=None if locale == "default" else locale,
)
await self.bot.set_webhook(
url=f"{self.config.API_URL}/api/telegram/webhook",
drop_pending_updates=True,
allowed_updates=self.allowed_updates,
secret_token=self.bot_auth_token,
)
async def bot_close(self):
await self.bot.delete_webhook()
await self.bot.log_out()
await self.bot.close()

View File

@@ -12,6 +12,7 @@ from typing import (
dataclass_transform,
)
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
from sqlmodel import SQLModel, BigInteger, Field, select, func, column, col
from sqlmodel.main import FieldInfo
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -64,7 +65,33 @@ class BotEntityMetaclass(SQLModelMetaclass):
if attribute_value:
if isinstance(attribute_value, EntityField):
descriptor_kwargs = attribute_value.__dict__.copy()
sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None)
sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None) # type: FieldInfo
if sm_descriptor:
if (
attribute_value.default is not None
and sm_descriptor.default is PydanticUndefined
):
sm_descriptor.default = attribute_value.default
if (
attribute_value.default_factory is not None
and sm_descriptor.default_factory is PydanticUndefined
):
sm_descriptor.default_factory = (
attribute_value.default_factory
)
else:
if (
attribute_value.default is not None
or attribute_value.default_factory is not None
):
sm_descriptor = Field()
if attribute_value.default is not None:
sm_descriptor.default = attribute_value.default
if attribute_value.default_factory is not None:
sm_descriptor.default_factory = (
attribute_value.default_factory
)
if sm_descriptor:
namespace[annotation] = sm_descriptor
@@ -157,23 +184,6 @@ class BotEntityMetaclass(SQLModelMetaclass):
fields_descriptors=bot_fields_descriptors,
)
descriptor_fields_sequence = [
key
for key, val in bot_fields_descriptors.items()
if not (val.is_optional or val.name == "id")
]
entity_descriptor: EntityDescriptor = namespace["bot_entity_descriptor"]
if entity_descriptor.default_form.edit_field_sequence is None:
entity_descriptor.default_form.edit_field_sequence = (
descriptor_fields_sequence
)
for form in entity_descriptor.forms.values():
if form.edit_field_sequence is None:
form.edit_field_sequence = descriptor_fields_sequence
for field_descriptor in bot_fields_descriptors.values():
field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"]

View File

@@ -93,7 +93,7 @@ class _BaseFieldDescriptor:
description: str | LazyProxy | EntityFieldCaptionCallable | None = None
edit_prompt: str | LazyProxy | EntityFieldCaptionCallable | None = None
caption_value: EntityFieldCaptionCallable | None = None
is_visible: bool = True
is_visible: bool | None = None
localizable: bool = False
bool_false_value: str | LazyProxy = "no"
bool_true_value: str | LazyProxy = "yes"
@@ -102,6 +102,7 @@ class _BaseFieldDescriptor:
ep_child_field: str | None = None
dt_type: Literal["date", "datetime"] = "date"
default: Any = None
default_factory: Callable[[], Any] | None = None
@dataclass(kw_only=True)
@@ -204,6 +205,7 @@ class CommandCallbackContext[UT: UserBase]:
class EntityEventContext:
db_session: AsyncSession
app: "QBotApp"
message: Message | CallbackQuery | None = None
@dataclass(kw_only=True)

View File

@@ -227,7 +227,9 @@ class Settings(metaclass=SettingsMetaclass):
)
return (
param.default
param.default_factory()
if param.default_factory
else param.default
if param.default
else (
[]
@@ -249,7 +251,6 @@ class Settings(metaclass=SettingsMetaclass):
session=session,
type_=setting.type_,
value=db_setting.value,
default=setting.default,
)
cls._loaded = True

View File

@@ -222,3 +222,40 @@ def get_field_descriptor(
if entity_descriptor:
return entity_descriptor.fields_descriptors.get(callback_data.field_name)
return None
def build_field_sequence(
entity_descriptor: EntityDescriptor, user: "UserBase", callback_data: ContextData
):
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 not (
fd.is_optional
or fd.field_name == "id"
or fd.field_name[:-3] == "_id"
or fd.default is not None
):
skip = False
for own_field in entity_descriptor.ownership_fields.items():
if (
own_field[1].rstrip("_id") == fd.field_name.rstrip("_id")
and own_field[0] in user.roles
and (
(
EntityPermission.CREATE_ALL not in user_permissions
and callback_data.context == CommandContext.ENTITY_CREATE
)
or (
EntityPermission.UPDATE_ALL not in user_permissions
and callback_data.context == CommandContext.ENTITY_EDIT
)
)
):
skip = True
break
if not skip:
field_sequence.append(fd.field_name)
return field_sequence

View File

@@ -21,7 +21,6 @@ from typing import Optional
class User(UserBase):
bot_entity_descriptor = Entity(
icon="👤",
full_name="User",
@@ -56,7 +55,6 @@ class User(UserBase):
class Entity(BotEntity):
bot_entity_descriptor = Entity(
icon="📦",
full_name="Entity",