Compare commits

...

45 Commits

Author SHA1 Message Date
Alexander Kalinovsky
400c6137fd Refactor language and role enums; update user language default value
All checks were successful
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Successful in 38s
Build Docs / deploy-docs (push) Successful in 4s
2025-08-28 15:29:14 +03:00
Alexander Kalinovsky
09e844c73b upd
All checks were successful
Build Docs / changes (push) Successful in 22s
Build Docs / build-docs (push) Successful in 42s
Build Docs / deploy-docs (push) Successful in 4s
2025-08-22 20:19:50 +03:00
Alexander Kalinovsky
40a28638bb 0.1.1
All checks were successful
Build Docs / changes (push) Successful in 28s
Build Docs / build-docs (push) Successful in 56s
Build Docs / deploy-docs (push) Successful in 4s
2025-08-19 17:57:19 +03:00
Alexander Kalinovsky
fe7ca7f51b rename bot app class
All checks were successful
Build Docs / changes (push) Successful in 6s
Build Docs / build-docs (push) Successful in 1m47s
Build Docs / deploy-docs (push) Successful in 5s
2025-08-12 12:56:18 +03:00
Alexander Kalinovsky
4df67c93d4 crud service
All checks were successful
Build Docs / changes (push) Successful in 30s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-08-11 20:47:39 +03:00
Alexander Kalinovsky
a078cdfd86 fix feed update
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-06-12 17:50:06 +03:00
Alexander Kalinovsky
4ac80e0105 add options functionality for input editors
All checks were successful
Build Docs / changes (push) Successful in 25s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-06-10 23:31:31 +03:00
Alexander Kalinovsky
923b0e6cc9 add user tg update handler
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-05-19 19:33:58 +07:00
Alexander Kalinovsky
b1f7ccf1b4 remove unnecessary logging
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-05-18 22:59:04 +07:00
Alexander Kalinovsky
469b160fb8 logging for db sessions
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-05-18 18:55:11 +07:00
Alexander Kalinovsky
ae036023e5 revert backgroundtasks in bot update handler
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-05-18 17:18:58 +07:00
Alexander Kalinovsky
44240b8b04 handle updates without backgroundtasks trial
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-05-18 15:14:01 +07:00
Alexander Kalinovsky
2af0fceb25 increase db connection pool
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-05-18 14:57:29 +07:00
Alexander Kalinovsky
5cadd66ce8 upd update handling
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-05-18 14:35:27 +07:00
Alexander Kalinovsky
33abe15562 fsm bugfixes
All checks were successful
Build Docs / changes (push) Successful in 16s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-05-16 16:35:28 +07:00
Alexander Kalinovsky
eb57a4ff78 type hinting enhancements, bugfix in user commands handler
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
2025-05-12 18:21:30 +07:00
Alexander Kalinovsky
dcacd31bbc fix copy button in string editor
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
2025-05-04 21:15:28 +07:00
Alexander Kalinovsky
094c36f61b minor change
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
2025-05-04 01:00:23 +07:00
Alexander Kalinovsky
90652b9f3f add bot entity's events, external bot command call
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
2025-04-30 19:21:29 +07:00
Alexander Kalinovsky
a4999159b9 fix i18n middleware
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-04-27 18:15:57 +07:00
Alexander Kalinovsky
5343e5b2cf fix bug in entity_data dict
Some checks failed
Build Docs / changes (push) Failing after 10s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-04-26 17:12:48 +07:00
Alexander Kalinovsky
e17629c607 fix BotContext in entity picker
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-04-25 19:33:26 +07:00
Alexander Kalinovsky
3e51fd4476 add user attr to delegates' context
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-04-25 16:44:15 +07:00
Alexander Kalinovsky
f6c5eb875b fix default_lifespan
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-04-23 21:33:07 +07:00
Alexander Kalinovsky
a134194852 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
2025-04-22 20:30:33 +07:00
Alexander Kalinovsky
a3357a2924 format fix
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-04-14 23:17:58 +07:00
Alexander Kalinovsky
b995abfc1e separate bot init and webhook setting
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-04-14 20:12:14 +07:00
Alexander Kalinovsky
0e6225e82f minor fixes
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-04-13 00:48:55 +07:00
Alexander Kalinovsky
43288698e9 fix bot_init
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-04-12 17:20:27 +07:00
Alexander Kalinovsky
8421d05826 fix bot config
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-04-12 17:13:17 +07:00
Alexander Kalinovsky
c81fb57c1a move webhook auth key to bot config
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-04-12 17:07:39 +07:00
Alexander Kalinovsky
783ecac91a remove pyngrok, removed unnecessary bot.close(), bot.logout() etc. calls
All checks were successful
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Successful in 1m1s
Build Docs / deploy-docs (push) Successful in 5s
2025-04-12 15:45:40 +07:00
Alexander Kalinovsky
dc006c70fd add form rendering call from app object
All checks were successful
Build Docs / changes (push) Successful in 7s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-04-08 01:20:45 +07:00
Alexander Kalinovsky
e10d7ff7bf enhanced strings and elements visibility delegates
All checks were successful
Build Docs / changes (push) Successful in 27s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-04-01 00:19:51 +07:00
Alexander Kalinovsky
6a7355996c fix config
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-27 20:54:22 +07:00
Alexander Kalinovsky
f417c7741c upd readme
All checks were successful
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Successful in 40s
Build Docs / deploy-docs (push) Successful in 3s
2025-03-27 16:26:43 +07:00
Alexander Kalinovsky
4364c2c175 rename project
All checks were successful
Build Docs / changes (push) Successful in 6s
Build Docs / build-docs (push) Successful in 1m5s
Build Docs / deploy-docs (push) Successful in 5s
2025-03-27 15:48:55 +07:00
Alexander Kalinovsky
5db09d0fd1 rename project 2025-03-27 15:48:00 +07:00
Alexander Kalinovsky
732c86b6fb fix permission checks for command forms
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-21 18:46:22 +07:00
Alexander Kalinovsky
66a6de8b20 minor updates
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-20 18:30:18 +07:00
Alexander Kalinovsky
8d99e6f5d5 undo last
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-20 02:15:11 +07:00
Alexander Kalinovsky
d7671c8d26 retry register webhook
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-20 02:00:54 +07:00
Alexander Kalinovsky
ffe2c95056 minor fixes
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-20 01:53:04 +07:00
Alexander Kalinovsky
6965c03fec minor fix
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-20 00:00:04 +07:00
Alexander Kalinovsky
5e2b8e51f6 app state fix
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-19 15:09:12 +07:00
86 changed files with 5101 additions and 3411 deletions

View File

@@ -1,11 +1,11 @@
<p align="center">
<a href="https://qbot.botforge.biz"><img src="https://qbot.botforge.biz/img/qbot.svg" alt="QBot"></a>
<a href="https://quickbot.botforge.biz"><img src="https://quickbot.botforge.biz/img/qbot.svg" alt="QuickBot"></a>
</p>
<p align="center">
<em>Telegram Bots Rapid Application Development (RAD) Framework.</em>
</p>
**QBot** is a library for fast development of Telegram bots and mini-apps following the **RAD (Rapid Application Development)** principle in a **declarative style**.
**QuickBot** is a library for fast development of Telegram bots and mini-apps following the **RAD (Rapid Application Development)** principle in a **declarative style**.
## Key Features
@@ -16,13 +16,13 @@
- **Context Preservation** Store navigation stacks and user interaction states in the database.
- **Internationalization Support** Localizable UI and string fields for multilingual bots.
**QBot** powered by **[FastAPI](https://fastapi.tiangolo.com)**, **[SQLModel](https://sqlmodel.tiangolo.com)** & **[aiogram](https://aiogram.dev)** Leverage the full capabilities of these frameworks for high performance and flexibility.
**QuickBot** powered by **[FastAPI](https://fastapi.tiangolo.com)**, **[SQLModel](https://sqlmodel.tiangolo.com)** & **[aiogram](https://aiogram.dev)** Leverage the full capabilities of these frameworks for high performance and flexibility.
## Benefits
- **Faster Development** Automate repetitive tasks and build bots in record time.
- **Highly Modular** Easily extend and customize functionality.
- **Less Code Duplication** Focus on core features while QBot handles the rest.
- **Less Code Duplication** Focus on core features while QuickBot handles the rest.
- **Enterprise-Grade Structure** Scalable, maintainable, and optimized for real-world usage.
## Example
@@ -38,12 +38,12 @@ class AppEntity(BotEntity):
)
name: str # entity field with default sqlmodel's FieldInfo descriptor
# and default qbot's field descriptor
# and default quickbot's field descriptor
description: str | None = Field( # field with sqlmodel's descriptor
sa_type = String, index = True) # and default qbot's descriptor
sa_type = String, index = True) # and default quickbot's descriptor
age: int = EntityField( # field with qbot's descriptor
age: int = EntityField( # field with quickbot's descriptor
caption = "Age",
)
@@ -69,7 +69,7 @@ class AppEntity(BotEntity):
)
app = QBotApp() # bot application based on FastAPI application
app = QuickBot() # bot application based on FastAPI application
# providing Telegram API webhook handler
@app.command( # decorator for bot commands definition
@@ -92,7 +92,7 @@ async def menu(context: CommandCallbackContext):
```
## Result
<iframe width="100%" height="691" src="https://www.youtube.com/embed/ptTnoppkYfM" title="QBot Framework The Open-Source RAD Tool for Telegram Bots" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<iframe width="100%" height="691" src="https://www.youtube.com/embed/ptTnoppkYfM" title="QuickBot Framework The Open-Source RAD Tool for Telegram Bots" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
Here you can see the result - [YouTube Video with Bot](https://www.youtube.com/shorts/ptTnoppkYfM)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -3,13 +3,13 @@
</style>
<p align="center">
<a href="https://qbot.botforge.biz"><img src="img/qbot.svg" alt="QBot"></a>
<a href="https://quickbot.botforge.biz"><img src="img/qbot.svg" alt="QuickBot"></a>
</p>
<p align="center">
<em>Telegram Bots Rapid Application Development (RAD) Framework.</em>
</p>
**QBot** is a library for fast development of Telegram bots and mini-apps following the **RAD (Rapid Application Development)** principle in a **declarative style**.
**QuickBot** is a library for fast development of Telegram bots and mini-apps following the **RAD (Rapid Application Development)** principle in a **declarative style**.
## Key Features
@@ -20,13 +20,13 @@
- **Context Preservation** Store navigation stacks and user interaction states in the database.
- **Internationalization Support** Localizable UI and string fields for multilingual bots.
**QBot** powered by **[FastAPI](https://fastapi.tiangolo.com)**, **[SQLModel](https://sqlmodel.tiangolo.com)** & **[aiogram](https://aiogram.dev)** Leverage the full capabilities of these frameworks for high performance and flexibility.
**QuickBot** powered by **[FastAPI](https://fastapi.tiangolo.com)**, **[SQLModel](https://sqlmodel.tiangolo.com)** & **[aiogram](https://aiogram.dev)** Leverage the full capabilities of these frameworks for high performance and flexibility.
## Benefits
- **Faster Development** Automate repetitive tasks and build bots in record time.
- **Highly Modular** Easily extend and customize functionality.
- **Less Code Duplication** Focus on core features while QBot handles the rest.
- **Less Code Duplication** Focus on core features while QuickBot handles the rest.
- **Enterprise-Grade Structure** Scalable, maintainable, and optimized for real-world usage.
## Example
@@ -42,12 +42,12 @@ class AppEntity(BotEntity):
)
name: str # entity field with default sqlmodel's FieldInfo descriptor
# and default qbot's field descriptor
# and default quickbot's field descriptor
description: str | None = Field( # field with sqlmodel's descriptor
sa_type = String, index = True) # and default qbot's descriptor
sa_type = String, index = True) # and default quickbot's descriptor
age: int = EntityField( # field with qbot's descriptor
age: int = EntityField( # field with quickbot's descriptor
caption = "Age",
)
@@ -66,14 +66,14 @@ class AppEntity(BotEntity):
back_populates="entities",
sa_relationship_kwargs={
"lazy": "selectin",
"foreign_keys": "Entity.user_id",
"foreign_keys": "AppEntity.user_id",
}
),
caption="User",
)
app = QBotApp() # bot application based on FastAPI application
app = QuickBot() # bot application based on FastAPI application
# providing Telegram API webhook handler
@app.command( # decorator for bot commands definition
@@ -96,6 +96,4 @@ async def menu(context: CommandCallbackContext):
```
## Result
<iframe width="100%" height="691" src="https://www.youtube.com/embed/ptTnoppkYfM" title="QBot Framework The Open-Source RAD Tool for Telegram Bots" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
forced_change_
<iframe width="100%" height="691" src="https://www.youtube.com/embed/ptTnoppkYfM" title="QuickBot Framework The Open-Source RAD Tool for Telegram Bots" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

View File

@@ -1,5 +1,5 @@
site_name: QBot Framework
site_url: https://qbot.botforge.biz
site_name: QuickBot Framework
site_url: https://quickbot.botforge.biz
theme:
name: material
palette:
@@ -10,5 +10,5 @@ theme:
code: 'Roboto Mono'
logo: 'img/qbot_1_1.svg'
favicon: 'img/qbot_1_1.svg'
repo_name: botforge/qbot
repo_url: https://git.botforge.biz/botforge/qbot
repo_name: botforge/quickbot
repo_url: https://git.botforge.biz/botforge/quickbot

View File

@@ -1,9 +1,13 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "quickbot"
version = "0.1.0"
description = "QBot - Rapid Application Development Framework for Telegram Bots"
version = "0.1.1"
description = "quickbot - Rapid Application Development Framework for Telegram Bots"
readme = "README.md"
requires-python = ">=3.12"
requires-python = ">=3.13"
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
@@ -15,15 +19,25 @@ authors = [
license = { file = "LICENSE" }
dependencies = [
"aiogram>=3.17.0",
"babel>=2.17.0",
"aiogram>=3.22.0",
"asyncpg>=0.30.0",
"fastapi[standard]>=0.115.8",
"greenlet>=3.1.1",
"mkdocs-material>=9.6.5",
"pydantic-settings>=2.7.1",
"pyngrok>=7.2.3",
"pytest>=8.3.4",
"ruff>=0.9.6",
"sqlmodel>=0.0.22",
"sqlmodel>=0.0.24",
"ujson>=5.10.0",
]
[project.optional-dependencies]
dev = [
"build>=1.2.1",
"twine>=5.0.0",
"pytest>=8.0.0",
"ruff>=0.5.0",
]
i18n = [
"aiogram[i18n]>=3.22.0",
]
[tool.setuptools.packages.find]
where = ["src"]

View File

@@ -1,22 +0,0 @@
from .main import QBotApp as QBotApp, Config as Config
from .router import Router as Router
from .model.bot_entity import BotEntity as BotEntity
from .model.bot_enum import BotEnum as BotEnum, EnumMember as EnumMember
from .bot.handlers.context import (
ContextData as ContextData,
CallbackCommand as CallbackCommand,
CommandContext as CommandContext,
)
from .model.descriptors import (
Entity as Entity,
EntityField as EntityField,
EntityForm as EntityForm,
EntityList as EntityList,
Filter as Filter,
EntityPermission as EntityPermission,
CommandCallbackContext as CommandCallbackContext,
EntityEventContext as EntityEventContext,
CommandButton as CommandButton,
FieldEditButton as FieldEditButton,
InlineButton as InlineButton,
)

View File

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

View File

@@ -1,166 +0,0 @@
from typing import TYPE_CHECKING
from aiogram import Router, F
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery
from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....model import EntityPermission
from ....model.settings import Settings
from ....model.user import UserBase
from ....utils.main import (
check_entity_permission,
get_field_descriptor,
)
from ....utils.serialization import deserialize, serialize
from ..context import ContextData, CallbackCommand, CommandContext
from ....auth import authorize_command
from ....utils.navigation import (
get_navigation_context,
save_navigation_context,
)
from ..forms.entity_form import entity_item
from .common import show_editor
from ..menu.parameters import parameters_menu
from .string import router as string_editor_router
from .date import router as date_picker_router
from .boolean import router as bool_editor_router
from .entity import router as entity_picker_router
if TYPE_CHECKING:
from ....main import QBotApp
logger = getLogger(__name__)
router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.FIELD_EDITOR))
async def field_editor_callback(query: CallbackQuery, **kwargs):
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
kwargs["state_data"] = state_data
await field_editor(message=query, **kwargs)
async def field_editor(message: Message | CallbackQuery, **kwargs):
callback_data: ContextData = kwargs.get("callback_data", None)
db_session: AsyncSession = kwargs["db_session"]
user: UserBase = kwargs["user"]
app: "QBotApp" = kwargs["app"]
# state: FSMContext = kwargs["state"]
state_data: dict = kwargs["state_data"]
entity_data = state_data.get("entity_data")
for key in ["current_value", "value", "locale_index"]:
if key in state_data:
state_data.pop(key)
kwargs["state_data"] = state_data
entity_descriptor = None
if callback_data.context == CommandContext.SETTING_EDIT:
field_descriptor = get_field_descriptor(app, callback_data)
if field_descriptor.type_ is bool:
if await authorize_command(user=user, callback_data=callback_data):
await Settings.set_param(
field_descriptor, not await Settings.get(field_descriptor)
)
else:
return await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
)
stack, context = get_navigation_context(state_data=state_data)
return await parameters_menu(
message=message, navigation_stack=stack, **kwargs
)
current_value = await Settings.get(field_descriptor, all_locales=True)
else:
field_descriptor = get_field_descriptor(app, callback_data)
entity_descriptor = field_descriptor.entity_descriptor
current_value = None
if (
field_descriptor.type_base is bool
and callback_data.context == CommandContext.ENTITY_FIELD_EDIT
):
entity = await entity_descriptor.type_.get(
session=db_session, id=int(callback_data.entity_id)
)
if check_entity_permission(
entity=entity, user=user, permission=EntityPermission.UPDATE
):
current_value: bool = (
getattr(entity, field_descriptor.field_name) or False
)
setattr(entity, field_descriptor.field_name, not current_value)
await db_session.commit()
stack, context = get_navigation_context(state_data=state_data)
kwargs.update({"callback_data": context})
return await entity_item(
query=message, navigation_stack=stack, **kwargs
)
if not entity_data and callback_data.context in [
CommandContext.ENTITY_EDIT,
CommandContext.ENTITY_FIELD_EDIT,
]:
entity = await entity_descriptor.type_.get(
session=kwargs["db_session"], id=int(callback_data.entity_id)
)
if check_entity_permission(
entity=entity, user=user, permission=EntityPermission.READ
):
if entity:
form_name = (
callback_data.form_params.split("&")[0]
if callback_data.form_params
else "default"
)
form = entity_descriptor.forms.get(
form_name, entity_descriptor.default_form
)
entity_data = {
key: serialize(
getattr(entity, key),
entity_descriptor.fields_descriptors[key],
)
for key in (
form.edit_field_sequence
if callback_data.context == CommandContext.ENTITY_EDIT
else [callback_data.field_name]
)
}
state_data.update({"entity_data": entity_data})
if entity_data:
current_value = await deserialize(
session=db_session,
type_=field_descriptor.type_,
value=entity_data.get(callback_data.field_name),
)
kwargs.update({"field_descriptor": field_descriptor})
save_navigation_context(state_data=state_data, callback_data=callback_data)
await show_editor(message=message, current_value=current_value, **kwargs)
router.include_routers(
string_editor_router,
date_picker_router,
bool_editor_router,
entity_picker_router,
)

View File

@@ -1,207 +0,0 @@
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
from aiogram.utils.i18n import I18n
from fastapi import FastAPI
from fastapi.applications import Lifespan, AppType
from secrets import token_hex
from logging import getLogger
from .config import Config
from .fsm.db_storage import DbStorage
from .middleware.telegram import AuthMiddleware, I18nMiddleware
from .model.user import UserBase
from .model.entity_metadata import EntityMetadata
from .model.descriptors import BotCommand
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()
await app.bot_init()
logger.info("qbot app started")
if app.lifespan:
async with app.lifespan(app) as state:
yield state
else:
yield
logger.info("stopping qbot app")
if app.lifespan_bot_init:
await app.bot_close()
if app.config.USE_NGROK:
app.ngrok_stop()
logger.info("qbot app stopped")
class QBotApp[UserType: UserBase](FastAPI):
"""
Main class for the QBot application
"""
def __init__(
self,
user_class: UserType = None,
config: Config | None = None,
bot_start: Callable[
[
Callable[[Message, Any], tuple[UserType, bool]],
Message,
Any,
],
None,
] = None,
lifespan: Lifespan[AppType] | None = None,
lifespan_bot_init: bool = True,
allowed_updates: list[str] | None = None,
*args,
**kwargs,
):
if config is None:
config = Config()
if user_class is None:
from .model.default_user import DefaultUser
user_class = DefaultUser
self.allowed_updates = allowed_updates or ["message", "callback_query"]
self.user_class = user_class
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"),
)
dp = Dispatcher(storage=DbStorage())
i18n = I18n(path="locales", default_locale="en", domain="messages")
i18n_middleware = I18nMiddleware(user_class=user_class, i18n=i18n)
i18n_middleware.setup(dp)
dp.callback_query.middleware(CallbackAnswerMiddleware())
from .bot.handlers.start import router as start_router
dp.include_router(start_router)
from .bot.handlers.menu.main import router as main_menu_router
auth = AuthMiddleware(user_class=user_class)
main_menu_router.message.middleware.register(auth)
main_menu_router.callback_query.middleware.register(auth)
dp.include_router(main_menu_router)
self.dp = dp
self.bot_auth_token = token_hex(128)
self.start_handler = bot_start
self.bot_commands = dict[str, BotCommand]()
self.lifespan_bot_init = lifespan_bot_init
super().__init__(lifespan=default_lifespan, *args, **kwargs)
from .api_route.telegram import router as telegram_router
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
from pyngrok.conf import PyngrokConfig
except ImportError:
logger.error("pyngrok is not installed")
raise
tunnel = ngrok.connect(
self.config.API_PORT,
pyngrok_config=PyngrokConfig(auth_token=self.config.NGROK_AUTH_TOKEN),
)
self.config.NGROK_URL = tunnel.public_url
def ngrok_stop(self):
try:
from pyngrok import ngrok
except ImportError:
logger.error("pyngrok is not installed")
raise
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():
if command.show_in_bot_commands:
if isinstance(command.caption, str) or command.caption is None:
if "default" not in commands_captions:
commands_captions["default"] = []
commands_captions["default"].append(
(command_name, command.caption or command_name)
)
else:
for locale, description in command.caption.items():
if locale not in commands_captions:
commands_captions[locale] = []
commands_captions[locale].append((command_name, description))
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(
[
AiogramBotCommand(command=command[0], description=command[1])
for command in commands
],
language_code=None if locale == "default" else locale,
)
async def bot_close(self):
await self.bot.delete_webhook()
await self.bot.log_out()
await self.bot.close()

View File

@@ -1,442 +0,0 @@
from types import NoneType, UnionType
from typing import (
Any,
ClassVar,
ForwardRef,
Optional,
Self,
Union,
get_args,
get_origin,
TYPE_CHECKING,
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
from sqlmodel.sql.expression import SelectOfScalar
from sqlmodel.main import SQLModelMetaclass, RelationshipInfo
from .descriptors import EntityDescriptor, EntityField, FieldDescriptor, Filter
from .entity_metadata import EntityMetadata
from . import session_dep
if TYPE_CHECKING:
from .user import UserBase
@dataclass_transform(
kw_only_default=True,
field_specifiers=(Field, FieldInfo, EntityField, FieldDescriptor),
)
class BotEntityMetaclass(SQLModelMetaclass):
_future_references = {}
def __new__(mcs, name, bases, namespace, **kwargs):
bot_fields_descriptors = {}
if bases:
bot_entity_descriptor = bases[0].__dict__.get("bot_entity_descriptor")
bot_fields_descriptors = (
{
key: FieldDescriptor(**value.__dict__.copy())
for key, value in bot_entity_descriptor.fields_descriptors.items()
}
if bot_entity_descriptor
else {}
)
if "__annotations__" in namespace:
for annotation in namespace["__annotations__"]:
if annotation in ["bot_entity_descriptor", "entity_metadata"]:
continue
attribute_value = namespace.get(annotation)
if isinstance(attribute_value, RelationshipInfo):
continue
descriptor_kwargs = {}
descriptor_name = annotation
if attribute_value:
if isinstance(attribute_value, EntityField):
descriptor_kwargs = attribute_value.__dict__.copy()
sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None) # 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
else:
namespace.pop(annotation)
descriptor_name = descriptor_kwargs.pop("name") or annotation
type_ = namespace["__annotations__"][annotation]
type_origin = get_origin(type_)
field_descriptor = FieldDescriptor(
name=descriptor_name,
field_name=annotation,
type_=type_,
type_base=type_,
**descriptor_kwargs,
)
is_list = False
is_optional = False
if type_origin is list:
field_descriptor.is_list = is_list = True
field_descriptor.type_base = type_ = get_args(type_)[0]
if type_origin is Union:
args = get_args(type_)
if isinstance(args[0], ForwardRef):
field_descriptor.is_optional = is_optional = True
field_descriptor.type_base = type_ = args[0].__forward_arg__
elif args[1] is NoneType:
field_descriptor.is_optional = is_optional = True
field_descriptor.type_base = type_ = args[0]
if type_origin is UnionType and get_args(type_)[1] is NoneType:
field_descriptor.is_optional = is_optional = True
field_descriptor.type_base = type_ = get_args(type_)[0]
if isinstance(type_, str):
type_not_found = True
for (
entity_descriptor
) in EntityMetadata().entity_descriptors.values():
if type_ == entity_descriptor.class_name:
field_descriptor.type_base = entity_descriptor.type_
field_descriptor.type_ = (
list[entity_descriptor.type_]
if is_list
else (
Optional[entity_descriptor.type_]
if type_origin == Union and is_optional
else (
entity_descriptor.type_ | None
if (type_origin == UnionType and is_optional)
else entity_descriptor.type_
)
)
)
type_not_found = False
break
if type_not_found:
if type_ in mcs._future_references:
mcs._future_references[type_].append(field_descriptor)
else:
mcs._future_references[type_] = [field_descriptor]
bot_fields_descriptors[descriptor_name] = field_descriptor
descriptor_name = name
if "bot_entity_descriptor" in namespace:
entity_descriptor = namespace.pop("bot_entity_descriptor")
descriptor_kwargs: dict = entity_descriptor.__dict__.copy()
descriptor_name = descriptor_kwargs.pop("name", None)
descriptor_name = descriptor_name or name.lower()
namespace["bot_entity_descriptor"] = EntityDescriptor(
name=descriptor_name,
class_name=name,
type_=name,
fields_descriptors=bot_fields_descriptors,
**descriptor_kwargs,
)
else:
descriptor_name = name.lower()
namespace["bot_entity_descriptor"] = EntityDescriptor(
name=descriptor_name,
class_name=name,
type_=name,
fields_descriptors=bot_fields_descriptors,
)
for field_descriptor in bot_fields_descriptors.values():
field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"]
if "table" not in kwargs:
kwargs["table"] = True
if kwargs["table"]:
entity_metadata = EntityMetadata()
entity_metadata.entity_descriptors[descriptor_name] = namespace[
"bot_entity_descriptor"
]
if "__annotations__" in namespace:
namespace["__annotations__"]["entity_metadata"] = ClassVar[
EntityMetadata
]
else:
namespace["__annotations__"] = {
"entity_metadata": ClassVar[EntityMetadata]
}
namespace["entity_metadata"] = entity_metadata
type_ = super().__new__(mcs, name, bases, namespace, **kwargs)
if name in mcs._future_references:
for field_descriptor in mcs._future_references[name]:
type_origin = get_origin(field_descriptor.type_)
field_descriptor.type_base = type_
field_descriptor.type_ = (
list[type_]
if type_origin is list
else (
Optional[type_]
if type_origin == Union
and isinstance(get_args(field_descriptor.type_)[0], ForwardRef)
else type_ | None
if type_origin == UnionType
else type_
)
)
setattr(namespace["bot_entity_descriptor"], "type_", type_)
return type_
class BotEntity[CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel](
SQLModel, metaclass=BotEntityMetaclass, table=False
):
bot_entity_descriptor: ClassVar[EntityDescriptor]
entity_metadata: ClassVar[EntityMetadata]
id: int = EntityField(
sm_descriptor=Field(primary_key=True, sa_type=BigInteger), is_visible=False
)
@classmethod
@session_dep
async def get(cls, *, session: AsyncSession | None = None, id: int):
return await session.get(cls, id, populate_existing=True)
@classmethod
def _static_filter_condition(
cls, select_statement: SelectOfScalar[Self], static_filter: list[Filter]
):
for sfilt in static_filter:
column = getattr(cls, sfilt.field_name)
if sfilt.operator == "==":
condition = column.__eq__(sfilt.value)
elif sfilt.operator == "!=":
condition = column.__ne__(sfilt.value)
elif sfilt.operator == "<":
condition = column.__lt__(sfilt.value)
elif sfilt.operator == "<=":
condition = column.__le__(sfilt.value)
elif sfilt.operator == ">":
condition = column.__gt__(sfilt.value)
elif sfilt.operator == ">=":
condition = column.__ge__(sfilt.value)
elif sfilt.operator == "ilike":
condition = col(column).ilike(f"%{sfilt.value}%")
elif sfilt.operator == "like":
condition = col(column).like(f"%{sfilt.value}%")
elif sfilt.operator == "in":
condition = col(column).in_(sfilt.value)
elif sfilt.operator == "not in":
condition = col(column).notin_(sfilt.value)
elif sfilt.operator == "is none":
condition = col(column).is_(None)
elif sfilt.operator == "is not none":
condition = col(column).isnot(None)
elif sfilt.operator == "contains":
condition = sfilt.value == col(column).any_()
else:
condition = None
if condition is not None:
select_statement = select_statement.where(condition)
return select_statement
@classmethod
def _filter_condition(
cls,
select_statement: SelectOfScalar[Self],
filter: str,
filter_fields: list[str],
):
condition = None
for field in filter_fields:
if condition is not None:
condition = condition | (column(field).ilike(f"%{filter}%"))
else:
condition = column(field).ilike(f"%{filter}%")
return select_statement.where(condition)
@classmethod
@session_dep
async def get_count(
cls,
*,
session: AsyncSession | None = None,
static_filter: list[Filter] | Any = None,
filter: str = None,
filter_fields: list[str] = None,
ext_filter: Any = None,
user: "UserBase" = None,
) -> int:
select_statement = select(func.count()).select_from(cls)
if static_filter:
if isinstance(static_filter, list):
select_statement = cls._static_filter_condition(
select_statement, static_filter
)
else:
select_statement = select_statement.where(static_filter)
if filter and filter_fields:
select_statement = cls._filter_condition(
select_statement, filter, filter_fields
)
if ext_filter:
select_statement = select_statement.where(ext_filter)
if user:
select_statement = cls._ownership_condition(select_statement, user)
return await session.scalar(select_statement)
@classmethod
@session_dep
async def get_multi(
cls,
*,
session: AsyncSession | None = None,
order_by=None,
static_filter: list[Filter] | Any = None,
filter: str = None,
filter_fields: list[str] = None,
ext_filter: Any = None,
user: "UserBase" = None,
skip: int = 0,
limit: int = None,
):
select_statement = select(cls).offset(skip)
if limit:
select_statement = select_statement.limit(limit)
if static_filter is not None:
if isinstance(static_filter, list):
select_statement = cls._static_filter_condition(
select_statement, static_filter
)
else:
select_statement = select_statement.where(static_filter)
if filter and filter_fields:
select_statement = cls._filter_condition(
select_statement, filter, filter_fields
)
if ext_filter is not None:
select_statement = select_statement.where(ext_filter)
if user:
select_statement = cls._ownership_condition(select_statement, user)
if order_by is not None:
select_statement = select_statement.order_by(order_by)
return (await session.exec(select_statement)).all()
@classmethod
def _ownership_condition(
cls, select_statement: SelectOfScalar[Self], user: "UserBase"
):
if cls.bot_entity_descriptor.ownership_fields:
condition = None
for role in user.roles:
if role in cls.bot_entity_descriptor.ownership_fields:
owner_col = column(cls.bot_entity_descriptor.ownership_fields[role])
if condition is not None:
condition = condition | (owner_col == user.id)
else:
condition = owner_col == user.id
else:
condition = None
break
if condition is not None:
return select_statement.where(condition)
return select_statement
@classmethod
@session_dep
async def create(
cls,
*,
session: AsyncSession | None = None,
obj_in: CreateSchemaType,
commit: bool = False,
):
if isinstance(obj_in, cls):
obj = obj_in
else:
obj = cls(**obj_in.model_dump())
session.add(obj)
if commit:
await session.commit()
return obj
@classmethod
@session_dep
async def update(
cls,
*,
session: AsyncSession | None = None,
id: int,
obj_in: UpdateSchemaType,
commit: bool = False,
):
obj = await session.get(cls, id)
if obj:
obj_data = obj.model_dump()
update_data = obj_in.model_dump(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(obj, field, update_data[field])
session.add(obj)
if commit:
await session.commit()
return obj
return None
@classmethod
@session_dep
async def remove(
cls, *, session: AsyncSession | None = None, id: int, commit: bool = False
):
obj = await session.get(cls, id)
if obj:
await session.delete(obj)
if commit:
await session.commit()
return obj
return None

View File

@@ -1,223 +0,0 @@
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from aiogram.utils.i18n import I18n
from aiogram.utils.keyboard import InlineKeyboardBuilder
from typing import Any, Callable, TYPE_CHECKING, Literal, Union
from babel.support import LazyProxy
from dataclasses import dataclass, field
from sqlmodel.ext.asyncio.session import AsyncSession
from .role import RoleBase
from . import EntityPermission
from ..bot.handlers.context import ContextData
if TYPE_CHECKING:
from .bot_entity import BotEntity
from ..main import QBotApp
from .user import UserBase
EntityCaptionCallable = Callable[["EntityDescriptor"], str]
EntityItemCaptionCallable = Callable[["EntityDescriptor", Any], str]
EntityFieldCaptionCallable = Callable[["FieldDescriptor", Any, Any], str]
@dataclass
class FieldEditButton:
field_name: str
caption: str | LazyProxy | EntityFieldCaptionCallable | None = None
visibility: Callable[[Any], bool] | None = None
@dataclass
class CommandButton:
command: ContextData | Callable[[ContextData, Any], ContextData] | str
caption: str | LazyProxy | EntityItemCaptionCallable
visibility: Callable[[Any], bool] | None = None
@dataclass
class InlineButton:
inline_button: InlineKeyboardButton | Callable[[Any], InlineKeyboardButton]
visibility: Callable[[Any], bool] | None = None
@dataclass
class Filter:
field_name: str
operator: Literal[
"==",
"!=",
">",
"<",
">=",
"<=",
"in",
"not in",
"like",
"ilike",
"is none",
"is not none",
"contains",
]
value_type: Literal["const", "param"] = "const"
value: Any | None = None
param_index: int | None = None
@dataclass
class EntityList:
caption: str | LazyProxy | EntityCaptionCallable | None = None
item_repr: EntityItemCaptionCallable | None = None
show_add_new_button: bool = True
item_form: str | None = None
pagination: bool = True
static_filters: list[Filter] = None
filtering: bool = False
filtering_fields: list[str] = None
order_by: str | Any | None = None
@dataclass
class EntityForm:
item_repr: EntityItemCaptionCallable | None = None
edit_field_sequence: list[str] = None
form_buttons: list[list[FieldEditButton | CommandButton | InlineButton]] = None
show_edit_button: bool = True
show_delete_button: bool = True
@dataclass(kw_only=True)
class _BaseFieldDescriptor:
icon: str = None
caption: str | LazyProxy | EntityFieldCaptionCallable | None = None
description: str | LazyProxy | EntityFieldCaptionCallable | None = None
edit_prompt: str | LazyProxy | EntityFieldCaptionCallable | None = None
caption_value: EntityFieldCaptionCallable | None = None
is_visible: bool | None = None
localizable: bool = False
bool_false_value: str | LazyProxy = "no"
bool_true_value: str | LazyProxy = "yes"
ep_form: str | None = None
ep_parent_field: str | None = None
ep_child_field: str | None = None
dt_type: Literal["date", "datetime"] = "date"
default: Any = None
default_factory: Callable[[], Any] | None = None
@dataclass(kw_only=True)
class EntityField(_BaseFieldDescriptor):
name: str | None = None
sm_descriptor: Any = None
@dataclass(kw_only=True)
class Setting(_BaseFieldDescriptor):
name: str | None = None
@dataclass(kw_only=True)
class FormField(_BaseFieldDescriptor):
name: str | None = None
type_: type
@dataclass(kw_only=True)
class FieldDescriptor(_BaseFieldDescriptor):
name: str
field_name: str
type_: type
type_base: type = None
is_list: bool = False
is_optional: bool = False
entity_descriptor: "EntityDescriptor" = None
command: "BotCommand" = None
def __hash__(self):
return self.name.__hash__()
@dataclass(kw_only=True)
class _BaseEntityDescriptor:
icon: str = "📘"
full_name: str | LazyProxy | EntityCaptionCallable | None = None
full_name_plural: str | LazyProxy | EntityCaptionCallable | None = None
description: str | LazyProxy | EntityCaptionCallable | None = None
item_repr: EntityItemCaptionCallable | None = None
default_list: EntityList = field(default_factory=EntityList)
default_form: EntityForm = field(default_factory=EntityForm)
lists: dict[str, EntityList] = field(default_factory=dict[str, EntityList])
forms: dict[str, EntityForm] = field(default_factory=dict[str, EntityForm])
show_in_entities_menu: bool = True
ownership_fields: dict[RoleBase, str] = field(default_factory=dict[RoleBase, str])
permissions: dict[EntityPermission, list[RoleBase]] = field(
default_factory=lambda: {
EntityPermission.LIST: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.READ: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.CREATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.UPDATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.DELETE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.LIST_ALL: [RoleBase.SUPER_USER],
EntityPermission.READ_ALL: [RoleBase.SUPER_USER],
EntityPermission.CREATE_ALL: [RoleBase.SUPER_USER],
EntityPermission.UPDATE_ALL: [RoleBase.SUPER_USER],
EntityPermission.DELETE_ALL: [RoleBase.SUPER_USER],
}
)
on_created: Callable[["BotEntity", "EntityEventContext"], None] | None = None
on_deleted: Callable[["BotEntity", "EntityEventContext"], None] | None = None
on_updated: Callable[["BotEntity", "EntityEventContext"], None] | None = None
@dataclass(kw_only=True)
class Entity(_BaseEntityDescriptor):
name: str | None = None
@dataclass
class EntityDescriptor(_BaseEntityDescriptor):
name: str
class_name: str
type_: type["BotEntity"]
fields_descriptors: dict[str, FieldDescriptor]
@dataclass(kw_only=True)
class CommandCallbackContext[UT: UserBase]:
keyboard_builder: InlineKeyboardBuilder = field(
default_factory=InlineKeyboardBuilder
)
message_text: str | None = None
register_navigation: bool = True
message: Message | CallbackQuery
callback_data: ContextData
db_session: AsyncSession
user: UT
app: "QBotApp"
state_data: dict[str, Any]
state: FSMContext
form_data: dict[str, Any]
i18n: I18n
kwargs: dict[str, Any] = field(default_factory=dict)
@dataclass(kw_only=True)
class EntityEventContext:
db_session: AsyncSession
app: "QBotApp"
message: Message | CallbackQuery | None = None
@dataclass(kw_only=True)
class BotCommand:
name: str
caption: str | dict[str, str] | None = None
pre_check: Callable[[Union[Message, CallbackQuery], Any], bool] | None = None
show_in_bot_commands: bool = False
register_navigation: bool = True
clear_navigation: bool = False
clear_state: bool = True
param_form: dict[str, FieldDescriptor] | None = None
show_cancel_in_param_form: bool = True
show_back_in_param_form: bool = True
handler: Callable[[CommandCallbackContext], None]

View File

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

View File

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

View File

@@ -1,23 +0,0 @@
from sqlmodel import Field, ARRAY
from .bot_entity import BotEntity
from .bot_enum import EnumType
from .language import LanguageBase
from .role import RoleBase
from .settings import DbSettings as DbSettings
from .fsm_storage import FSMStorage as FSMStorage
from .view_setting import ViewSetting as ViewSetting
class UserBase(BotEntity, table=False):
__tablename__ = "user"
lang: LanguageBase = Field(sa_type=EnumType(LanguageBase), default=LanguageBase.EN)
is_active: bool = True
name: str
roles: list[RoleBase] = Field(
sa_type=ARRAY(EnumType(RoleBase)), default=[RoleBase.DEFAULT_USER]
)

View File

@@ -1,261 +0,0 @@
from babel.support import LazyProxy
from inspect import iscoroutinefunction, signature
from aiogram.types import Message, CallbackQuery
from aiogram.utils.i18n import I18n
from typing import Any, TYPE_CHECKING
import ujson as json
from ..model.bot_entity import BotEntity
from ..model.bot_enum import BotEnum
from ..model.settings import Settings
from ..model.descriptors import (
FieldDescriptor,
EntityDescriptor,
EntityItemCaptionCallable,
EntityFieldCaptionCallable,
EntityPermission,
EntityCaptionCallable,
)
from ..bot.handlers.context import ContextData, CommandContext
if TYPE_CHECKING:
from ..model.user import UserBase
from ..main import QBotApp
def get_user_permissions(
user: "UserBase", entity_descriptor: EntityDescriptor
) -> list[EntityPermission]:
permissions = list[EntityPermission]()
for permission, roles in entity_descriptor.permissions.items():
for role in roles:
if role in user.roles:
permissions.append(permission)
break
return permissions
def get_local_text(text: str, locale: str = None) -> str:
if not locale:
i18n = I18n.get_current(no_error=True)
if i18n:
locale = i18n.current_locale
else:
locale = "en"
try:
obj = json.loads(text) # @IgnoreException
except Exception:
return text
else:
return obj.get(locale, obj[list(obj.keys())[0]])
def check_entity_permission(
entity: BotEntity, user: "UserBase", permission: EntityPermission
) -> bool:
perm_mapping = {
EntityPermission.LIST: EntityPermission.LIST_ALL,
EntityPermission.READ: EntityPermission.READ_ALL,
EntityPermission.UPDATE: EntityPermission.UPDATE_ALL,
EntityPermission.CREATE: EntityPermission.CREATE_ALL,
EntityPermission.DELETE: EntityPermission.DELETE_ALL,
}
if permission not in perm_mapping:
raise ValueError(f"Invalid permission: {permission}")
entity_descriptor = entity.__class__.bot_entity_descriptor
permissions = get_user_permissions(user, entity_descriptor)
if perm_mapping[permission] in permissions:
return True
ownership_filds = entity_descriptor.ownership_fields
for role in user.roles:
if role in ownership_filds:
if getattr(entity, ownership_filds[role]) == user.id:
return True
else:
if permission in permissions:
return True
return False
def get_send_message(message: Message | CallbackQuery):
if isinstance(message, Message):
return message.answer
else:
return message.message.edit_text
def clear_state(state_data: dict, clear_nav: bool = False):
if clear_nav:
state_data.clear()
else:
stack = state_data.get("navigation_stack")
context = state_data.get("navigation_context")
state_data.clear()
if stack:
state_data["navigation_stack"] = stack
if context:
state_data["navigation_context"] = context
async def get_entity_item_repr(
entity: BotEntity, item_repr: EntityItemCaptionCallable | None = None
) -> str:
descr = entity.bot_entity_descriptor
if item_repr:
return item_repr(descr, entity)
return (
descr.item_repr(descr, entity)
if descr.item_repr
else f"{
await get_callable_str(descr.full_name, descr, entity)
if descr.full_name
else descr.name
}: {str(entity.id)}"
)
async def get_value_repr(
value: Any, field_descriptor: FieldDescriptor, locale: str | None = None
) -> str:
if value is None:
return ""
type_ = field_descriptor.type_base
if isinstance(value, bool):
return "【✔︎】" if value else "【 】"
elif field_descriptor.is_list:
if issubclass(type_, BotEntity):
return (
f"[{', '.join([await get_entity_item_repr(item) for item in value])}]"
)
elif issubclass(type_, BotEnum):
return f"[{', '.join(item.localized(locale) for item in value)}]"
elif type_ is str:
return f"[{', '.join([f'"{item}"' for item in value])}]"
else:
return f"[{', '.join([str(item) for item in value])}]"
elif issubclass(type_, BotEntity):
return await get_entity_item_repr(value)
elif issubclass(type_, BotEnum):
return value.localized(locale)
elif isinstance(value, str):
if field_descriptor and field_descriptor.localizable:
return get_local_text(text=value, locale=locale)
return value
elif isinstance(value, int):
return str(value)
elif isinstance(value, float):
return str(value)
else:
return str(value)
async def get_callable_str(
callable_str: (
str
| LazyProxy
| EntityCaptionCallable
| EntityItemCaptionCallable
| EntityFieldCaptionCallable
),
descriptor: FieldDescriptor | EntityDescriptor,
entity: Any = None,
value: Any = None,
) -> str:
if isinstance(callable_str, str):
return callable_str
elif isinstance(callable_str, LazyProxy):
return callable_str.value
elif callable(callable_str):
args = signature(callable_str).parameters
if iscoroutinefunction(callable_str):
if len(args) == 1:
return await callable_str(descriptor)
elif len(args) == 2:
return await callable_str(descriptor, entity)
elif len(args) == 3:
return await callable_str(descriptor, entity, value)
else:
if len(args) == 1:
return callable_str(descriptor)
elif len(args) == 2:
return callable_str(descriptor, entity)
elif len(args) == 3:
return callable_str(descriptor, entity, value)
def get_entity_descriptor(
app: "QBotApp", callback_data: ContextData
) -> EntityDescriptor:
if callback_data.entity_name:
return app.entity_metadata.entity_descriptors[callback_data.entity_name]
return None
def get_field_descriptor(
app: "QBotApp", callback_data: ContextData
) -> FieldDescriptor | None:
if callback_data.context == CommandContext.SETTING_EDIT:
return Settings.list_params()[callback_data.field_name]
elif callback_data.context == CommandContext.COMMAND_FORM:
command = app.bot_commands[callback_data.user_command.split("&")[0]]
if (
command
and command.param_form
and callback_data.field_name in command.param_form
):
return command.param_form[callback_data.field_name]
elif callback_data.context in [
CommandContext.ENTITY_CREATE,
CommandContext.ENTITY_EDIT,
CommandContext.ENTITY_FIELD_EDIT,
]:
entity_descriptor = get_entity_descriptor(app, callback_data)
if entity_descriptor:
return entity_descriptor.fields_descriptors.get(callback_data.field_name)
return None
def 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

51
src/quickbot/__init__.py Normal file
View File

@@ -0,0 +1,51 @@
from .main import QuickBot, Config
from .router import Router
from .model.bot_entity import BotEntity
from .model.bot_process import BotProcess
from .model import BotEnum, EnumMember
from .bot.handlers.context import (
ContextData,
CallbackCommand,
CommandContext,
)
from .model.descriptors import (
Entity,
EntityField,
EntityForm,
EntityList,
Filter,
EntityPermission,
CommandCallbackContext,
BotContext,
CommandButton,
FieldEditButton,
InlineButton,
FormField,
Process,
)
__all__ = [
"QuickBot",
"Config",
"Router",
"BotEntity",
"BotProcess",
"BotEnum",
"EnumMember",
"ContextData",
"CallbackCommand",
"CommandContext",
"Entity",
"EntityField",
"EntityForm",
"EntityList",
"Filter",
"EntityPermission",
"CommandCallbackContext",
"BotContext",
"CommandButton",
"FieldEditButton",
"InlineButton",
"FormField",
"Process",
]

View File

@@ -0,0 +1,37 @@
from typing import Annotated, TYPE_CHECKING
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError
from sqlmodel.ext.asyncio.session import AsyncSession
from quickbot.auth.jwt import decode_access_token
from quickbot.db import get_db
from quickbot.model.user import UserBase
if TYPE_CHECKING:
from quickbot import QuickBot
security_scheme = HTTPBearer(
scheme_name="bearerAuth",
bearerFormat="JWT",
)
async def get_current_user(
request: Request,
db_session: Annotated[AsyncSession, Depends(get_db)],
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
) -> UserBase:
try:
payload = decode_access_token(credentials.credentials)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid token")
app: QuickBot = request.app
user = await app.user_class.get(
session=db_session,
id=int(user_id),
)
return user
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")

View File

@@ -0,0 +1,49 @@
from fastapi import Depends, Request
from pydantic import BaseModel
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Annotated, TYPE_CHECKING
from ..db import get_db
from ..model.descriptors import EntityDescriptor
from .depends import get_current_user
if TYPE_CHECKING:
from ..main import QuickBot
from ..model.user import UserBase
class ListParams(BaseModel):
query: str = ""
order_by: str = ""
limit: int = 100
offset: int = 0
async def list_entity_items(
db_session: Annotated[AsyncSession, Depends(get_db)],
request: Request,
params: Annotated[ListParams, Depends()],
current_user=Depends(get_current_user),
):
entity_descriptor: EntityDescriptor = request.app.bot_metadata.entity_descriptors[
request.url.path.split("/")[-1]
]
entity_list = await entity_descriptor.crud.list_all(
db_session=db_session,
user=current_user,
)
return entity_list
async def get_me(
db_session: Annotated[AsyncSession, Depends(get_db)],
request: Request,
current_user: Annotated["UserBase", Depends(get_current_user)],
):
app: "QuickBot" = request.app
user = await app.user_class.bot_entity_descriptor.crud.get_by_id(
db_session=db_session,
user=current_user,
id=current_user.id,
)
return user

View File

@@ -0,0 +1,79 @@
from aiogram.types import Update
from fastapi import APIRouter, Request, Response, Depends, HTTPException, Body
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Annotated
from ..db import get_db
from ..main import QuickBot
from ..auth.telegram import check_telegram_auth
from ..auth.jwt import create_access_token
from logging import getLogger
logger = getLogger(__name__)
router = APIRouter()
@router.post("/webhook", name="telegram_webhook")
async def telegram_webhook(
db_session: Annotated[AsyncSession, Depends(get_db)],
request: Request,
):
logger.debug("Webhook request %s", await request.json())
app: QuickBot = request.app
if app.webhook_handler:
return await app.webhook_handler(app=app, request=request)
request_token = request.headers.get("X-Telegram-Bot-Api-Secret-Token")
if request_token != app.config.TELEGRAM_WEBHOOK_AUTH_KEY:
logger.warning("Unauthorized request %s", request)
return Response(status_code=403)
try:
update = Update(**await request.json())
except Exception:
logger.error("Invalid request", exc_info=True)
return Response(status_code=400)
try:
await app.dp.feed_webhook_update(
bot=app.bot,
update=update,
db_session=db_session,
app=app,
app_state=request.state,
)
except Exception:
logger.error("Error processing update", exc_info=True)
return Response(status_code=500)
return Response(status_code=200)
@router.post("/auth")
async def telegram_login(request: Request, data: dict = Body(...)):
if not check_telegram_auth(data, request.app.config.TELEGRAM_BOT_TOKEN):
raise HTTPException(status_code=401, detail="Invalid Telegram auth")
payload = {
"sub": str(data["id"]),
"first_name": data.get("first_name"),
"username": data.get("username"),
}
token = create_access_token(payload)
return {"access_token": token, "token_type": "bearer"}
# async def feed_bot_update(
# app: QBotApp,
# update: Update,
# app_state: State,
# ):
# async with async_session() as db_session:
# await app.dp.feed_webhook_update(
# bot=app.bot,
# update=update,
# db_session=db_session,
# app=app,
# app_state=app_state,
# )

20
src/quickbot/auth/jwt.py Normal file
View File

@@ -0,0 +1,20 @@
from datetime import datetime, timedelta
from jose import jwt
SECRET_KEY = "your_secret_key" # TODO: вынести в конфиг
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 1 неделя
def create_access_token(data: dict, expires_delta: timedelta = None):
to_encode = data.copy()
expire = datetime.utcnow() + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_access_token(token: str):
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])

View File

@@ -0,0 +1,15 @@
import hashlib
import hmac
def check_telegram_auth(data: dict, bot_token: str) -> bool:
auth_data = data.copy()
hash_ = auth_data.pop("hash", None)
if not hash_:
return False
data_check_string = "\n".join([f"{k}={v}" for k, v in sorted(auth_data.items())])
secret_key = hashlib.sha256(bot_token.encode()).digest()
hmac_hash = hmac.new(
secret_key, data_check_string.encode(), hashlib.sha256
).hexdigest()
return hmac_hash == hash_

View File

@@ -1,7 +1,7 @@
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ....model.descriptors import EntityDescriptor
from ....model.descriptors import BotContext, EntityDescriptor
from ....utils.main import get_callable_str
from ..context import ContextData, CallbackCommand
@@ -9,6 +9,7 @@ from ..context import ContextData, CallbackCommand
async def add_filter_controls(
keyboard_builder: InlineKeyboardBuilder,
entity_descriptor: EntityDescriptor,
context: BotContext,
filter: str = None,
filtering_fields: list[str] = None,
page: int = 1,
@@ -16,8 +17,9 @@ async def add_filter_controls(
caption = ", ".join(
[
await get_callable_str(
entity_descriptor.fields_descriptors[field_name].caption,
entity_descriptor,
callable_str=entity_descriptor.fields_descriptors[field_name].caption,
context=context,
descriptor=entity_descriptor.fields_descriptors[field_name],
)
if entity_descriptor.fields_descriptors[field_name].caption
else field_name

View File

@@ -20,7 +20,7 @@ from ..editors.entity import render_entity_picker
from .routing import route_callback
if TYPE_CHECKING:
from ....main import QBotApp
from ....main import QuickBot
router = Router()
@@ -42,7 +42,7 @@ async def view_filter_edit(query: CallbackQuery, **kwargs):
cmd = args[1]
db_session: AsyncSession = kwargs["db_session"]
app: "QBotApp" = kwargs["app"]
app: "QuickBot" = kwargs["app"]
user: UserBase = kwargs["user"]
entity_descriptor = get_entity_descriptor(app=app, callback_data=callback_data)
@@ -129,7 +129,7 @@ async def view_filter_edit_input(message: Message, **kwargs):
callback_data = ContextData.unpack(state_data["context_data"])
db_session: AsyncSession = kwargs["db_session"]
user: UserBase = kwargs["user"]
app: "QBotApp" = kwargs["app"]
app: "QuickBot" = kwargs["app"]
entity_descriptor = get_entity_descriptor(app=app, callback_data=callback_data)
filter = message.text
await ViewSetting.set_filter(

View File

@@ -13,7 +13,7 @@ def add_pagination_controls(
):
if total_pages > 1:
navigation_buttons = []
ContextData(**callback_data.model_dump()).__setattr__
# ContextData(**callback_data.model_dump()).__setattr__
if total_pages > 10:
navigation_buttons.append(
InlineKeyboardButton(

View File

@@ -1,8 +1,9 @@
from aiogram.types import Message, CallbackQuery
from aiogram.fsm.context import FSMContext
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from qbot.main import QBotApp
from quickbot.main import QuickBot
from ..context import CallbackCommand
@@ -12,23 +13,26 @@ from ....utils.navigation import (
pop_navigation_context,
)
import qbot.bot.handlers.menu.main as menu_main
import qbot.bot.handlers.menu.settings as menu_settings
import qbot.bot.handlers.menu.parameters as menu_parameters
import qbot.bot.handlers.menu.language as menu_language
import qbot.bot.handlers.menu.entities as menu_entities
import qbot.bot.handlers.forms.entity_list as form_list
import qbot.bot.handlers.forms.entity_form as form_item
import qbot.bot.handlers.editors.main as editor
async def route_callback(message: Message | CallbackQuery, back: bool = True, **kwargs):
import quickbot.bot.handlers.menu.main as menu_main
import quickbot.bot.handlers.menu.language as menu_language
import quickbot.bot.handlers.menu.settings as menu_settings
import quickbot.bot.handlers.menu.parameters as menu_parameters
import quickbot.bot.handlers.menu.entities as menu_entities
import quickbot.bot.handlers.forms.entity_list as form_list
import quickbot.bot.handlers.forms.entity_form as form_item
import quickbot.bot.handlers.editors.main as editor
import quickbot.bot.handlers.user_handlers.main as user_handler
state_data = kwargs["state_data"]
state: FSMContext = kwargs["state"]
stack, context = get_navigation_context(state_data)
if back:
context = pop_navigation_context(stack)
stack = save_navigation_context(callback_data=context, state_data=state_data)
kwargs.update({"callback_data": context, "navigation_stack": stack})
await state.set_data(state_data)
if context:
if context.command == CallbackCommand.MENU_ENTRY_MAIN:
await menu_main.main_menu(message, **kwargs)
@@ -47,12 +51,10 @@ async def route_callback(message: Message | CallbackQuery, back: bool = True, **
elif context.command == CallbackCommand.FIELD_EDITOR:
await editor.field_editor(message, **kwargs)
elif context.command == CallbackCommand.USER_COMMAND:
import qbot.bot.handlers.user_handlers.main as user_handler
app: "QBotApp" = kwargs["app"]
app: "QuickBot" = kwargs["app"]
cmd = app.bot_commands.get(context.user_command.split("&")[0])
await user_handler.cammand_handler(message=message, cmd=cmd, **kwargs)
await user_handler.command_handler(message=message, cmd=cmd, **kwargs)
else:
raise ValueError(f"Unknown command {context.command}")
else:

View File

@@ -21,6 +21,7 @@ class CallbackCommand(StrEnum):
ENTITY_PICKER_TOGGLE_ITEM = "et"
VIEW_FILTER_EDIT = "vf"
USER_COMMAND = "uc"
DELETE_MESSAGE = "dl"
class CommandContext(StrEnum):

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,21 @@ async def bool_editor(
state_data = kwargs["state_data"]
context = BotContext(
db_session=kwargs["db_session"],
app=kwargs["app"],
app_state=kwargs["app_state"],
user=user,
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

@@ -1,10 +1,15 @@
from aiogram.types import Message, CallbackQuery
from decimal import Decimal
from datetime import datetime, time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from quickbot.main import QuickBot
from quickbot.utils.serialization import deserialize
from ....model.bot_entity import BotEntity
from ....model.bot_enum import BotEnum
from ....model.descriptors import FieldDescriptor
from ....model.descriptors import BotContext, FieldDescriptor
from ....model.settings import Settings
from ....model.user import UserBase
from ....utils.main import get_callable_str, get_value_repr
@@ -21,38 +26,91 @@ async def show_editor(message: Message | CallbackQuery, **kwargs):
user: UserBase = kwargs["user"]
callback_data: ContextData = kwargs.get("callback_data", None)
state_data: dict = kwargs["state_data"]
db_session = kwargs["db_session"]
app: "QuickBot" = kwargs["app"]
value_type = field_descriptor.type_base
entity_data_dict: dict = state_data.get("entity_data")
entity_data = None
if callback_data.context == CommandContext.COMMAND_FORM:
cmd = app.bot_commands.get(callback_data.user_command.split("&")[0])
entity_data = (
{
key: await deserialize(
session=kwargs["db_session"],
type_=cmd.param_form[key].type_,
value=value,
)
for key, value in entity_data_dict.items()
}
if entity_data_dict and cmd.param_form
else None
)
elif callback_data.context == CommandContext.ENTITY_CREATE:
entity_data = (
{
key: await deserialize(
session=kwargs["db_session"],
type_=field_descriptor.entity_descriptor.fields_descriptors[
key
].type_,
value=value,
)
for key, value in entity_data_dict.items()
}
if entity_data_dict
else None
)
else:
entity_id = callback_data.entity_id
if entity_id:
entity_data = await field_descriptor.entity_descriptor.type_.get(
session=db_session, id=entity_id
)
context = BotContext(
db_session=db_session,
app=app,
app_state=kwargs["app_state"],
user=user,
message=message,
)
if field_descriptor.edit_prompt:
edit_prompt = await get_callable_str(
field_descriptor.edit_prompt,
field_descriptor,
callback_data
if callback_data.context == CommandContext.COMMAND_FORM
else None,
current_value,
callable_str=field_descriptor.edit_prompt,
context=context,
descriptor=field_descriptor,
entity=entity_data,
)
else:
if field_descriptor.caption:
caption_str = await get_callable_str(
field_descriptor.caption,
field_descriptor,
callback_data
if callback_data.context == CommandContext.COMMAND_FORM
else None,
current_value,
context=context,
descriptor=field_descriptor,
)
else:
caption_str = field_descriptor.name
if callback_data.context == CommandContext.ENTITY_EDIT:
db_session = kwargs["db_session"]
app = kwargs["app"]
edit_prompt = (
await Settings.get(
Settings.APP_STRINGS_FIELD_EDIT_PROMPT_TEMPLATE_P_NAME_VALUE
)
).format(
name=caption_str,
value=await get_value_repr(current_value, field_descriptor, user.lang),
value=await get_value_repr(
value=current_value,
field_descriptor=field_descriptor,
context=context,
locale=user.lang,
),
)
else:
edit_prompt = (
@@ -61,6 +119,7 @@ async def show_editor(message: Message | CallbackQuery, **kwargs):
)
).format(name=caption_str)
kwargs["entity_data"] = entity_data
kwargs["edit_prompt"] = edit_prompt
if value_type not in [int, float, Decimal, str]:

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
@@ -14,7 +14,7 @@ from ....utils.main import get_send_message, get_field_descriptor
from .wrapper import wrap_editor
if TYPE_CHECKING:
from ....main import QBotApp
from ....main import QuickBot
logger = getLogger(__name__)
@@ -23,7 +23,7 @@ router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.TIME_PICKER))
async def time_picker_callback(
query: CallbackQuery, callback_data: ContextData, app: "QBotApp", **kwargs
query: CallbackQuery, callback_data: ContextData, app: "QuickBot", **kwargs
):
if not callback_data.data:
return
@@ -158,6 +158,13 @@ async def time_picker(
)
state_data = kwargs["state_data"]
context = BotContext(
db_session=kwargs["db_session"],
app=kwargs["app"],
app_state=kwargs["app_state"],
user=user,
message=message,
)
await wrap_editor(
keyboard_builder=keyboard_builder,
@@ -165,6 +172,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 +280,21 @@ async def date_picker(
state_data = kwargs["state_data"]
context = BotContext(
db_session=kwargs["db_session"],
app=kwargs["app"],
app_state=kwargs["app_state"],
user=user,
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 +312,11 @@ async def date_picker(
async def date_picker_year(
query: CallbackQuery,
callback_data: ContextData,
app: "QBotApp",
state: FSMContext,
user: UserBase,
**kwargs,
):
app: "QuickBot" = kwargs["app"]
start_date = datetime.strptime(callback_data.data, "%Y-%m-%d %H-%M")
state_data = await state.get_data()
@@ -366,12 +383,21 @@ 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"],
user=user,
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 +406,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: "QuickBot" = kwargs["app"]
field_descriptor = get_field_descriptor(app, callback_data)
state: FSMContext = kwargs["state"]
state_data = await state.get_data()

View File

@@ -1,3 +1,4 @@
from inspect import iscoroutinefunction
from aiogram import Router, F
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
@@ -14,13 +15,14 @@ from ....model.bot_enum import BotEnum
from ....model.settings import Settings
from ....model.user import UserBase
from ....model.view_setting import ViewSetting
from ....model.descriptors import FieldDescriptor, Filter
from ....model.descriptors import BotContext, EntityList, FieldDescriptor
from ....model import EntityPermission
from ....utils.main import (
get_user_permissions,
get_send_message,
get_field_descriptor,
get_callable_str,
prepare_static_filter,
)
from ....utils.serialization import serialize, deserialize
from ..context import ContextData, CallbackCommand
@@ -29,7 +31,7 @@ from ..common.filtering import add_filter_controls
from .wrapper import wrap_editor
if TYPE_CHECKING:
from ....main import QBotApp
from ....main import QuickBot
logger = getLogger(__name__)
router = Router()
@@ -93,24 +95,78 @@ async def render_entity_picker(
page_size = await Settings.get(Settings.PAGE_SIZE)
form_list = None
context = BotContext(
db_session=db_session,
app=kwargs["app"],
app_state=kwargs["app_state"],
user=user,
message=message,
)
entity = None
if issubclass(type_, BotEnum):
items_count = len(type_.all_members)
total_pages = calc_total_pages(items_count, page_size)
page = min(page, total_pages)
enum_items = list(type_.all_members.values())[
page_size * (page - 1) : page_size * page
]
items = [
{
"text": f"{'' if not is_list else '【✔︎】 ' if item in (current_value or []) else '【 】 '}{item.localized(user.lang)}",
"value": item.value,
}
for item in enum_items
]
if isinstance(field_descriptor.options, list):
enum_items = field_descriptor.options[
page_size * (page - 1) : page_size * page
]
elif callable(field_descriptor.options):
if callback_data.entity_id:
entity = await field_descriptor.entity_descriptor.type_.get(
session=db_session, id=callback_data.entity_id
)
if iscoroutinefunction(field_descriptor.options):
enum_items = (await field_descriptor.options(entity, context)) or []
else:
enum_items = field_descriptor.options(entity, context) or []
enum_items = enum_items[page_size * (page - 1) : page_size * page]
else:
enum_items = list(type_.all_members.values())[
page_size * (page - 1) : page_size * page
]
items = []
for it_row in enum_items:
if not isinstance(it_row, list):
it_row = [it_row]
item_row = []
for item in it_row:
if isinstance(item, tuple):
item_value, item_text = item
else:
item_value = item
item_text = item.localized(user.lang)
item_row.append(
{
"text": f"{'' if not is_list else '[✓] ' if item_value in (current_value or []) else '[ ] '}{item_text}",
"value": item_value.value,
}
)
items.append(item_row)
elif issubclass(type_, BotEntity):
form_name = field_descriptor.ep_form or "default"
form_list = type_.bot_entity_descriptor.lists.get(
form_name, type_.bot_entity_descriptor.default_list
ep_form = "default"
ep_form_params = []
if field_descriptor.ep_form:
if callable(field_descriptor.ep_form):
if iscoroutinefunction(field_descriptor.ep_form):
ep_form = await field_descriptor.ep_form(context)
else:
ep_form = field_descriptor.ep_form(context)
else:
ep_form = field_descriptor.ep_form
ep_form_list = ep_form.split("&")
ep_form = ep_form_list[0]
ep_form_params = ep_form_list[1:] if len(ep_form_list) > 1 else []
form_list: EntityList = type_.bot_entity_descriptor.lists.get(
ep_form, type_.bot_entity_descriptor.default_list
)
permissions = get_user_permissions(user, type_.bot_entity_descriptor)
if form_list.filtering:
@@ -123,17 +179,31 @@ async def render_entity_picker(
entity_filter = None
list_all = EntityPermission.LIST_ALL in permissions
if list_all or EntityPermission.LIST in permissions:
if list_all or EntityPermission.LIST_RLS in permissions:
if (
field_descriptor.ep_parent_field
and field_descriptor.ep_parent_field
and field_descriptor.ep_child_field
and callback_data.entity_id
):
if callable(field_descriptor.ep_parent_field):
parent_field = field_descriptor.ep_parent_field(
field_descriptor.entity_descriptor.type_
).key
else:
parent_field = field_descriptor.ep_parent_field
if callable(field_descriptor.ep_child_field):
child_field = field_descriptor.ep_child_field(
field_descriptor.entity_descriptor.type_
).key
else:
child_field = field_descriptor.ep_child_field
entity = await field_descriptor.entity_descriptor.type_.get(
session=db_session, id=callback_data.entity_id
)
value = getattr(entity, field_descriptor.ep_parent_field)
ext_filter = column(field_descriptor.ep_child_field).__eq__(value)
value = getattr(entity, parent_field)
ext_filter = column(child_field).__eq__(value)
else:
ext_filter = None
@@ -141,19 +211,11 @@ async def render_entity_picker(
if form_list.pagination:
items_count = await type_.get_count(
session=db_session,
static_filter=(
[
Filter(
field_name=f.field_name,
operator=f.operator,
value_type="const",
value=f.value,
)
for f in form_list.static_filters
if f.value_type == "const"
]
if isinstance(form_list.static_filters, list)
else form_list.static_filters
static_filter=await prepare_static_filter(
db_session=db_session,
entity_descriptor=type_.bot_entity_descriptor,
static_filters=form_list.static_filters,
params=ep_form_params,
),
ext_filter=ext_filter,
filter=entity_filter,
@@ -170,19 +232,11 @@ async def render_entity_picker(
entity_items = await type_.get_multi(
session=db_session,
order_by=form_list.order_by,
static_filter=(
[
Filter(
field_name=f.field_name,
operator=f.operator,
value_type="const",
value=f.value,
)
for f in form_list.static_filters
if f.value_type == "const"
]
if isinstance(form_list.static_filters, list)
else form_list.static_filters
static_filter=await prepare_static_filter(
db_session=db_session,
entity_descriptor=type_.bot_entity_descriptor,
static_filters=form_list.static_filters,
params=ep_form_params,
),
ext_filter=ext_filter,
filter=entity_filter,
@@ -197,53 +251,60 @@ async def render_entity_picker(
entity_items = list[BotEntity]()
items = [
{
"text": f"{
''
if not is_list
else '【✔︎】 '
if item in (current_value or [])
else '【 】 '
}{
type_.bot_entity_descriptor.item_repr(
type_.bot_entity_descriptor, item
)
if type_.bot_entity_descriptor.item_repr
else await get_callable_str(
type_.bot_entity_descriptor.full_name,
type_.bot_entity_descriptor,
item,
)
if type_.bot_entity_descriptor.full_name
else f'{type_.bot_entity_descriptor.name}: {str(item.id)}'
}",
"value": str(item.id),
}
[
{
"text": f"{
''
if not is_list
else '[✓] '
if item in (current_value or [])
else '[ ] '
}{
await get_callable_str(
callable_str=type_.bot_entity_descriptor.item_repr,
context=context,
entity=item,
)
if type_.bot_entity_descriptor.item_repr
else await get_callable_str(
callable_str=type_.bot_entity_descriptor.full_name,
context=context,
descriptor=type_.bot_entity_descriptor,
)
if type_.bot_entity_descriptor.full_name
else f'{type_.bot_entity_descriptor.name}: {str(item.id)}'
}",
"value": str(item.id),
}
]
for item in entity_items
]
keyboard_builder = InlineKeyboardBuilder()
for item in items:
keyboard_builder.row(
InlineKeyboardButton(
text=item["text"],
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,
form_params=callback_data.form_params,
user_command=callback_data.user_command,
data=f"{page}&{item['value']}" if is_list else item["value"],
).pack(),
for item_row in items:
btn_row = []
for item in item_row:
btn_row.append(
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,
form_params=callback_data.form_params,
user_command=callback_data.user_command,
data=f"{page}&{item['value']}" if is_list else item["value"],
).pack(),
)
)
)
keyboard_builder.row(*btn_row)
if form_list and form_list.pagination:
add_pagination_controls(
@@ -262,6 +323,7 @@ async def render_entity_picker(
await add_filter_controls(
keyboard_builder=keyboard_builder,
entity_descriptor=type_.bot_entity_descriptor,
context=context,
filter=entity_filter,
filtering_fields=form_list.filtering_fields,
)
@@ -290,13 +352,20 @@ async def render_entity_picker(
callback_data=callback_data,
state_data=state_data,
user=user,
context=context,
entity=entity,
)
await state.set_data(state_data)
send_message = get_send_message(message)
await send_message(text=edit_prompt, reply_markup=keyboard_builder.as_markup())
if message:
send_message = get_send_message(message)
await send_message(text=edit_prompt, reply_markup=keyboard_builder.as_markup())
else:
app: "QuickBot" = kwargs["app"]
await app.bot.send_message(
chat_id=user.id, text=edit_prompt, reply_markup=keyboard_builder.as_markup()
)
@router.callback_query(
@@ -309,7 +378,7 @@ async def entity_picker_callback(
query: CallbackQuery,
callback_data: ContextData,
db_session: AsyncSession,
app: "QBotApp",
app: "QuickBot",
state: FSMContext,
**kwargs,
):

View File

@@ -0,0 +1,277 @@
from inspect import iscoroutinefunction
from typing import TYPE_CHECKING
from aiogram import Router, F
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery
from logging import getLogger
from sqlalchemy.orm.collections import InstrumentedList
from sqlmodel.ext.asyncio.session import AsyncSession
from quickbot.model.descriptors import BotContext, EntityForm
from ....model import EntityPermission
from ....model.settings import Settings
from ....model.user import UserBase
from ....model.permissions import check_entity_permission
from ....utils.main import (
build_field_sequence,
get_field_descriptor,
clear_state,
)
from ....utils.serialization import deserialize, serialize
from ..context import ContextData, CallbackCommand, CommandContext
from ....auth import authorize_command
from ....utils.navigation import (
get_navigation_context,
save_navigation_context,
)
from ..forms.entity_form import entity_item
from .common import show_editor
from ..menu.parameters import parameters_menu
from .string import router as string_editor_router
from .date import router as date_picker_router
from .boolean import router as bool_editor_router
from .entity import router as entity_picker_router
if TYPE_CHECKING:
from ....main import QuickBot
logger = getLogger(__name__)
router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.FIELD_EDITOR))
async def field_editor_callback(query: CallbackQuery, **kwargs):
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
kwargs["state_data"] = state_data
await field_editor(message=query, **kwargs)
async def field_editor(message: Message | CallbackQuery, **kwargs):
callback_data: ContextData = kwargs.get("callback_data", None)
db_session: AsyncSession = kwargs["db_session"]
user: UserBase = kwargs["user"]
app: "QuickBot" = kwargs["app"]
state: FSMContext = kwargs["state"]
state_data: dict = kwargs["state_data"]
entity_data = state_data.get("entity_data")
for key in ["current_value", "value", "locale_index"]:
if key in state_data:
state_data.pop(key)
kwargs["state_data"] = state_data
entity_descriptor = None
if callback_data.context == CommandContext.SETTING_EDIT:
field_descriptor = get_field_descriptor(app, callback_data)
if field_descriptor.type_ is bool:
if await authorize_command(user=user, callback_data=callback_data):
await Settings.set_param(
field_descriptor, not await Settings.get(field_descriptor)
)
else:
return await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
)
stack, context = get_navigation_context(state_data=state_data)
return await parameters_menu(
message=message, navigation_stack=stack, **kwargs
)
current_value = await Settings.get(field_descriptor, all_locales=True)
else:
field_descriptor = get_field_descriptor(app, callback_data)
entity_descriptor = field_descriptor.entity_descriptor
current_value = None
context = BotContext(
db_session=db_session,
app=app,
app_state=kwargs["app_state"],
user=user,
message=message,
)
if (
field_descriptor.type_base is bool
and callback_data.context == CommandContext.ENTITY_FIELD_EDIT
):
entity = await entity_descriptor.type_.get(
session=db_session, id=int(callback_data.entity_id)
)
if await check_entity_permission(
entity=entity, user=user, permission=EntityPermission.UPDATE_RLS
):
old_values = {}
for f in entity.bot_entity_descriptor.fields_descriptors.values():
value = getattr(entity, f.field_name)
if isinstance(value, InstrumentedList):
value = list(value)
old_values[f.field_name] = value
new_values = old_values.copy()
current_value: bool = (
getattr(entity, field_descriptor.field_name) or False
)
new_values[field_descriptor.field_name] = not current_value
can_update = True
if entity_descriptor.before_update_save:
if iscoroutinefunction(entity_descriptor.before_update_save):
can_update = await entity_descriptor.before_update_save(
old_values,
new_values,
context,
)
else:
can_update = entity_descriptor.before_update_save(
old_values,
new_values,
context,
)
if isinstance(can_update, str):
await message.answer(text=can_update, **{"show_alert": True})
elif not can_update:
await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)),
**{"show_alert": True},
)
if isinstance(can_update, bool) and can_update:
for attr in new_values:
if attr != "id":
setattr(entity, attr, new_values[attr])
await db_session.commit()
if entity_descriptor.on_updated:
if iscoroutinefunction(entity_descriptor.on_updated):
await entity_descriptor.on_updated(
old_values,
entity,
context,
)
else:
entity_descriptor.on_updated(
old_values,
entity,
context,
)
stack, context_data = get_navigation_context(state_data=state_data)
kwargs.update({"callback_data": context_data})
await state.set_data(state_data)
return await entity_item(
query=message, navigation_stack=stack, **kwargs
)
return
if not entity_data and callback_data.context in [
CommandContext.ENTITY_EDIT,
CommandContext.ENTITY_FIELD_EDIT,
]:
entity = await entity_descriptor.type_.get(
session=kwargs["db_session"], id=int(callback_data.entity_id)
)
if await check_entity_permission(
entity=entity, user=user, permission=EntityPermission.READ_RLS
):
if entity:
form_name = (
callback_data.form_params.split("&")[0]
if callback_data.form_params
else None
)
form: EntityForm = entity_descriptor.forms.get(
form_name, entity_descriptor.default_form
)
if form.edit_field_sequence:
field_sequence = form.edit_field_sequence
else:
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(
entity,
entity_descriptor.fields_descriptors[key].field_name,
),
entity_descriptor.fields_descriptors[key],
)
for key in (
field_sequence
if callback_data.context == CommandContext.ENTITY_EDIT
else [callback_data.field_name]
)
}
state_data.update({"entity_data": entity_data})
if callback_data.context == CommandContext.ENTITY_CREATE:
if entity_descriptor.before_create:
if iscoroutinefunction(entity_descriptor.before_create):
can_create = await entity_descriptor.before_create(
context,
)
else:
can_create = entity_descriptor.before_create(
context,
)
if isinstance(can_create, str):
return await message.answer(text=can_create, **{"show_alert": True})
elif not can_create:
return await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)),
**{"show_alert": True},
)
if entity_data:
current_value = await deserialize(
session=db_session,
type_=field_descriptor.type_,
value=entity_data.get(callback_data.field_name),
)
kwargs.update({"field_descriptor": field_descriptor})
save_navigation_context(state_data=state_data, callback_data=callback_data)
await show_editor(message=message, current_value=current_value, **kwargs)
@router.callback_query(ContextData.filter(F.command == CallbackCommand.DELETE_MESSAGE))
async def delete_message_callback(query: CallbackQuery, **kwargs):
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
context_data: ContextData = kwargs["callback_data"]
clear_nav = context_data.data == "clear_nav"
clear_state(state_data=state_data, clear_nav=clear_nav)
await state.set_data(state_data)
await query.message.delete()
router.include_routers(
string_editor_router,
date_picker_router,
bool_editor_router,
entity_picker_router,
)

View File

@@ -2,23 +2,31 @@ from inspect import iscoroutinefunction
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.fsm.context import FSMContext
from sqlalchemy.orm.collections import InstrumentedList
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from decimal import Decimal
import json
from ..context import ContextData, CallbackCommand, CommandContext
from ...command_context_filter import CallbackCommandFilter
from ..user_handlers.main import cammand_handler
from ..user_handlers.main import command_handler
from ....model import EntityPermission
from ....model.user import UserBase
from ....model.settings import Settings
from ....model.descriptors import EntityEventContext, FieldDescriptor
from ....model.descriptors import (
BotContext,
EntityForm,
EntityList,
FieldDescriptor,
Filter,
FilterExpression,
)
from ....model.language import LanguageBase
from ....auth import authorize_command
from ....model.permissions import check_entity_permission
from ....utils.main import (
get_user_permissions,
check_entity_permission,
clear_state,
get_entity_descriptor,
get_field_descriptor,
@@ -29,31 +37,90 @@ from ..common.routing import route_callback
from .common import show_editor
if TYPE_CHECKING:
from ....main import QBotApp
from ....main import QuickBot
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"],
user=kwargs["user"],
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)
)
async def field_editor_callback(message: Message | CallbackQuery, **kwargs):
app: "QBotApp" = kwargs["app"]
state: FSMContext = kwargs["state"]
app: "QuickBot" = kwargs["app"]
callback_data: ContextData = kwargs.get("callback_data", None)
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
kwargs["state_data"] = state_data
if isinstance(message, Message):
callback_data: ContextData = kwargs.get("callback_data", None)
context_data = state_data.get("context_data")
if context_data:
callback_data = ContextData.unpack(context_data)
value = message.text
field_descriptor = get_field_descriptor(app, callback_data)
if not field_descriptor.options_custom_value:
return
value = message.text
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"))
@@ -72,38 +139,23 @@ async def field_editor_callback(message: Message | CallbackQuery, **kwargs):
current_value = state_data.get("current_value")
state_data.update({"value": value})
entity_descriptor = get_entity_descriptor(app, callback_data)
# entity_descriptor = field_descriptor.entity_descriptor
# 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,
# entity_descriptor=entity_descriptor,
current_value=current_value,
value=value,
**kwargs,
)
# else:
# value = state_data.get("value")
# if value:
# value = json.loads(value)
# else:
# value = {}
# value[list(LanguageBase.all_members.keys())[locale_index]] = (
# message.text
# )
# value = json.dumps(value, ensure_ascii=False)
elif type_base in [int, float, Decimal]:
try:
_ = type_base(value) # @IgnoreException
except Exception:
return await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_INVALID_INPUT))
)
else:
callback_data: ContextData = kwargs["callback_data"]
field_descriptor = get_field_descriptor(app, callback_data)
if callback_data.data:
if callback_data.data == "skip":
value = None
@@ -111,7 +163,6 @@ async def field_editor_callback(message: Message | CallbackQuery, **kwargs):
value = callback_data.data
else:
value = state_data.get("value")
field_descriptor = get_field_descriptor(app, callback_data)
kwargs.update(
{
@@ -144,6 +195,8 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
)
clear_state(state_data=state_data)
return await route_callback(message=message, back=True, **kwargs)
elif callback_data.context in [
@@ -152,9 +205,12 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
CommandContext.ENTITY_FIELD_EDIT,
CommandContext.COMMAND_FORM,
]:
app: "QBotApp" = kwargs["app"]
app: "QuickBot" = kwargs["app"]
entity_descriptor = get_entity_descriptor(app, callback_data)
entity_data = state_data.get("entity_data", {})
entity_data[field_descriptor.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)
@@ -163,19 +219,28 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
form_name = (
callback_data.form_params.split("&")[0]
if callback_data.form_params
else "default"
else None
)
form = entity_descriptor.forms.get(
form: EntityForm = entity_descriptor.forms.get(
form_name, entity_descriptor.default_form
)
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"],
user=user,
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 = (
@@ -186,9 +251,7 @@ 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 and not 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
if (
@@ -199,15 +262,15 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
prev_form_name = (
prev_callback_data.form_params.split("&")[0]
if prev_callback_data.form_params
else "default"
else None
)
prev_form_params = (
prev_callback_data.form_params.split("&")[1:]
if prev_callback_data.form_params
else []
)
prev_form_list = entity_descriptor.lists.get(
prev_form_name or "default", entity_descriptor.default_list
prev_form_list: EntityList = entity_descriptor.lists.get(
prev_form_name, entity_descriptor.default_list
)
if prev_form_list.static_filters:
@@ -228,7 +291,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]
@@ -256,18 +319,50 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
)
else:
entity_data[field_descriptor.field_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
user_permissions = get_user_permissions(user, entity_descriptor)
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
if callback_data.context in [
CommandContext.ENTITY_CREATE,
CommandContext.ENTITY_EDIT,
]:
user_permissions = get_user_permissions(user, entity_descriptor)
if entity_descriptor.rls_filters:
filters = []
if isinstance(entity_descriptor.rls_filters, Filter):
filters = [entity_descriptor.rls_filters]
elif (
isinstance(entity_descriptor.rls_filters, FilterExpression)
and entity_descriptor.rls_filters.operator == "and"
and all(
isinstance(f, Filter)
for f in entity_descriptor.rls_filters.filters
)
):
filters = entity_descriptor.rls_filters.filters
filter_params = []
if filters and entity_descriptor.rls_filters_params:
if iscoroutinefunction(entity_descriptor.rls_filters_params):
filter_params = await entity_descriptor.rls_filters_params(
user
)
else:
filter_params = entity_descriptor.rls_filters_params(user)
for f in filters:
if f.operator == "==":
if isinstance(f.field, str):
field_name = f.field
else:
field_name = f.field(entity_descriptor.type_).key
entity_data[field_name] = (
f.value
if f.value_type == "const"
else filter_params[f.param_index]
)
deser_entity_data = {
key: await deserialize(
@@ -278,59 +373,87 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
for key, value in entity_data.items()
}
context = BotContext(
db_session=db_session,
app=app,
app_state=kwargs["app_state"],
user=user,
message=message,
)
if callback_data.context == CommandContext.ENTITY_CREATE:
entity_type = entity_descriptor.type_
user_permissions = get_user_permissions(user, entity_descriptor)
if (
EntityPermission.CREATE not in user_permissions
EntityPermission.CREATE_RLS not in user_permissions
and EntityPermission.CREATE_ALL not in user_permissions
):
return await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
)
new_entity = await entity_type.create(
session=db_session,
obj_in=entity_type(**deser_entity_data),
commit=True,
)
new_entity = entity_type(**deser_entity_data)
if entity_descriptor.on_created:
if iscoroutinefunction(entity_descriptor.on_created):
await entity_descriptor.on_created(
can_create = True
if entity_descriptor.before_create_save:
if iscoroutinefunction(entity_descriptor.before_create_save):
can_create = await entity_descriptor.before_create_save(
new_entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
context,
)
else:
entity_descriptor.on_created(
can_create = entity_descriptor.before_create_save(
new_entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
context,
)
if isinstance(can_create, str):
await message.answer(text=can_create, **{"show_alert": True})
elif not can_create:
await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)),
**{"show_alert": True},
)
form_name = (
callback_data.form_params.split("&")[0]
if callback_data.form_params
else "default"
)
form_list = entity_descriptor.lists.get(
form_name or "default", entity_descriptor.default_list
)
if isinstance(can_create, bool) and can_create:
new_entity = await entity_type.create(
session=db_session,
obj_in=new_entity,
commit=True,
)
state_data["navigation_context"] = ContextData(
command=CallbackCommand.ENTITY_ITEM,
entity_name=entity_descriptor.name,
form_params=form_list.item_form,
entity_id=str(new_entity.id),
).pack()
if entity_descriptor.on_created:
if iscoroutinefunction(entity_descriptor.on_created):
await entity_descriptor.on_created(
new_entity,
context,
)
else:
entity_descriptor.on_created(
new_entity,
context,
)
state_data.update(state_data)
form_name = (
callback_data.form_params.split("&")[0]
if callback_data.form_params
else None
)
form_list = entity_descriptor.lists.get(
form_name, entity_descriptor.default_list
)
clear_state(state_data=state_data)
return await route_callback(message=message, back=False, **kwargs)
state_data["navigation_context"] = ContextData(
command=CallbackCommand.ENTITY_ITEM,
entity_name=entity_descriptor.name,
form_params=form_list.item_form,
entity_id=str(new_entity.id),
).pack()
state_data.update(state_data)
clear_state(state_data=state_data)
return await route_callback(message=message, back=False, **kwargs)
elif callback_data.context in [
CommandContext.ENTITY_EDIT,
@@ -344,34 +467,74 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
text=(await Settings.get(Settings.APP_STRINGS_NOT_FOUND))
)
if not check_entity_permission(
entity=entity, user=user, permission=EntityPermission.UPDATE
if not await check_entity_permission(
entity=entity, user=user, permission=EntityPermission.UPDATE_RLS
):
return await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
)
old_values = {}
for f in entity.bot_entity_descriptor.fields_descriptors.values():
value = getattr(entity, f.field_name)
if isinstance(value, InstrumentedList):
value = list(value)
old_values[f.field_name] = value
new_values = old_values.copy()
for key, value in deser_entity_data.items():
setattr(entity, key, value)
new_values[
entity.bot_entity_descriptor.fields_descriptors[key].field_name
] = value
await db_session.commit()
await db_session.refresh(entity)
can_update = True
if entity_descriptor.on_updated:
if iscoroutinefunction(entity_descriptor.on_updated):
await entity_descriptor.on_updated(
entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
if entity_descriptor.before_update_save:
if iscoroutinefunction(entity_descriptor.before_update_save):
can_update = await entity_descriptor.before_update_save(
old_values,
new_values,
context,
)
else:
entity_descriptor.on_updated(
entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
can_update = entity_descriptor.before_update_save(
old_values,
new_values,
context,
)
if isinstance(can_update, str):
await message.answer(text=can_update, **{"show_alert": True})
elif not can_update:
await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)),
**{"show_alert": True},
)
if isinstance(can_update, bool) and can_update:
for attr in new_values:
if attr != "id":
setattr(entity, attr, new_values[attr])
await db_session.commit()
await db_session.refresh(entity)
if entity_descriptor.on_updated:
if iscoroutinefunction(entity_descriptor.on_updated):
await entity_descriptor.on_updated(
old_values,
entity,
context,
)
else:
entity_descriptor.on_updated(
old_values,
entity,
context,
)
else:
await db_session.rollback()
elif callback_data.context == CommandContext.COMMAND_FORM:
clear_state(state_data=state_data)
@@ -388,9 +551,8 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
cmd = app.bot_commands.get(callback_data.user_command.split("&")[0])
return await cammand_handler(message=message, cmd=cmd, **kwargs)
return await command_handler(message=message, cmd=cmd, **kwargs)
clear_state(state_data=state_data)
# TODO: Try back=False and check if it works to navigate to newly created entity
await route_callback(message=message, back=True, **kwargs)

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
@@ -74,25 +74,39 @@ async def string_editor(
state_data.update({"context_data": context_data.pack()})
if _current_value:
if (
_current_value
and field_descriptor.show_current_value_button
and field_descriptor.options_custom_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),
copy_text=CopyTextButton(text=_current_value[:256]),
)
)
state_data = kwargs["state_data"]
context = BotContext(
db_session=kwargs["db_session"],
app=kwargs["app"],
app_state=kwargs["app_state"],
user=user,
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,
entity=kwargs.get("entity_data"),
)
await state.set_data(state_data)

View File

@@ -1,12 +1,17 @@
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from inspect import iscoroutinefunction
from typing import Any
from ....model.bot_entity import BotEntity
from ....model.bot_enum import BotEnum
from ....model.settings import Settings
from ....model.descriptors import 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
from ....utils.main import build_field_sequence
from ....utils.main import build_field_sequence, get_value_repr
from ....utils.serialization import serialize
async def wrap_editor(
@@ -15,6 +20,8 @@ async def wrap_editor(
callback_data: ContextData,
state_data: dict,
user: UserBase,
context: BotContext,
entity: BotEntity | Any = None,
):
if callback_data.context in [
CommandContext.ENTITY_CREATE,
@@ -22,9 +29,9 @@ async def wrap_editor(
CommandContext.ENTITY_FIELD_EDIT,
CommandContext.COMMAND_FORM,
]:
btns = []
show_back = True
show_cancel = True
if callback_data.context == CommandContext.COMMAND_FORM:
field_sequence = list(field_descriptor.command.param_form.keys())
field_index = field_sequence.index(callback_data.field_name)
@@ -34,18 +41,20 @@ async def wrap_editor(
form_name = (
callback_data.form_params.split("&")[0]
if callback_data.form_params
else "default"
else None
)
form = field_descriptor.entity_descriptor.forms.get(
form: EntityForm = 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(
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)
@@ -54,8 +63,55 @@ async def wrap_editor(
else 0
)
stack, context = get_navigation_context(state_data=state_data)
context = pop_navigation_context(stack)
stack, navigation_context = get_navigation_context(state_data=state_data)
navigation_context = pop_navigation_context(stack)
if not issubclass(field_descriptor.type_base, BotEnum):
options = []
if field_descriptor.options:
if isinstance(field_descriptor.options, list):
options = field_descriptor.options
elif callable(field_descriptor.options):
if iscoroutinefunction(field_descriptor.options):
options = await field_descriptor.options(entity, context)
else:
options = field_descriptor.options(entity, context)
for option_row in options:
btns_row = []
for option in option_row:
if isinstance(option, tuple):
value = option[0]
caption = option[1]
else:
value = option
caption = await get_value_repr(
value=value,
field_descriptor=field_descriptor,
context=context,
)
btns_row.append(
InlineKeyboardButton(
text=caption,
callback_data=ContextData(
command=CallbackCommand.FIELD_EDITOR_CALLBACK,
context=callback_data.context,
entity_name=callback_data.entity_name,
entity_id=callback_data.entity_id,
field_name=callback_data.field_name,
form_params=callback_data.form_params,
user_command=callback_data.user_command,
data=serialize(
value=value,
field_descriptor=field_descriptor,
),
).pack(),
)
)
keyboard_builder.row(*btns_row)
btns = []
if field_index > 0 and show_back:
btns.append(
@@ -73,7 +129,10 @@ async def wrap_editor(
)
)
if field_descriptor.is_optional:
if (
field_descriptor.is_optional
and field_descriptor.show_skip_in_editor == "Auto"
):
btns.append(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_SKIP_BTN)),
@@ -96,7 +155,11 @@ async def wrap_editor(
keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_CANCEL_BTN)),
callback_data=context.pack(),
callback_data=navigation_context.pack()
if navigation_context
else ContextData(
command=CallbackCommand.DELETE_MESSAGE, data="clear_nav"
).pack(),
)
)

View File

@@ -8,23 +8,24 @@ from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....model.descriptors import (
EntityForm,
FieldEditButton,
CommandButton,
InlineButton,
EntityEventContext,
BotContext,
)
from ....model.bot_entity import BotEntity
from ....model.settings import Settings
from ....model.user import UserBase
from ....model import EntityPermission
from ....model.permissions import check_entity_permission
from ....utils.main import (
check_entity_permission,
get_send_message,
clear_state,
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 (
@@ -33,7 +34,7 @@ from ....utils.navigation import (
)
if TYPE_CHECKING:
from ....main import QBotApp
from ....main import QuickBot
logger = getLogger(__name__)
@@ -49,6 +50,7 @@ async def entity_item_callback(query: CallbackQuery, **kwargs):
clear_state(state_data=state_data)
stack = save_navigation_context(callback_data=callback_data, state_data=state_data)
await state.set_data(state_data)
await entity_item(query=query, navigation_stack=stack, **kwargs)
@@ -58,7 +60,7 @@ async def entity_item(
callback_data: ContextData,
db_session: AsyncSession,
user: UserBase,
app: "QBotApp",
app: "QuickBot",
navigation_stack: list[ContextData],
**kwargs,
):
@@ -70,34 +72,48 @@ async def entity_item(
entity_item = await entity_type.get(session=db_session, id=callback_data.entity_id)
state: FSMContext = kwargs["state"]
# state: FSMContext = kwargs["state"]
state_data = kwargs["state_data"]
await state.set_data(state_data)
# await state.set_data(state_data)
if not entity_item:
if not entity_item and query:
return await query.answer(
text=(await Settings.get(Settings.APP_STRINGS_NOT_FOUND))
)
# is_owned = issubclass(entity_type, OwnedBotEntity)
if not check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.READ
if query and not await check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.READ_RLS
):
return await query.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
)
can_edit = check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.UPDATE
can_edit = await check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.UPDATE_RLS
)
form = entity_descriptor.forms.get(
callback_data.form_params or "default", entity_descriptor.default_form
form: EntityForm = entity_descriptor.forms.get(
callback_data.form_params, entity_descriptor.default_form
)
context = BotContext(
db_session=db_session,
app=app,
app_state=kwargs["app_state"],
user=user,
message=query,
default_handler=item_repr,
)
if form.before_open:
if iscoroutinefunction(form.before_open):
await form.before_open(entity_item, context)
else:
form.before_open(entity_item, context)
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:
@@ -105,7 +121,14 @@ async def entity_item(
continue
if isinstance(button, FieldEditButton) and can_edit:
field_name = button.field_name
if isinstance(button.field, str):
field_name = button.field
else:
field_name = button.field(entity_descriptor.type_).key
for fd in entity_descriptor.fields_descriptors.values():
if fd.field_name == field_name:
field_name = fd.name
break
btn_caption = button.caption
if field_name in entity_descriptor.fields_descriptors:
field_descriptor = entity_descriptor.fields_descriptors[
@@ -114,16 +137,17 @@ async def entity_item(
field_value = getattr(entity_item, field_descriptor.field_name)
if btn_caption:
btn_text = await get_callable_str(
btn_caption, field_descriptor, entity_item, field_value
callable_str=btn_caption,
context=context,
entity=entity_item,
)
else:
if field_descriptor.type_base is bool:
btn_text = f"{'【✔︎】 ' if field_value else ' '}{
btn_text = f"{'[✓] ' if field_value else '[ ] '}{
await get_callable_str(
field_descriptor.caption,
field_descriptor,
entity_item,
field_value,
callable_str=field_descriptor.caption,
context=context,
descriptor=field_descriptor,
)
if field_descriptor.caption
else field_name
@@ -135,10 +159,9 @@ async def entity_item(
else '✏️'
} {
await get_callable_str(
field_descriptor.caption,
field_descriptor,
entity_item,
field_value,
callable_str=field_descriptor.caption,
context=context,
descriptor=field_descriptor,
)
if field_descriptor.caption
else field_name
@@ -160,7 +183,9 @@ async def entity_item(
btn_caption = button.caption
btn_text = await get_callable_str(
btn_caption, entity_descriptor, entity_item
callable_str=btn_caption,
context=context,
entity=entity_item,
)
if isinstance(button.command, ContextData):
@@ -202,10 +227,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(
@@ -222,8 +249,8 @@ async def entity_item(
)
if (
check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.DELETE
await check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.DELETE_RLS
)
and form.show_delete_button
):
@@ -243,67 +270,13 @@ async def entity_item(
keyboard_builder.row(*edit_delete_row)
if form.item_repr:
item_text = form.item_repr(entity_descriptor, entity_item)
item_text = await get_callable_str(
callable_str=form.item_repr,
context=context,
entity=entity_item,
)
else:
entity_caption = (
await get_callable_str(
entity_descriptor.full_name, entity_descriptor, entity_item
)
if entity_descriptor.full_name
else entity_descriptor.name
)
entity_item_repr = (
await get_callable_str(
entity_descriptor.item_repr, entity_descriptor, entity_item
)
if entity_descriptor.item_repr
else str(entity_item.id)
)
item_text = f"<b><u><i>{entity_caption or entity_descriptor.name}:</i></u></b> <b>{entity_item_repr}</b>"
user_permissions = get_user_permissions(user, entity_descriptor)
for field_descriptor in entity_descriptor.fields_descriptors.values():
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
)
if field_descriptor.caption_value:
value = await get_callable_str(
field_descriptor.caption_value,
field_descriptor,
entity_item,
getattr(entity_item, field_descriptor.field_name),
)
else:
value = await get_value_repr(
value=getattr(entity_item, field_descriptor.field_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 ''}"
item_text = await item_repr(entity_item=entity_item, context=context)
context = pop_navigation_context(navigation_stack)
if context:
@@ -318,6 +291,87 @@ async def entity_item(
# state_data = kwargs["state_data"]
# await state.set_data(state_data)
send_message = get_send_message(query)
if query:
send_message = get_send_message(query)
await send_message(text=item_text, reply_markup=keyboard_builder.as_markup())
else:
await app.bot.send_message(
chat_id=user.id,
text=item_text,
reply_markup=keyboard_builder.as_markup(),
)
await send_message(text=item_text, reply_markup=keyboard_builder.as_markup())
async def item_repr(entity_item: BotEntity, context: BotContext):
entity_descriptor = entity_item.bot_entity_descriptor
user = context.user
entity_caption = (
await get_callable_str(
callable_str=entity_descriptor.full_name,
context=context,
descriptor=entity_descriptor,
)
if entity_descriptor.full_name
else entity_descriptor.name
)
entity_item_repr = (
await get_callable_str(
callable_str=entity_descriptor.item_repr,
context=context,
entity=entity_item,
)
if entity_descriptor.item_repr
else str(entity_item.id)
)
item_text = f"<b><u><i>{entity_caption or entity_descriptor.name}:</i></u></b> <b>{entity_item_repr}</b>"
# user_permissions = get_user_permissions(user, entity_descriptor)
for field_descriptor in entity_descriptor.fields_descriptors.values():
if (
isinstance(field_descriptor.is_visible, bool)
and not field_descriptor.is_visible
):
continue
if callable(field_descriptor.is_visible):
if iscoroutinefunction(field_descriptor.is_visible):
field_visible = await field_descriptor.is_visible(
field_descriptor, entity_item, context
)
else:
field_visible = field_descriptor.is_visible(
field_descriptor, entity_item, context
)
if not field_visible:
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,
context=context,
descriptor=field_descriptor,
)
if field_descriptor.caption
else field_descriptor.field_name
)
value = await get_value_repr(
value=getattr(entity_item, field_descriptor.field_name),
field_descriptor=field_descriptor,
context=context,
locale=user.lang,
)
item_text += f"\n{field_caption or field_descriptor.name}:{f' <b>{value}</b>' if value else ''}"
return item_text

View File

@@ -6,21 +6,21 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import TYPE_CHECKING
from qbot.model.descriptors import EntityEventContext
from quickbot.model.descriptors import BotContext
from ..context import ContextData, CallbackCommand
from ....model.user import UserBase
from ....model.settings import Settings
from ....model import EntityPermission
from ....model.permissions import check_entity_permission
from ....utils.main import (
check_entity_permission,
get_entity_item_repr,
get_entity_descriptor,
)
from ..common.routing import route_callback
if TYPE_CHECKING:
from ....main import QBotApp
from ....main import QuickBot
router = Router()
@@ -31,7 +31,7 @@ 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"]
app: "QuickBot" = kwargs["app"]
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
kwargs["state_data"] = state_data
@@ -42,31 +42,63 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
session=db_session, id=int(callback_data.entity_id)
)
if not check_entity_permission(
entity=entity, user=user, permission=EntityPermission.DELETE
if not await check_entity_permission(
entity=entity, user=user, permission=EntityPermission.DELETE_RLS
):
return await query.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
)
if callback_data.data == "yes":
entity = await entity_descriptor.type_.remove(
session=db_session, id=int(callback_data.entity_id), commit=True
)
context = BotContext(
db_session=db_session,
app=app,
app_state=kwargs["app_state"],
user=user,
message=query,
)
if entity_descriptor.on_deleted:
if iscoroutinefunction(entity_descriptor.on_created):
await entity_descriptor.on_deleted(
if callback_data.data == "yes":
can_delete = True
if entity_descriptor.before_delete:
if iscoroutinefunction(entity_descriptor.before_delete):
can_delete = await entity_descriptor.before_delete(
entity,
EntityEventContext(db_session=db_session, app=app, message=query),
context,
)
else:
entity_descriptor.on_deleted(
can_delete = entity_descriptor.before_delete(
entity,
EntityEventContext(db_session=db_session, app=app, message=query),
context,
)
if isinstance(can_delete, str):
await query.answer(text=can_delete, show_alert=True)
elif not can_delete:
await query.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)),
show_alert=True,
)
await route_callback(message=query, **kwargs)
if isinstance(can_delete, bool) and can_delete:
await db_session.delete(entity)
await db_session.commit()
if entity_descriptor.on_updated:
if iscoroutinefunction(entity_descriptor.on_updated):
await entity_descriptor.on_updated(
entity,
context,
)
else:
entity_descriptor.on_updated(
entity,
context,
)
await route_callback(message=query, **kwargs)
else:
await route_callback(message=query, back=False, **kwargs)
elif not callback_data.data:
entity = await entity_descriptor.type_.get(
@@ -76,7 +108,12 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
return await query.message.edit_text(
text=(
await Settings.get(Settings.APP_STRINGS_CONFIRM_DELETE_P_NAME)
).format(name=await get_entity_item_repr(entity=entity)),
).format(
name=await get_entity_item_repr(
entity=entity,
context=context,
)
),
reply_markup=InlineKeyboardBuilder()
.row(
InlineKeyboardButton(

View File

@@ -10,7 +10,11 @@ from ....model.bot_entity import BotEntity
from ....model.settings import Settings
from ....model.user import UserBase
from ....model.view_setting import ViewSetting
from ....model.descriptors import EntityDescriptor, Filter
from ....model.descriptors import (
BotContext,
EntityForm,
EntityList,
)
from ....model import EntityPermission
from ....utils.main import (
get_user_permissions,
@@ -19,15 +23,15 @@ from ....utils.main import (
get_entity_descriptor,
get_callable_str,
build_field_sequence,
prepare_static_filter,
)
from ....utils.serialization import deserialize
from ..context import ContextData, CallbackCommand, CommandContext
from ..common.pagination import add_pagination_controls
from ..common.filtering import add_filter_controls
from ....utils.navigation import pop_navigation_context, save_navigation_context
if TYPE_CHECKING:
from ....main import QBotApp
from ....main import QuickBot
logger = getLogger(__name__)
@@ -47,6 +51,7 @@ async def entity_list_callback(query: CallbackQuery, **kwargs):
clear_state(state_data=state_data)
stack = save_navigation_context(callback_data=callback_data, state_data=state_data)
await state.set_data(state_data)
await entity_list(message=query, navigation_stack=stack, **kwargs)
@@ -55,43 +60,12 @@ def calc_total_pages(items_count: int, page_size: int) -> int:
return max(items_count // page_size + (1 if items_count % page_size else 0), 1)
async def _prepare_static_filter(
db_session: AsyncSession,
entity_descriptor: EntityDescriptor,
static_filters: list[Filter],
params: list[str],
) -> list[Filter]:
return (
[
Filter(
field_name=f.field_name,
operator=f.operator,
value_type="const",
value=(
f.value
if f.value_type == "const"
else await deserialize(
session=db_session,
type_=entity_descriptor.fields_descriptors[
f.field_name
].type_base,
value=params[f.param_index],
)
),
)
for f in static_filters
]
if static_filters
else None
)
async def entity_list(
message: CallbackQuery | Message,
callback_data: ContextData,
db_session: AsyncSession,
user: UserBase,
app: "QBotApp",
app: "QuickBot",
navigation_stack: list[ContextData],
**kwargs,
):
@@ -103,27 +77,36 @@ async def entity_list(
form_params = (
callback_data.form_params.split("&") if callback_data.form_params else []
)
form_name = form_params.pop(0) if form_params else "default"
form_list = entity_descriptor.lists.get(
form_name or "default", entity_descriptor.default_list
form_name = form_params.pop(0) if form_params else None
form_list: EntityList = entity_descriptor.lists.get(
form_name, entity_descriptor.default_list
)
form_item = entity_descriptor.forms.get(
form_list.item_form or "default", entity_descriptor.default_form
form_item: EntityForm = entity_descriptor.forms.get(
form_list.item_form, entity_descriptor.default_form
)
keyboard_builder = InlineKeyboardBuilder()
context = BotContext(
db_session=db_session,
app=app,
app_state=kwargs["app_state"],
user=user,
message=message,
)
if (
EntityPermission.CREATE in user_permissions
EntityPermission.CREATE_RLS 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(
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(
@@ -153,14 +136,14 @@ async def entity_list(
)
if (
list_all
or EntityPermission.LIST in user_permissions
or EntityPermission.READ in user_permissions
or EntityPermission.LIST_RLS in user_permissions
or EntityPermission.READ_RLS in user_permissions
):
if form_list.pagination:
page_size = await Settings.get(Settings.PAGE_SIZE)
items_count = await entity_type.get_count(
session=db_session,
static_filter=await _prepare_static_filter(
static_filter=await prepare_static_filter(
db_session=db_session,
entity_descriptor=entity_descriptor,
static_filters=form_list.static_filters,
@@ -181,7 +164,7 @@ async def entity_list(
items = await entity_type.get_multi(
session=db_session,
order_by=form_list.order_by,
static_filter=await _prepare_static_filter(
static_filter=await prepare_static_filter(
db_session=db_session,
entity_descriptor=entity_descriptor,
static_filters=form_list.static_filters,
@@ -200,19 +183,31 @@ async def entity_list(
page = 1
for item in items:
caption = None
if form_list.item_repr:
caption = form_list.item_repr(entity_descriptor, item)
caption = await get_callable_str(
callable_str=form_list.item_repr,
context=context,
entity=item,
)
elif entity_descriptor.item_repr:
caption = entity_descriptor.item_repr(entity_descriptor, item)
caption = await get_callable_str(
callable_str=entity_descriptor.item_repr,
context=context,
entity=item,
)
elif entity_descriptor.full_name:
caption = f"{
await get_callable_str(
callable_str=entity_descriptor.full_name,
context=context,
descriptor=entity_descriptor,
entity=item,
)
}: {item.id}"
else:
if not caption:
caption = f"{entity_descriptor.name}: {item.id}"
keyboard_builder.row(
@@ -240,35 +235,48 @@ async def entity_list(
await add_filter_controls(
keyboard_builder=keyboard_builder,
entity_descriptor=entity_descriptor,
context=context,
filter=entity_filter,
filtering_fields=form_list.filtering_fields,
)
context = pop_navigation_context(navigation_stack)
if context:
navigation_context = pop_navigation_context(navigation_stack)
if navigation_context:
keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data=context.pack(),
callback_data=navigation_context.pack(),
)
)
if form_list.caption:
entity_text = await get_callable_str(form_list.caption, entity_descriptor)
entity_text = await get_callable_str(
callable_str=form_list.caption,
context=context,
descriptor=entity_descriptor,
)
else:
if entity_descriptor.full_name_plural:
entity_text = await get_callable_str(
entity_descriptor.full_name_plural, entity_descriptor
callable_str=entity_descriptor.full_name_plural,
context=context,
descriptor=entity_descriptor,
)
else:
entity_text = entity_descriptor.name
if entity_descriptor.description:
entity_text = f"{entity_text} {await get_callable_str(entity_descriptor.description, entity_descriptor)}"
if entity_descriptor.ui_description:
entity_text = f"{entity_text} {
await get_callable_str(
callable_str=entity_descriptor.ui_description,
context=context,
descriptor=entity_descriptor,
)
}"
state: FSMContext = kwargs["state"]
state_data = kwargs["state_data"]
await state.set_data(state_data)
# state: FSMContext = kwargs["state"]
# state_data = kwargs["state_data"]
# await state.set_data(state_data)
send_message = get_send_message(message)

View File

@@ -2,17 +2,17 @@ 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 typing import TYPE_CHECKING
from quickbot.model.descriptors import BotContext
from ....model.settings import Settings
from ..context import ContextData, CallbackCommand
from ....utils.main import get_send_message
from ....model.descriptors import EntityCaptionCallable
from ....utils.main import get_send_message, get_callable_str
from ....utils.navigation import save_navigation_context, pop_navigation_context
if TYPE_CHECKING:
from ....main import QBotApp
from ....main import QuickBot
logger = getLogger(__name__)
@@ -35,23 +35,33 @@ async def menu_entry_entities(message: CallbackQuery, **kwargs):
async def entities_menu(
message: Message | CallbackQuery,
app: "QBotApp",
app: "QuickBot",
state: FSMContext,
navigation_stack: list[ContextData],
**kwargs,
):
keyboard_builder = InlineKeyboardBuilder()
entity_metadata = app.entity_metadata
entity_metadata = app.bot_metadata
for entity in entity_metadata.entity_descriptors.values():
if entity.show_in_entities_menu:
if entity.full_name_plural.__class__ == EntityCaptionCallable:
caption = entity.full_name_plural(entity) or entity.name
elif entity.full_name_plural.__class__ == LazyProxy:
caption = f"{f'{entity.icon} ' if entity.icon else ''}{entity.full_name_plural.value or entity.name}"
if entity.full_name_plural:
caption = await get_callable_str(
callable_str=entity.full_name_plural,
context=BotContext(
db_session=kwargs["db_session"],
app=app,
app_state=kwargs["app_state"],
user=kwargs["user"],
message=message,
),
descriptor=entity,
)
else:
caption = f"{f'{entity.icon} ' if entity.icon else ''}{entity.full_name_plural or entity.name}"
caption = entity.name
caption = f"{f'{entity.icon} ' if entity.icon else ''}{caption}"
keyboard_builder.row(
InlineKeyboardButton(

View File

@@ -15,7 +15,6 @@ from ....model.language import LanguageBase
from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand
from ..common.routing import route_callback
from ....utils.main import get_send_message
@@ -94,3 +93,6 @@ async def set_language(message: CallbackQuery, **kwargs):
i18n: I18n = kwargs["i18n"]
with i18n.use_locale(user.lang.value):
await route_callback(message, **kwargs)
from ..common.routing import route_callback # noqa: E402

View File

@@ -7,17 +7,17 @@ from ..context import ContextData, CallbackCommand
from ....utils.main import get_send_message
from ....utils.navigation import save_navigation_context, pop_navigation_context
import qbot.bot.handlers.menu.entities as entities
import qbot.bot.handlers.menu.settings as settings
import qbot.bot.handlers.menu.parameters as parameters
import qbot.bot.handlers.menu.language as language
import qbot.bot.handlers.editors.main as editor
import qbot.bot.handlers.editors.main_callbacks as editor_callbacks
import qbot.bot.handlers.forms.entity_list as entity_list
import qbot.bot.handlers.forms.entity_form as entity_form
import qbot.bot.handlers.forms.entity_form_callbacks as entity_form_callbacks
import qbot.bot.handlers.common.filtering_callbacks as filtering_callbacks
import qbot.bot.handlers.user_handlers.main as user_handlers_main
import quickbot.bot.handlers.menu.entities as entities
import quickbot.bot.handlers.menu.settings as settings
import quickbot.bot.handlers.menu.parameters as parameters
import quickbot.bot.handlers.menu.language as language
import quickbot.bot.handlers.editors.main as editor
import quickbot.bot.handlers.editors.main_callbacks as editor_callbacks
import quickbot.bot.handlers.forms.entity_list as entity_list
import quickbot.bot.handlers.forms.entity_form as entity_form
import quickbot.bot.handlers.forms.entity_form_callbacks as entity_form_callbacks
import quickbot.bot.handlers.common.filtering_callbacks as filtering_callbacks
import quickbot.bot.handlers.user_handlers.main as user_handlers_main
logger = getLogger(__name__)

View File

@@ -4,6 +4,8 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from quickbot.model.descriptors import BotContext
from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand, CommandContext
@@ -53,22 +55,35 @@ async def parameters_menu(
if not key.is_visible:
continue
context = BotContext(
db_session=kwargs["db_session"],
app=kwargs["app"],
app_state=kwargs["app_state"],
user=user,
message=message,
)
if key.caption_value:
caption = await get_callable_str(
callable_str=key.caption_value, descriptor=key, entity=None, value=value
callable_str=key.caption_value,
context=context,
descriptor=key,
entity={key.field_name: value},
)
else:
if key.caption:
caption = await get_callable_str(
callable_str=key.caption, descriptor=key, entity=None, value=value
callable_str=key.caption,
context=context,
descriptor=key,
)
else:
caption = key.name
if key.type_ is bool:
caption = f"{'【✔︎】' if value else ' '} {caption}"
caption = f"{'[✓]' if value else '[ ]'} {caption}"
else:
caption = f"{caption}: {await get_value_repr(value=value, field_descriptor=key, locale=user.lang)}"
caption = f"{caption}: {await get_value_repr(value=value, field_descriptor=key, context=context, locale=user.lang)}"
keyboard_builder.row(
InlineKeyboardButton(

View File

@@ -5,7 +5,7 @@ from aiogram.types import Message
from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ...main import QBotApp
from ...main import QuickBot
from ...model.settings import Settings
from ...model.language import LanguageBase
from ...model.user import UserBase
@@ -18,24 +18,27 @@ router = Router()
@router.message(CommandStart())
async def start(message: Message, **kwargs):
app: QBotApp = kwargs["app"]
app: QuickBot = kwargs["app"]
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
clear_state(state_data=state_data, clear_nav=True)
if app.start_handler:
await app.start_handler(default_start_handler, message, **kwargs)
else:
await default_start_handler(message, **kwargs)
await state.set_data(state_data)
async def default_start_handler[UserType: UserBase](
message: Message,
db_session: AsyncSession,
app: QBotApp,
app: QuickBot,
state: FSMContext,
**kwargs,
) -> tuple[UserType, bool]:
state_data = await state.get_data()
clear_state(state_data=state_data, clear_nav=True)
User = app.user_class
user = await User.get(session=db_session, id=message.from_user.id)
@@ -52,7 +55,7 @@ async def default_start_handler[UserType: UserBase](
]:
lang = LanguageBase(message.from_user.language_code)
else:
lang = LanguageBase.EN
lang = LanguageBase.DEFAULT
user = await User.create(
session=db_session,

View File

@@ -1,81 +1,32 @@
from aiogram import Router, F
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from inspect import iscoroutinefunction
from typing import TYPE_CHECKING
from qbot.utils.main import clear_state
from qbot.utils.navigation import (
save_navigation_context,
from quickbot.utils.main import clear_state
from quickbot.utils.navigation import (
get_navigation_context,
pop_navigation_context,
)
from qbot.bot.handlers.editors.main import field_editor
from qbot.bot.handlers.common.routing import route_callback
from qbot.utils.serialization import deserialize
from qbot.utils.main import get_send_message
from qbot.model.descriptors import BotCommand, CommandCallbackContext
from qbot.model.settings import Settings
from quickbot.bot.handlers.editors.main import field_editor
from quickbot.utils.serialization import deserialize
from quickbot.utils.main import get_send_message
from quickbot.model.descriptors import BotCommand, CommandCallbackContext
from quickbot.model.settings import Settings
if TYPE_CHECKING:
from qbot.main import QBotApp
from quickbot.main import QuickBot
from quickbot.model.user import UserBase
from ..context import ContextData, CallbackCommand, CommandContext
router = Router()
@router.message(F.text.startswith("/"))
async def command_text(message: Message, **kwargs):
str_command = message.text.lstrip("/")
callback_data = ContextData(
command=CallbackCommand.USER_COMMAND, user_command=str_command
)
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
kwargs["state_data"] = state_data
await process_command_handler(
message=message, callback_data=callback_data, **kwargs
)
@router.callback_query(ContextData.filter(F.command == CallbackCommand.USER_COMMAND))
async def command_callback(message: CallbackQuery, **kwargs):
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
kwargs["state_data"] = state_data
await process_command_handler(message=message, **kwargs)
async def process_command_handler(message: Message | CallbackQuery, **kwargs):
state_data: dict = kwargs["state_data"]
callback_data: ContextData = kwargs["callback_data"]
app: "QBotApp" = kwargs["app"]
cmd = app.bot_commands.get(callback_data.user_command.split("&")[0])
if cmd is None:
return
if cmd.clear_navigation:
state_data.pop("navigation_stack", None)
state_data.pop("navigation_context", None)
if cmd.register_navigation:
clear_state(state_data=state_data)
save_navigation_context(callback_data=callback_data, state_data=state_data)
await cammand_handler(message=message, cmd=cmd, **kwargs)
async def cammand_handler(message: Message | CallbackQuery, cmd: BotCommand, **kwargs):
async def command_handler(message: Message | CallbackQuery, cmd: BotCommand, **kwargs):
callback_data: ContextData = kwargs["callback_data"]
state: FSMContext = kwargs["state"]
state_data: dict = kwargs["state_data"]
app: "QBotApp" = kwargs["app"]
app: "QuickBot" = kwargs["app"]
user: "UserBase" = kwargs["user"]
entity_data_dict: dict = state_data.get("entity_data")
form_data = (
@@ -96,12 +47,14 @@ async def cammand_handler(message: Message | CallbackQuery, cmd: BotCommand, **k
callback_data=callback_data,
form_data=form_data,
db_session=kwargs["db_session"],
user=kwargs["user"],
user=user,
app=app,
app_state=kwargs["app_state"],
state_data=state_data,
state=state,
i18n=kwargs["i18n"],
register_navigation=cmd.register_navigation,
clear_navigation=cmd.clear_navigation,
kwargs=kwargs,
)
@@ -138,22 +91,33 @@ async def cammand_handler(message: Message | CallbackQuery, cmd: BotCommand, **k
callback_data=back_callback_data.pack(),
)
)
if message:
send_message = get_send_message(message)
if isinstance(message, CallbackQuery):
message = message.message
if callback_context.message_text:
await send_message(
text=callback_context.message_text,
reply_markup=callback_context.keyboard_builder.as_markup(),
)
else:
await message.edit_reply_markup(
elif isinstance(message, CallbackQuery):
await message.message.edit_reply_markup(
reply_markup=callback_context.keyboard_builder.as_markup()
)
else:
clear_state(state_data=state_data)
await route_callback(message, back=True, **kwargs)
if callback_context.message_text:
await app.bot.send_message(
chat_id=user.id,
text=callback_context.message_text,
reply_markup=callback_context.keyboard_builder.as_markup(),
)
if not callback_context.register_navigation:
if callback_context.clear_navigation:
clear_state(state_data=state_data, clear_nav=True)
await state.set_data(state_data)
else:
clear_state(state_data=state_data)
await route_callback(message, back=True, **kwargs)
from quickbot.bot.handlers.common.routing import route_callback # noqa: E402

View File

@@ -0,0 +1,60 @@
from aiogram import Router, F
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery
from typing import TYPE_CHECKING
from quickbot.utils.main import clear_state
from quickbot.utils.navigation import save_navigation_context
if TYPE_CHECKING:
from quickbot.main import QuickBot
from ..context import ContextData, CallbackCommand
from .command_handler import command_handler
router = Router()
@router.message(F.text.startswith("/"))
async def command_text(message: Message, **kwargs):
str_command = message.text.lstrip("/")
callback_data = ContextData(
command=CallbackCommand.USER_COMMAND, user_command=str_command
)
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
kwargs["state_data"] = state_data
await process_command_handler(
message=message, callback_data=callback_data, **kwargs
)
@router.callback_query(ContextData.filter(F.command == CallbackCommand.USER_COMMAND))
async def command_callback(message: CallbackQuery, **kwargs):
state: FSMContext = kwargs["state"]
state_data = await state.get_data()
kwargs["state_data"] = state_data
await process_command_handler(message=message, **kwargs)
async def process_command_handler(message: Message | CallbackQuery, **kwargs):
state_data: dict = kwargs["state_data"]
callback_data: ContextData = kwargs["callback_data"]
app: "QuickBot" = kwargs["app"]
cmd = app.bot_commands.get(callback_data.user_command.split("&")[0])
if cmd is None:
return
if cmd.clear_navigation:
state_data.pop("navigation_stack", None)
state_data.pop("navigation_context", None)
if cmd.register_navigation:
clear_state(state_data=state_data)
save_navigation_context(callback_data=callback_data, state_data=state_data)
await command_handler(message=message, cmd=cmd, **kwargs)

View File

@@ -9,6 +9,8 @@ class Config(BaseSettings):
env_file=".env", env_ignore_empty=True, extra="ignore"
)
STACK_NAME: str = "quickbot"
SECRET_KEY: str = "changethis"
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
@@ -26,17 +28,36 @@ class Config(BaseSettings):
API_PORT: int = 8000
TELEGRAM_WEBHOOK_URL: str = "http://localhost:8000"
TELEGRAM_WEBHOOK_DOMAIN: str = "localhost"
TELEGRAM_WEBHOOK_SCHEME: str = "https"
TELEGRAM_WEBHOOK_PORT: int = 443
@property
def TELEGRAM_WEBHOOK_URL(self) -> str:
return f"{self.TELEGRAM_WEBHOOK_SCHEME}://{self.TELEGRAM_WEBHOOK_DOMAIN}{
f':{self.TELEGRAM_WEBHOOK_PORT}'
if (
(
self.TELEGRAM_WEBHOOK_PORT != 80
and self.TELEGRAM_WEBHOOK_SCHEME == 'http'
)
or (
self.TELEGRAM_WEBHOOK_PORT != 443
and self.TELEGRAM_WEBHOOK_SCHEME == 'https'
)
)
else ''
}"
TELEGRAM_WEBHOOK_AUTH_KEY: str = "changethis"
TELEGRAM_BOT_USERNAME: str = "quickbot"
TELEGRAM_BOT_SERVER: str = "https://api.telegram.org"
TELEGRAM_BOT_SERVER_IS_LOCAL: bool = False
TELEGRAM_BOT_TOKEN: str = "changethis"
ADMIN_TELEGRAM_ID: int
USE_NGROK: bool = False
NGROK_AUTH_TOKEN: str = "changethis"
NGROK_URL: str = ""
LOG_LEVEL: str = "DEBUG"
def _check_default_secret(self, var_name: str, value: str | None) -> None:
@@ -55,8 +76,6 @@ class Config(BaseSettings):
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
self._check_default_secret("DB_PASSWORD", self.DB_PASSWORD)
self._check_default_secret("TELEGRAM_BOT_TOKEN", self.TELEGRAM_BOT_TOKEN)
if self.USE_NGROK:
self._check_default_secret("NGROK_AUTH_TOKEN", self.NGROK_AUTH_TOKEN)
return self

View File

@@ -1,19 +1,16 @@
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import sessionmaker
from typing import AsyncGenerator
from ..config import config
# import logging
# logger = logging.getLogger('sqlalchemy.engine')
# logger.setLevel(logging.DEBUG)
async_engine = create_async_engine(config.DATABASE_URI)
async_engine = create_async_engine(config.DATABASE_URI, pool_size=20, max_overflow=60)
async_session = sessionmaker[AsyncSession](
async_engine, class_=AsyncSession, expire_on_commit=False
)
async def get_db() -> AsyncSession: # type: ignore
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
yield session

View File

@@ -78,7 +78,7 @@ class DbStorage(BaseStorage):
db_data = (
await session.exec(select(FSMStorage).where(FSMStorage.key == db_key))
).first()
return json.loads(db_data.value) if db_data else {}
return json.loads(db_data.value) if db_data else {}
async def close(self):
return await super().close()

View File

@@ -0,0 +1,40 @@
import gettext as gt
import os
from contextlib import contextmanager
def get_translator(language, domain, localedir):
return gt.translation(domain, localedir, languages=[language])
def gettext(message: str) -> str:
return gt.gettext(message)
def get_local_text(message: str, locale: str) -> str:
return translators[locale].gettext(message)
@contextmanager
def use_locale(locale: str):
original_gettext = gt.gettext
gt.gettext = translators[locale].gettext
try:
yield
finally:
gt.gettext = original_gettext
locales_dir = os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, "locales")
locales = [
locale
for locale in os.listdir(locales_dir)
if os.path.isdir(os.path.join(locales_dir, locale))
]
translators = {
locale: get_translator(locale, "messages", locales_dir)
for locale in os.listdir(locales_dir)
if os.path.isdir(os.path.join(locales_dir, locale))
}

754
src/quickbot/main.py Normal file
View File

@@ -0,0 +1,754 @@
"""
main.py - QuickBot RAD Framework Main Application Module
Defines QuickBot, the main entry point for the QuickBot rapid application development (RAD) framework for Telegram bots.
Integrates FastAPI (HTTP API), Aiogram (Telegram bot logic), SQLModel (async DB), and i18n (internationalization).
Key Features:
- Dynamic registration of CRUD API endpoints for all entities
- Telegram bot command and webhook management
- Row-level security (RLS) and user management
- Middleware for authentication and localization
- Swagger UI with Telegram login integration
"""
from contextlib import asynccontextmanager
from inspect import iscoroutinefunction
from typing import Union
from typing import Annotated, Callable, Any, Generic, TypeVar
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
from aiogram.utils.i18n import I18n
from fastapi import Depends, FastAPI, Request, Body, Path, HTTPException
from fastapi.applications import Lifespan, AppType
from fastapi.datastructures import State
from logging import getLogger
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.openapi.utils import get_openapi
from sqlmodel.ext.asyncio.session import AsyncSession
from quickbot.bot.handlers.user_handlers.main import command_handler
from quickbot.db import get_db
from quickbot.model.list_schema import ListSchema
from quickbot.plugin import Registerable
from quickbot.utils.main import clear_state
from quickbot.utils.navigation import save_navigation_context
from quickbot.model.crud_service import NotFoundError, ForbiddenError
from .config import Config
from .bot.handlers.forms.entity_form import entity_item
from .fsm.db_storage import DbStorage
from .middleware.telegram import AuthMiddleware, I18nMiddleware
from .model.bot_entity import BotEntity
from .model.user import UserBase
from .model.bot_metadata import BotMetadata
from .model.descriptors import (
BotCommand,
EntityDescriptor,
ProcessDescriptor,
BotContext,
)
from .model.crud_command import CrudCommand
from .bot.handlers.context import CallbackCommand, ContextData
from .router import Router
from .api_route.models import list_entity_items, get_me
from .api_route.depends import get_current_user
logger = getLogger(__name__)
UserType = TypeVar("UserType", bound=UserBase, default=UserBase)
ConfigType = TypeVar("ConfigType", bound=Config, default=Config)
@asynccontextmanager
async def default_lifespan(app: "QuickBot"):
logger.debug("starting qbot app")
if app.lifespan_bot_init:
await app.bot_init()
if app.lifespan_set_webhook:
await app.set_webhook()
app.config.TELEGRAM_BOT_USERNAME = (await app.bot.get_me()).username
logger.info("qbot app started")
if app.lifespan:
async with app.lifespan(app) as state:
yield state
else:
yield
logger.info("qbot app stopped")
class QuickBot(Generic[UserType, ConfigType], FastAPI):
"""
Main application class for QuickBot RAD framework.
Integrates FastAPI, Aiogram, SQLModel, and i18n for rapid Telegram bot development.
Handles bot initialization, API registration, command routing, and RLS.
Args:
config: App configuration (see quickbot/config.py)
user_class: User model class (subclass of UserBase)
bot_start: Optional custom bot start handler
lifespan: Optional FastAPI lifespan context
lifespan_bot_init: Whether to run bot init on startup
lifespan_set_webhook: Whether to set webhook on startup
webhook_handler: Optional custom webhook handler
allowed_updates: List of Telegram update types to allow
"""
def __init__(
self,
config: ConfigType = Config(),
user_class: type[UserType] = None,
bot_start: Callable[
[
Callable[[Message, Any], tuple[UserType, bool]],
Message,
Any,
],
None,
] = None,
lifespan: Lifespan[AppType] | None = None,
lifespan_bot_init: bool = True,
lifespan_set_webhook: bool = True,
webhook_handler: Callable[["QuickBot", Request], Any] = None,
allowed_updates: list[str] | None = None,
**kwargs,
):
# --- Initialize default user class if not provided ---
if user_class is None:
from .model.default_user import DefaultUser
user_class = DefaultUser
self.allowed_updates = list(
(set(allowed_updates or [])).union({"message", "callback_query"})
)
self.user_class = user_class
self.bot_metadata: BotMetadata = user_class.bot_metadata
self.bot_metadata.app = self
self.config = config
self.lifespan = lifespan
# --- Setup Telegram API server and session ---
api_server = TelegramAPIServer.from_base(
self.config.TELEGRAM_BOT_SERVER,
is_local=self.config.TELEGRAM_BOT_SERVER_IS_LOCAL,
)
session = AiohttpSession(api=api_server)
# --- Initialize Telegram Bot instance ---
self.bot = Bot(
token=self.config.TELEGRAM_BOT_TOKEN,
session=session,
default=DefaultBotProperties(
parse_mode="HTML", link_preview_is_disabled=True
),
)
# --- Setup Aiogram dispatcher with DB storage for FSM ---
dp = Dispatcher(storage=DbStorage())
# --- Setup i18n and middleware ---
self.i18n = I18n(path="locales", default_locale="en", domain="messages")
i18n_middleware = I18nMiddleware(user_class=user_class, i18n=self.i18n)
i18n_middleware.setup(dp)
dp.callback_query.middleware(CallbackAnswerMiddleware())
# --- Register core routers (start, main menu) ---
from .bot.handlers.start import router as start_router
dp.include_router(start_router)
from .bot.handlers.menu.main import router as main_menu_router
# Register authentication middleware for menu routers
self.auth = AuthMiddleware(user_class=user_class)
main_menu_router.message.middleware.register(self.auth)
main_menu_router.callback_query.middleware.register(self.auth)
dp.include_router(main_menu_router)
self.dp = dp
# --- Extension points for custom bot start and webhook handlers ---
self.start_handler = bot_start
self.webhook_handler = webhook_handler
self.bot_commands = dict[str, BotCommand]()
self.lifespan_bot_init = lifespan_bot_init
self.lifespan_set_webhook = lifespan_set_webhook
# --- Initialize FastAPI with custom lifespan and no default docs ---
super().__init__(lifespan=default_lifespan, docs_url=None, **kwargs)
self.bot_metadata.app_state = self.state
# --- Initialize plugins ---
self.plugins = dict[str, Any]()
# --- Register Telegram API router for /telegram endpoints (for webhook and auth) ---
from .api_route.telegram import router as telegram_router
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
# --- Register all entity CRUD endpoints dynamically (for models API) ---
self.register_models_api()
# --- Register custom Swagger UI with Telegram login (for docs) ---
self.register_swagger_ui_html()
def register_plugin(self, plugin: Any):
self.plugins[type(plugin).__name__] = plugin
if isinstance(plugin, Registerable):
plugin.register(self)
def register_swagger_ui_html(self):
"""
Register a custom /docs endpoint with Telegram login widget and JWT support for Swagger UI.
"""
def swagger_ui_html():
return HTMLResponse(f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>QuickBot API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.26.2/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.26.2/swagger-ui-bundle.js" crossorigin></script>
<script>
function logout() {{
localStorage.removeItem("jwt");
window.ui.preauthorizeApiKey("bearerAuth", "invalid.token");
//location.reload();
}}
function injectTelegramWidget(jwt) {{
const auth_wrapper = document.querySelector(".auth-wrapper");
if (!auth_wrapper) return;
const oldAuthBtn = auth_wrapper.querySelector(".authorize");
if (oldAuthBtn) oldAuthBtn.remove();
const authContainer = document.createElement("div");
authContainer.className = "auth-info";
/*if (jwt) {{
try {{
console.log(jwt);
const payload = JSON.parse(atob(jwt.split('.')[1]));
const username = payload.username || payload.id;
authContainer.innerHTML = `
<span>👤 ${{username}}</span>
<button class="logout-btn" onclick="logout()">Logout</button>
`;
}} catch (e) {{
authContainer.textContent = "JWT error";
}}
}} else {{*/
const script = document.createElement("script");
script.async = true;
script.src = "https://telegram.org/js/telegram-widget.js";
script.setAttribute("data-telegram-login", "{self.config.TELEGRAM_BOT_USERNAME}");
script.setAttribute("data-size", "large");
script.setAttribute("data-onauth", "handleTelegramAuth(user)");
script.setAttribute("data-request-access", "write");
authContainer.appendChild(script);
//}}
auth_wrapper.appendChild(authContainer);
}}
function waitForElement(selector, callback) {{
const el = document.querySelector(selector);
if (el) {{
callback(el);
return;
}}
const observer = new MutationObserver(() => {{
const el = document.querySelector(selector);
if (el) {{
observer.disconnect();
callback(el);
}}
}});
observer.observe(document.body, {{ childList: true, subtree: true }});
}}
function handleTelegramAuth(user) {{
fetch('/telegram/auth', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(user)
}})
.then(res => res.json())
.then(data => {{
localStorage.setItem("jwt", data.access_token);
window.ui.preauthorizeApiKey("bearerAuth", data.access_token);
//location.reload();
}})
.catch(() => alert("Authorization error"));
}}
window.handleTelegramAuth = handleTelegramAuth;
window.onload = function () {{
const jwt = localStorage.getItem("jwt", null);
window.ui = SwaggerUIBundle({{
url: '/openapi.json',
dom_id: '#swagger-ui',
onComplete: function () {{
if (jwt) {{
window.ui.preauthorizeApiKey("bearerAuth", jwt);
}}
waitForElement(".auth-wrapper", (el) => {{
injectTelegramWidget(jwt);
}});
}}
}});
}};
</script>
</body>
</html>
""")
self.router.add_api_route(
path="/docs",
include_in_schema=False,
endpoint=swagger_ui_html,
methods=["GET"],
tags=["docs"],
)
def openapi_json():
schema = get_openapi(
title="FastAPI + Telegram OAuth",
version="1.0.0",
description="Swagger с Telegram Login",
routes=self.routes,
)
schema["components"]["securitySchemes"] = {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
}
}
for path in schema["paths"].values():
for op in path.values():
op.setdefault("security", [{"bearerAuth": []}])
return JSONResponse(schema)
self.router.add_api_route(
path="/openapi.json",
endpoint=openapi_json,
methods=["GET"],
tags=["docs"],
include_in_schema=False,
)
def register_models_api(self):
"""
Dynamically register CRUD API endpoints for all entities in the app's metadata.
Endpoints: list, create, get by id, update, delete.
Uses FastAPI dependency injection for database session and user authentication.
"""
def make_create_api_endpoint(entity_descriptor: EntityDescriptor):
async def create_entity(
db_session: Annotated[AsyncSession, Depends(get_db)],
request: Request,
obj_in: entity_descriptor.crud.create_schema = Body(...),
current_user=Depends(get_current_user),
):
try:
ret_obj = await entity_descriptor.crud.create(
db_session=db_session,
user=current_user,
model=obj_in,
)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=e.args[0])
except ForbiddenError as e:
raise HTTPException(status_code=403, detail=e.args[0])
except Exception as e:
logger.error(f"Error creating entity: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
return ret_obj
return create_entity
def make_update_api_endpoint(entity_descriptor: EntityDescriptor):
async def update_entity(
db_session: Annotated[AsyncSession, Depends(get_db)],
request: Request,
id: int = Path(..., description="ID of the entity to update"),
obj_in: entity_descriptor.crud.update_schema = Body(...),
current_user=Depends(get_current_user),
):
try:
entity = await entity_descriptor.crud.update(
db_session=db_session,
id=id,
model=obj_in,
user=current_user,
)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=e.args[0])
except ForbiddenError as e:
raise HTTPException(status_code=403, detail=e.args[0])
except Exception as e:
logger.error(f"Error updating entity: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
return entity
return update_entity
def make_delete_api_endpoint(entity_descriptor: EntityDescriptor):
async def delete_entity(
db_session: Annotated[AsyncSession, Depends(get_db)],
request: Request,
id: int = Path(..., description="ID of the entity to delete"),
current_user=Depends(get_current_user),
):
try:
entity = await entity_descriptor.crud.delete(
db_session=db_session,
id=id,
user=current_user,
)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=e.args[0])
except ForbiddenError as e:
raise HTTPException(status_code=403, detail=e.args[0])
except Exception as e:
logger.error(f"Error deleting entity: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
return entity
return delete_entity
def make_get_by_id_api_endpoint(entity_descriptor: EntityDescriptor):
async def get_entity_by_id(
db_session: Annotated[AsyncSession, Depends(get_db)],
request: Request,
id: int = Path(..., description="ID of the entity to get"),
current_user=Depends(get_current_user),
):
try:
entity = await entity_descriptor.type_.get(
session=db_session,
id=id,
)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=e.args[0])
except ForbiddenError as e:
raise HTTPException(status_code=403, detail=e.args[0])
except Exception as e:
logger.error(f"Error getting entity: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
return entity
return get_entity_by_id
def make_process_api_endpoint(process_descriptor: ProcessDescriptor):
async def run_process(
db_session: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[UserBase, Depends(get_current_user)],
request: Request,
obj_in: process_descriptor.input_schema = Body(...),
):
for role in current_user.roles:
if role in process_descriptor.roles:
break
else:
raise HTTPException(status_code=403, detail="Forbidden")
run_func = process_descriptor.process_class.run
bot_context = BotContext(
db_session=db_session,
app=current_user.bot_metadata.app,
app_state=current_user.bot_metadata.app_state,
user=current_user,
)
try:
if iscoroutinefunction(run_func):
result = await run_func(
context=bot_context,
parameters=obj_in,
)
else:
result = run_func(
context=bot_context,
parameters=obj_in,
)
return result
except Exception as e:
logger.error(f"Error running process: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
return run_process
for entity_descriptor in self.bot_metadata.entity_descriptors.values():
if issubclass(entity_descriptor.type_, UserBase):
self.router.add_api_route(
path=f"/models/{entity_descriptor.name}/me",
methods=["GET"],
endpoint=get_me,
response_model=entity_descriptor.crud.schema,
summary="Get current user",
description="Get current user",
tags=["models"],
)
if CrudCommand.LIST in entity_descriptor.crud.commands:
self.router.add_api_route(
path=f"/models/{entity_descriptor.name}",
endpoint=list_entity_items,
methods=["GET"],
response_model=list[
Union[entity_descriptor.crud.schema, ListSchema]
],
summary=f"List {entity_descriptor.name}",
description=f"List {entity_descriptor.name}",
tags=["models"],
)
if CrudCommand.CREATE in entity_descriptor.crud.commands:
self.router.add_api_route(
path=f"/models/{entity_descriptor.name}",
methods=["POST"],
endpoint=make_create_api_endpoint(entity_descriptor),
response_model=entity_descriptor.crud.schema,
summary=f"Create {entity_descriptor.name}",
description=f"Create {entity_descriptor.name}",
tags=["models"],
)
if CrudCommand.GET_BY_ID in entity_descriptor.crud.commands:
self.router.add_api_route(
path=f"/models/{entity_descriptor.name}/{{id}}",
methods=["GET"],
endpoint=make_get_by_id_api_endpoint(entity_descriptor),
response_model=entity_descriptor.crud.schema,
summary=f"Get {entity_descriptor.name} by id",
description=f"Get {entity_descriptor.name} by id",
tags=["models"],
)
if CrudCommand.UPDATE in entity_descriptor.crud.commands:
self.router.add_api_route(
path=f"/models/{entity_descriptor.name}/{{id}}",
methods=["PATCH"],
endpoint=make_update_api_endpoint(entity_descriptor),
response_model=Union[entity_descriptor.crud.schema, ListSchema],
summary=f"Update {entity_descriptor.name}",
description=f"Update {entity_descriptor.name}",
tags=["models"],
)
if CrudCommand.DELETE in entity_descriptor.crud.commands:
self.router.add_api_route(
path=f"/models/{entity_descriptor.name}/{{id}}",
methods=["DELETE"],
endpoint=make_delete_api_endpoint(entity_descriptor),
response_model=Union[entity_descriptor.crud.schema, ListSchema],
summary=f"Delete {entity_descriptor.name}",
description=f"Delete {entity_descriptor.name}",
tags=["models"],
)
for process_descriptor in self.bot_metadata.process_descriptors.values():
self.router.add_api_route(
path=f"/processes/{process_descriptor.name}",
methods=["POST"],
endpoint=make_process_api_endpoint(process_descriptor),
response_model=process_descriptor.output_schema,
summary=f"Run {process_descriptor.name}",
description=process_descriptor.description,
tags=["processes"],
)
def register_routers(self, *routers: Router):
# Register additional routers and their commands with the application.
# This allows modular extension of bot command sets and menu trees.
for router in routers:
for command_name, command in router._commands.items():
self.bot_commands[command_name] = command
async def bot_init(self):
# --- Set up bot commands for all locales ---
# This method collects all commands (with captions) that should be shown in the Telegram UI.
# It supports localization by grouping commands by locale.
commands_captions = dict[str, list[tuple[str, str]]]()
for command_name, command in self.bot_commands.items():
if command.show_in_bot_commands:
if isinstance(command.caption, str) or command.caption is None:
# Default locale (or no caption provided)
if "default" not in commands_captions:
commands_captions["default"] = []
commands_captions["default"].append(
(command_name, command.caption or command_name)
)
else:
# Localized captions per locale
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))
# Register commands with Telegram for each locale
for locale, commands in commands_captions.items():
await self.bot.set_my_commands(
[
AiogramBotCommand(command=command[0], description=command[1])
for command in commands
],
language_code=None if locale == "default" else locale,
)
async def set_webhook(self):
# --- Set Telegram webhook for receiving updates ---
# This is called on startup if lifespan_set_webhook is True.
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.config.TELEGRAM_WEBHOOK_AUTH_KEY,
)
async def show_form(
self,
app_state: State,
user_id: int,
entity: type[BotEntity] | str,
entity_id: int,
db_session: AsyncSession,
form_name: str = None,
form_params: list[Any] = None,
):
# --- Show a form for a specific entity instance to a user ---
# Used for interactive entity editing or viewing in the Telegram bot UI.
f_params = []
if form_name:
f_params.append(form_name)
if form_params:
f_params.extend([str(p) for p in form_params])
# Allow passing entity as class or string name
if isinstance(entity, type):
entity = entity.bot_entity_descriptor.name
# Prepare callback data for navigation stack
callback_data = ContextData(
command=CallbackCommand.ENTITY_ITEM,
entity_name=entity,
entity_id=entity_id,
form_params="&".join(f_params),
)
# Get FSM state context for the user
state = self.dp.fsm.get_context(bot=self.bot, chat_id=user_id, user_id=user_id)
state_data = await state.get_data()
clear_state(state_data=state_data)
stack = save_navigation_context(
callback_data=callback_data, state_data=state_data
)
await state.set_data(state_data)
# Fetch user object for locale and permissions
user = await self.user_class.get(
session=db_session,
id=user_id,
)
# Use i18n context for the user's language
with self.i18n.context(), self.i18n.use_locale(user.lang.value):
await entity_item(
query=None,
db_session=db_session,
callback_data=callback_data,
app=self,
user=user,
navigation_stack=stack,
state=state,
state_data=state_data,
i18n=self.i18n,
app_state=app_state,
)
async def execute_command(
self,
app_state: State,
command: str,
user_id: int,
db_session: AsyncSession,
):
# --- Execute a user command in the Telegram bot context ---
# This is used for programmatically triggering bot commands (e.g., from API or callback).
state = self.dp.fsm.get_context(bot=self.bot, chat_id=user_id, user_id=user_id)
state_data = await state.get_data()
callback_data = ContextData(
command=CallbackCommand.USER_COMMAND,
user_command=command,
)
command_name = command.split("&")[0]
cmd = self.bot_commands.get(command_name)
# Fetch user object for permissions and locale
user = await self.user_class.get(
session=db_session,
id=user_id,
)
if cmd is None:
# Command not found (could be a custom or unregistered command)
return
# Optionally clear navigation stack if command requires it
if cmd.clear_navigation:
state_data.pop("navigation_stack", None)
state_data.pop("navigation_context", None)
# Optionally register navigation context for this command
if cmd.register_navigation:
clear_state(state_data=state_data)
save_navigation_context(callback_data=callback_data, state_data=state_data)
# Use i18n context for the user's language
with self.i18n.context(), self.i18n.use_locale(user.lang.value):
await command_handler(
message=None,
cmd=cmd,
db_session=db_session,
callback_data=callback_data,
app=self,
user=user,
state=state,
state_data=state_data,
i18n=self.i18n,
app_state=app_state,
)

View File

@@ -23,9 +23,15 @@ class AuthMiddleware(BaseMiddleware):
event: TelegramObject,
data: Dict[str, Any],
) -> Any:
user = await self.user_class.get(
id=event.from_user.id, session=data["db_session"]
)
if event.business_connection_id:
business_connection = await event.bot.get_business_connection(
event.business_connection_id
)
user_id = business_connection.user.id
else:
user_id = event.from_user.id
user = await self.user_class.get(id=user_id, session=data["db_session"])
if user and user.is_active:
data["user"] = user

View File

@@ -17,7 +17,7 @@ class I18nMiddleware(SimpleI18nMiddleware):
async def get_locale(self, event: TelegramObject, data: Dict[str, Any]) -> str:
db_session = data.get("db_session")
if db_session and event.model_fields.get("from_user"):
if db_session and event.__dict__.get("from_user"):
user = await self.user_class.get(id=event.from_user.id, session=db_session)
if user and user.lang:
return user.lang.value

View File

@@ -6,13 +6,19 @@ from typing import cast
from .bot_enum import BotEnum, EnumMember
from ..db import async_session
from .user import UserBase
from .role import RoleBase
from .language import LanguageBase
__all__ = ["UserBase", "RoleBase", "LanguageBase"]
class EntityPermission(BotEnum):
LIST = EnumMember("list")
READ = EnumMember("read")
CREATE = EnumMember("create")
UPDATE = EnumMember("update")
DELETE = EnumMember("delete")
LIST_RLS = EnumMember("list_rls")
READ_RLS = EnumMember("read_rls")
CREATE_RLS = EnumMember("create_rls")
UPDATE_RLS = EnumMember("update_rls")
DELETE_RLS = EnumMember("delete_rls")
LIST_ALL = EnumMember("list_all")
READ_ALL = EnumMember("read_all")
CREATE_ALL = EnumMember("create_all")

View File

@@ -0,0 +1,46 @@
"""
BotEntity module provides a metaclass and base class for creating database entities
with enhanced functionality for bot operations, including field descriptors,
filtering, and ownership management.
"""
from typing import (
TYPE_CHECKING,
dataclass_transform,
)
from pydantic import BaseModel
from pydantic._internal._model_construction import ModelMetaclass
from sqlmodel import Field
from sqlmodel.main import FieldInfo
from .descriptors import EntityField, FieldDescriptor
if TYPE_CHECKING:
pass
@dataclass_transform(
kw_only_default=True,
field_specifiers=(Field, FieldInfo, EntityField, FieldDescriptor),
)
class AnnotatedSchemaMetaclass(ModelMetaclass):
"""
Metaclass for annotated schemas.
"""
def __new__(mcs, name, bases, namespace, **kwargs):
"""
Create a new annotated schema.
"""
# --- Create the class using parent metaclass ---
type_ = super().__new__(mcs, name, bases, namespace, **kwargs)
return type_
class AnnotatedSchema(BaseModel, metaclass=AnnotatedSchemaMetaclass):
"""
Base class for annotated schemas.
"""

View File

@@ -0,0 +1,579 @@
"""
BotEntity module provides a metaclass and base class for creating database entities
with enhanced functionality for bot operations, including field descriptors,
filtering, and ownership management.
"""
from types import NoneType, UnionType
from typing import (
Any,
ClassVar,
ForwardRef,
Optional,
Union,
get_args,
get_origin,
TYPE_CHECKING,
dataclass_transform,
Self,
)
from pydantic import BaseModel
from pydantic.fields import _Unset
from pydantic_core import PydanticUndefined
from sqlmodel import SQLModel, BigInteger, Field, select, func
from sqlmodel.main import FieldInfo
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.main import SQLModelMetaclass, RelationshipInfo
from .descriptors import (
EntityDescriptor,
EntityField,
FieldDescriptor,
Filter,
FilterExpression,
)
from .bot_metadata import BotMetadata
from .crud_service import CrudService
from . import session_dep
from .utils import (
_static_filter_condition,
_build_filter_condition,
_filter_condition,
_apply_rls_filters,
)
if TYPE_CHECKING:
from .user import UserBase
@dataclass_transform(
kw_only_default=True,
field_specifiers=(Field, FieldInfo, EntityField, FieldDescriptor),
)
class BotEntityMetaclass(SQLModelMetaclass):
"""
Metaclass for BotEntity that handles field processing, descriptor creation,
and type resolution for bot-specific database entities.
This metaclass extends SQLModelMetaclass to provide additional functionality
for bot operations including field descriptors, type annotations, and
entity metadata management.
"""
# Store future references for forward-declared types
_future_references = {}
def __new__(mcs, name, bases, namespace, **kwargs):
"""
Create a new class with processed field descriptors and metadata.
Args:
name: Name of the class being created
bases: Base classes
namespace: Class namespace containing attributes and annotations
**kwargs: Additional keyword arguments passed to the metaclass
Returns:
The created class with processed field descriptors and metadata
"""
bot_fields_descriptors = {}
# --- Inherit field descriptors from parent classes (if any) ---
if bases:
bot_entity_descriptor = bases[0].__dict__.get("bot_entity_descriptor")
bot_fields_descriptors = (
{
key: FieldDescriptor(**value.__dict__.copy())
for key, value in bot_entity_descriptor.fields_descriptors.items()
}
if bot_entity_descriptor
else {}
)
# --- Process field annotations to create field descriptors ---
if "__annotations__" in namespace:
for annotation in namespace["__annotations__"]:
# Skip special attributes
if annotation in ["bot_entity_descriptor", "bot_metadata"]:
continue
attribute_value = namespace.get(annotation, PydanticUndefined)
# Skip relationship fields (handled by SQLModel)
if isinstance(attribute_value, RelationshipInfo):
continue
descriptor_kwargs = {}
descriptor_name = annotation
# --- Process EntityField attributes to extract SQLModel field descriptors ---
if attribute_value is not PydanticUndefined:
if isinstance(attribute_value, EntityField):
descriptor_kwargs = attribute_value.__dict__.copy()
# Extract SQLModel field descriptor if present
sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None) # type: FieldInfo
if sm_descriptor:
# Transfer default values from EntityField to SQLModel descriptor
if (
attribute_value.default is not PydanticUndefined
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 None
):
sm_descriptor.default_factory = (
attribute_value.default_factory
)
if attribute_value.description is not PydanticUndefined:
sm_descriptor.description = attribute_value.description
else:
# Create new SQLModel field descriptor if none exists
if (
attribute_value.default is not None
or attribute_value.default_factory is not None
):
sm_descriptor = Field()
if attribute_value.default is not PydanticUndefined:
sm_descriptor.default = attribute_value.default
if attribute_value.default_factory is not None:
sm_descriptor.default_factory = (
attribute_value.default_factory
)
if attribute_value.description is not PydanticUndefined:
sm_descriptor.description = (
attribute_value.description
)
# Clean up internal attributes
descriptor_kwargs.pop("__orig_class__", None)
# Replace EntityField with SQLModel field descriptor in namespace
if sm_descriptor:
namespace[annotation] = sm_descriptor
else:
namespace.pop(annotation)
descriptor_name = descriptor_kwargs.pop("name") or annotation
elif isinstance(attribute_value, FieldInfo):
if attribute_value.default is not PydanticUndefined:
descriptor_kwargs["default"] = attribute_value.default
if attribute_value.default_factory is not None:
descriptor_kwargs["default_factory"] = (
attribute_value.default_factory
)
if attribute_value.description is not _Unset:
descriptor_kwargs["description"] = (
attribute_value.description
)
elif isinstance(attribute_value, RelationshipInfo):
pass
else:
descriptor_kwargs["default"] = attribute_value
# --- Get the type annotation for the field ---
type_ = namespace["__annotations__"][annotation]
# --- Create field descriptor with basic information ---
field_descriptor = FieldDescriptor(
name=descriptor_name,
field_name=annotation,
type_=type_,
type_base=type_,
**descriptor_kwargs,
)
# --- Process type annotations to determine if field is list or optional ---
type_origin = get_origin(type_)
is_list = False
is_optional = False
# Handle list types (e.g., List[str])
if type_origin is list:
field_descriptor.is_list = is_list = True
field_descriptor.type_base = type_ = get_args(type_)[0]
# Handle Union types for optional fields (e.g., Optional[str])
if type_origin is Union:
args = get_args(type_)
if isinstance(args[0], ForwardRef):
field_descriptor.is_optional = is_optional = True
field_descriptor.type_base = type_ = args[0].__forward_arg__
elif args[1] is NoneType:
field_descriptor.is_optional = is_optional = True
field_descriptor.type_base = type_ = args[0]
# Handle Python 3.10+ UnionType (e.g., str | None)
if type_origin is UnionType and get_args(type_)[1] is NoneType:
field_descriptor.is_optional = is_optional = True
field_descriptor.type_base = type_ = get_args(type_)[0]
# --- Handle string type references (forward references to other entities) ---
if isinstance(type_, str):
type_not_found = True
for entity_descriptor in BotMetadata().entity_descriptors.values():
if type_ == entity_descriptor.class_name:
# Resolve the type to the actual entity class
field_descriptor.type_base = entity_descriptor.type_
field_descriptor.type_ = (
list[entity_descriptor.type_]
if is_list
else (
Optional[entity_descriptor.type_]
if type_origin == Union and is_optional
else (
entity_descriptor.type_ | None
if (type_origin == UnionType and is_optional)
else entity_descriptor.type_
)
)
)
type_not_found = False
break
# If type not found, store for future resolution
if type_not_found:
if type_ in mcs._future_references:
mcs._future_references[type_].append(field_descriptor)
else:
mcs._future_references[type_] = [field_descriptor]
bot_fields_descriptors[descriptor_name] = field_descriptor
# --- Process entity descriptor configuration ---
descriptor_name = name
if "bot_entity_descriptor" in namespace:
# Extract and process custom entity descriptor
entity_descriptor = namespace.pop("bot_entity_descriptor")
descriptor_kwargs: dict = entity_descriptor.__dict__.copy()
descriptor_name = descriptor_kwargs.pop("name", None)
descriptor_kwargs.pop("__orig_class__", None)
descriptor_name = descriptor_name or name.lower()
entity_descriptor = namespace["bot_entity_descriptor"] = EntityDescriptor(
name=descriptor_name,
class_name=name,
type_=name,
fields_descriptors=bot_fields_descriptors,
**descriptor_kwargs,
)
else:
# Create default entity descriptor
descriptor_name = name.lower()
entity_descriptor = namespace["bot_entity_descriptor"] = EntityDescriptor(
name=descriptor_name,
class_name=name,
type_=name,
fields_descriptors=bot_fields_descriptors,
)
# --- Link field descriptors to their entity descriptor ---
for field_descriptor in bot_fields_descriptors.values():
field_descriptor.entity_descriptor = entity_descriptor
# --- Configure table settings (set to True by default) ---
if "table" not in kwargs:
kwargs["table"] = True
# --- If table is set to True, register entity in global metadata ---
if kwargs["table"]:
# Register entity in global metadata
entity_metadata = BotMetadata()
entity_metadata.entity_descriptors[descriptor_name] = entity_descriptor
# Add entity_metadata to class annotations
if "__annotations__" in namespace:
namespace["__annotations__"]["bot_metadata"] = ClassVar[BotMetadata]
else:
namespace["__annotations__"] = {"bot_metadata": ClassVar[BotMetadata]}
namespace["bot_metadata"] = entity_metadata
# --- Create the class using parent metaclass ---
type_ = super().__new__(mcs, name, bases, namespace, **kwargs)
# --- Resolve future references now that the class exists ---
if name in mcs._future_references:
for field_descriptor in mcs._future_references[name]:
type_origin = get_origin(field_descriptor.type_)
field_descriptor.type_base = type_
field_descriptor.type_ = (
list[type_]
if type_origin is list
else (
Optional[type_]
if type_origin == Union
and isinstance(get_args(field_descriptor.type_)[0], ForwardRef)
else type_ | None
if type_origin == UnionType
else type_
)
)
# --- Set the resolved type in the entity descriptor ---
entity_descriptor.type_ = type_
# setattr(entity_descriptor, "type_", type_)
if kwargs["table"] and entity_descriptor.crud is None:
entity_descriptor.crud = CrudService(entity_descriptor)
return type_
class BotEntity(SQLModel, metaclass=BotEntityMetaclass, table=False):
"""
Base class for bot entities that provides CRUD operations, filtering,
and Row Level Security (RLS) capabilities.
This class extends SQLModel and uses BotEntityMetaclass to provide
enhanced functionality for bot operations including:
- Field descriptors for UI generation
- Advanced filtering and search capabilities
- Row Level Security (RLS) access control
- Standardized CRUD operations
"""
# Class variables set by the metaclass
bot_entity_descriptor: ClassVar[EntityDescriptor]
bot_metadata: ClassVar[BotMetadata]
# Standard ID field for all entities
id: int = EntityField(
sm_descriptor=Field(primary_key=True, sa_type=BigInteger),
is_visible=False,
default=None,
)
@classmethod
@session_dep
async def get(
cls,
*,
session: AsyncSession | None = None,
id: int,
user: "UserBase | None" = None,
) -> Self:
"""
Retrieve a single entity by ID.
Args:
session: Database session (injected by session_dep)
id: Entity ID to retrieve
Returns:
The entity instance or None if not found
"""
select_statement = select(cls).where(cls.id == id)
if user:
select_statement = await _apply_rls_filters(cls, select_statement, user)
return await session.scalar(select_statement)
@classmethod
@session_dep
async def get_count(
cls,
*,
session: AsyncSession | None = None,
user: "UserBase",
static_filter: Filter | FilterExpression | Any = None,
filter: str = None,
filter_fields: list[str] = None,
ext_filter: Any = None,
) -> int:
"""
Get the count of entities matching the specified criteria.
Args:
session: Database session (injected by session_dep)
static_filter: List of static filter conditions
filter: Text search filter
filter_fields: Fields to search in for text filter
ext_filter: Additional custom filter conditions
user: User for RLS-based filtering
Returns:
Count of matching entities
"""
# --- Build select statement for counting entities ---
select_statement = select(func.count()).select_from(cls)
# --- Apply various filter conditions ---
if static_filter:
if isinstance(static_filter, list):
select_statement = _static_filter_condition(
select_statement, static_filter
)
else:
# Handle single Filter or FilterExpression object
condition = _build_filter_condition(cls, static_filter)
if condition is not None:
select_statement = select_statement.where(condition)
if filter and filter_fields:
select_statement = _filter_condition(
select_statement, filter, filter_fields
)
if ext_filter:
select_statement = select_statement.where(ext_filter)
select_statement = await _apply_rls_filters(cls, select_statement, user)
return await session.scalar(select_statement)
@classmethod
@session_dep
async def get_multi(
cls,
*,
session: AsyncSession | None = None,
user: "UserBase | None" = None,
order_by=None,
static_filter: Filter | FilterExpression | Any = None,
filter: str = None,
filter_fields: list[str] = None,
ext_filter: Any = None,
skip: int = 0,
limit: int = None,
) -> list[Self]:
"""
Retrieve multiple entities with filtering, pagination, and ordering.
Args:
session: Database session (injected by session_dep)
order_by: Ordering criteria
static_filter: List of static filter conditions
filter: Text search filter
filter_fields: Fields to search in for text filter
ext_filter: Additional custom filter conditions
user: User for RLS-based filtering
skip: Number of records to skip (for pagination)
limit: Maximum number of records to return
Returns:
List of matching entity instances
"""
# --- Build select statement for entity retrieval ---
select_statement = select(cls).offset(skip)
if limit:
select_statement = select_statement.limit(limit)
# --- Apply various filter conditions ---
if static_filter is not None:
if isinstance(static_filter, list):
select_statement = _static_filter_condition(
cls, select_statement, static_filter
)
else:
# Handle single Filter or FilterExpression object
condition = _build_filter_condition(cls, static_filter)
if condition is not None:
select_statement = select_statement.where(condition)
if filter and filter_fields:
select_statement = _filter_condition(
cls, select_statement, filter, filter_fields
)
if ext_filter is not None:
select_statement = select_statement.where(ext_filter)
if user:
select_statement = await _apply_rls_filters(cls, select_statement, user)
if order_by:
select_statement = select_statement.order_by(order_by)
return (await session.exec(select_statement)).all()
@classmethod
@session_dep
async def create(
cls,
*,
session: AsyncSession | None = None,
obj_in: BaseModel,
commit: bool = False,
) -> Self:
"""
Create a new entity instance.
Args:
session: Database session (injected by session_dep)
obj_in: Data for creating the entity (can be Pydantic model or entity instance)
commit: Whether to commit the transaction immediately
Returns:
The created entity instance
"""
# --- Accept both entity instances and Pydantic models ---
if isinstance(obj_in, cls):
obj = obj_in
else:
obj = cls(**obj_in.model_dump())
session.add(obj)
if commit:
await session.commit()
return obj
@classmethod
@session_dep
async def update(
cls,
*,
session: AsyncSession | None = None,
id: int,
obj_in: BaseModel,
commit: bool = False,
) -> Self:
"""
Update an existing entity instance.
Args:
session: Database session (injected by session_dep)
id: ID of the entity to update
obj_in: Data for updating the entity
commit: Whether to commit the transaction immediately
Returns:
The updated entity instance or None if not found
"""
obj = await session.get(cls, id)
if obj:
obj_data = obj.model_dump()
update_data = obj_in.model_dump(exclude_unset=True)
# Only update fields present in the update data
for field in obj_data:
if field in update_data:
setattr(obj, field, update_data[field])
session.add(obj)
if commit:
await session.commit()
return obj
return None
@classmethod
@session_dep
async def remove(
cls, *, session: AsyncSession | None = None, id: int, commit: bool = False
) -> Self:
"""
Delete an entity instance.
Args:
session: Database session (injected by session_dep)
id: ID of the entity to delete
commit: Whether to commit the transaction immediately
Returns:
The deleted entity instance or None if not found
"""
obj = await session.get(cls, id)
if obj:
await session.delete(obj)
if commit:
await session.commit()
return obj
return None

View File

@@ -1,5 +1,8 @@
from aiogram.utils.i18n import I18n
from pydantic_core.core_schema import str_schema
from pydantic import GetCoreSchemaHandler
# from pydantic_core.core_schema import str_schema
from pydantic_core import core_schema
from sqlalchemy.types import TypeDecorator
from sqlmodel import AutoString
from typing import Any, Self, overload
@@ -59,13 +62,13 @@ class BotEnumMetaclass(type):
class EnumMember(object):
@overload
def __init__(self, value: str) -> "EnumMember": ...
def __init__(self, value: str) -> Self: ...
@overload
def __init__(self, value: "EnumMember") -> "EnumMember": ...
def __init__(self, value: Self) -> Self: ...
@overload
def __init__(self, value: str, loc_obj: dict[str, str]) -> "EnumMember": ...
def __init__(self, value: str, loc_obj: dict[str, str]) -> Self: ...
def __init__(
self,
@@ -74,7 +77,7 @@ class EnumMember(object):
parent: type = None,
name: str = None,
casting: bool = True,
) -> "EnumMember":
) -> Self:
if not casting:
self._parent = parent
self._name = name
@@ -82,9 +85,9 @@ class EnumMember(object):
self.loc_obj = loc_obj
@overload
def __new__(cls: Self, *args, **kwargs) -> "EnumMember": ...
def __new__(cls: Self, *args, **kwargs) -> Self: ...
def __new__(cls, *args, casting: bool = True, **kwargs) -> "EnumMember":
def __new__(cls, *args, casting: bool = True, **kwargs) -> Self:
if (cls.__name__ == "EnumMember") or not casting:
obj = super().__new__(cls)
kwargs["casting"] = False
@@ -93,9 +96,10 @@ class EnumMember(object):
if args.__len__() == 0:
return list(cls.all_members.values())[0]
if args.__len__() == 1 and isinstance(args[0], str):
return {member.value: member for key, member in cls.all_members.items()}[
args[0]
]
for key, member in cls.all_members.items():
if member.value == args[0]:
return member
return None
elif args.__len__() == 1:
return {member.value: member for key, member in cls.all_members.items()}[
args[0].value
@@ -103,8 +107,8 @@ class EnumMember(object):
else:
return args[0]
def __get_pydantic_core_schema__(cls, *args, **kwargs):
return str_schema()
# def __get_pydantic_core_schema__(cls, *args, **kwargs):
# return str_schema()
def __get__(self, instance, owner) -> Self:
return {
@@ -135,6 +139,13 @@ class EnumMember(object):
def __hash__(self):
return hash(self.value)
def __lt__(self, other: Self | str | Any | None) -> bool:
if isinstance(other, str):
return self.value < other
if isinstance(other, EnumMember):
return self.value < other.value
return False
def localized(self, lang: str = None) -> str:
if self.loc_obj:
if not lang:
@@ -151,6 +162,29 @@ class EnumMember(object):
return self.value
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: type, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
return core_schema.no_info_after_validator_function(
function=cls._validate_from_string,
schema=core_schema.str_schema(),
serialization=core_schema.plain_serializer_function_ser_schema(
cls._serialize_to_string, return_schema=core_schema.str_schema()
),
)
@classmethod
def _validate_from_string(cls, value: str) -> Self:
member = cls(value)
if member is None:
raise ValueError(f"Invalid value for {cls.__name__}: {value}")
return member
@classmethod
def _serialize_to_string(cls, value: Self) -> str:
return value.value
class BotEnum(EnumMember, metaclass=BotEnumMetaclass):
all_members: dict[str, EnumMember]

View File

@@ -0,0 +1,16 @@
from fastapi.datastructures import State
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from quickbot.main import QuickBot
from .descriptors import EntityDescriptor, ProcessDescriptor
from ._singleton import Singleton
class BotMetadata(metaclass=Singleton):
def __init__(self):
self.entity_descriptors: dict[str, EntityDescriptor] = {}
self.process_descriptors: dict[str, ProcessDescriptor] = {}
self.app: "QuickBot" = None
self.app_state: State = None

View File

@@ -0,0 +1,77 @@
import inspect
from typing_extensions import get_type_hints
from pydantic import BaseModel
from typing import ClassVar
from .descriptors import ProcessDescriptor, BotContext, Process
from .bot_metadata import BotMetadata
class BotProcessMetaclass(type):
def __new__(mcs, name, bases, namespace, **kwargs):
if name == "BotProcess":
namespace.pop("run")
return super().__new__(mcs, name, bases, namespace, **kwargs)
run_attr = namespace.get("run", None)
if run_attr is None or not isinstance(run_attr, staticmethod):
raise TypeError(f"{name}.run must be defined as a @staticmethod")
func = run_attr.__func__
sig = inspect.signature(func)
if list(sig.parameters.keys()) not in [["context", "parameters"], ["context"]]:
raise TypeError(
f"{name}.run must have exactly two arguments: context, parameters or one argument: context"
)
hints = get_type_hints(func, globalns=globals(), localns=namespace)
# Check arguments
ctx_type = hints.get("context")
if ctx_type is None or not issubclass(ctx_type, BotContext):
raise TypeError(f"{name}.run: 'context' must be BotContext")
param_type = hints.get("parameters")
if param_type is not None and not issubclass(param_type, BaseModel):
raise TypeError(f"{name}.run: 'parameters' must be subclass of BaseModel")
return_type = hints.get("return")
# Auto-generation of schemas
descriptor_kwargs = {"process_class": None}
process_descriptor = namespace.pop("bot_process_descriptor", None)
if process_descriptor and isinstance(process_descriptor, Process):
descriptor_kwargs.update(process_descriptor.__dict__)
descriptor_kwargs.update(
name=name,
input_schema=param_type,
output_schema=return_type,
)
descriptor = ProcessDescriptor(**descriptor_kwargs)
namespace["bot_process_descriptor"] = descriptor
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
descriptor.process_class = cls
bot_metadata = BotMetadata()
bot_metadata.process_descriptors[descriptor.name] = descriptor
namespace["bot_metadata"] = bot_metadata
return super().__new__(mcs, name, bases, namespace, **kwargs)
class BotProcess(metaclass=BotProcessMetaclass):
"""
Base class for business logic processes.
"""
bot_process_descriptor: ClassVar[ProcessDescriptor]
bot_metadata: ClassVar[BotMetadata]
@staticmethod
def run(context: BotContext, parameters: BaseModel = None): ...

View File

@@ -0,0 +1,9 @@
from enum import StrEnum
class CrudCommand(StrEnum):
LIST = "list"
GET_BY_ID = "get_by_id"
CREATE = "create"
UPDATE = "update"
DELETE = "delete"

View File

@@ -0,0 +1,374 @@
from sqlmodel import select, col
from sqlmodel.ext.asyncio.session import AsyncSession
from pydantic import BaseModel
from quickbot.model.permissions import get_user_permissions
from quickbot.model.descriptors import EntityDescriptor, BotContext
from quickbot.model.crud_command import CrudCommand
from quickbot.model.utils import (
entity_to_schema,
entity_to_list_schema,
pydantic_model,
)
from quickbot.model.descriptors import EntityPermission
from sqlalchemy.exc import IntegrityError
from asyncpg import ForeignKeyViolationError
from logging import getLogger
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from quickbot.model.user import UserBase
logger = getLogger(__name__)
class NotFoundError(Exception):
pass
class ForbiddenError(Exception):
pass
class CrudService:
def __init__(
self,
entity_descriptor: EntityDescriptor,
commands: list[CrudCommand] = [
CrudCommand.LIST,
CrudCommand.GET_BY_ID,
CrudCommand.CREATE,
CrudCommand.UPDATE,
CrudCommand.DELETE,
],
schema: type[BaseModel] = None,
create_schema: type[BaseModel] = None,
update_schema: type[BaseModel] = None,
):
self.entity_descriptor = entity_descriptor
self.commands = commands
if CrudCommand.CREATE in commands:
self.create_schema = create_schema or pydantic_model(
entity_descriptor, entity_descriptor.type_.__module__, "create"
)
else:
self.create_schema = None
if CrudCommand.UPDATE in commands:
self.update_schema = update_schema or pydantic_model(
entity_descriptor, entity_descriptor.type_.__module__, "update"
)
else:
self.update_schema = None
self.schema = schema or pydantic_model(
entity_descriptor, entity_descriptor.type_.__module__
)
async def list_all(
self, db_session: AsyncSession, user: "UserBase"
) -> list[BaseModel]:
if CrudCommand.LIST not in self.commands:
raise ForbiddenError(
f"List command not allowed for entity {self.entity_descriptor.name}"
)
user_permissions = get_user_permissions(user, self.entity_descriptor)
if (
EntityPermission.READ_ALL in user_permissions
or EntityPermission.READ_RLS in user_permissions
):
ret_list = await self.entity_descriptor.type_.get_multi(
session=db_session,
user=user
if EntityPermission.READ_ALL not in user_permissions
else None,
)
return [entity_to_schema(item) for item in ret_list]
elif (
EntityPermission.LIST_ALL in user_permissions
or EntityPermission.LIST_RLS in user_permissions
):
ret_list = await self.entity_descriptor.type_.get_multi(
session=db_session,
user=user
if EntityPermission.LIST_ALL not in user_permissions
else None,
)
context = BotContext(
db_session=db_session,
app=user.bot_metadata.app,
app_state=user.bot_metadata.app_state,
user=user,
)
return [await entity_to_list_schema(item, context) for item in ret_list]
else:
raise ForbiddenError(
f"User {user.id} does not have permission to read or list entities"
)
async def get_by_id(
self, db_session: AsyncSession, user: "UserBase", id: int
) -> BaseModel:
if CrudCommand.GET_BY_ID not in self.commands:
raise ForbiddenError(
f"Get by id command not allowed for entity {self.entity_descriptor.name}"
)
ret_obj = await self.entity_descriptor.type_.get(session=db_session, id=id)
if ret_obj is None:
raise NotFoundError(f"Entity with id {id} not found")
return entity_to_schema(ret_obj)
async def create(
self, db_session: AsyncSession, user: "UserBase", model: BaseModel
) -> BaseModel:
if CrudCommand.CREATE not in self.commands:
raise ForbiddenError(
f"Create command not allowed for entity {self.entity_descriptor.name}"
)
user_permissions = get_user_permissions(user, self.entity_descriptor)
if EntityPermission.CREATE_ALL in user_permissions:
# TODO: check if entity values are valid
pass
elif EntityPermission.CREATE_RLS in user_permissions:
# TODO: check if RLS fields are valid
# TODO: check if entity values are valid
pass
else:
raise ForbiddenError(
f"User {user.id} does not have permission to create entities"
)
obj_dict = {}
ret_obj_dict = {}
for field_descriptor in self.entity_descriptor.fields_descriptors.values():
# Only process fields present in the input model
field_name = field_descriptor.field_name
if field_name in model.__class__.model_fields:
# Handle list fields that are relations to other BotEntities
if (
field_descriptor.is_list
and isinstance(field_descriptor.type_base, type)
and hasattr(field_descriptor.type_base, "bot_entity_descriptor")
):
items = (
await db_session.exec(
select(field_descriptor.type_base).where(
col(field_descriptor.type_base.id).in_(
getattr(model, field_name)
)
)
)
).all()
obj_dict[field_name] = items
ret_obj_dict[field_name] = [item.id for item in items]
elif isinstance(field_descriptor.type_base, type) and hasattr(
field_descriptor.type_base, "all_members"
):
if field_descriptor.is_list:
obj_dict[field_name] = [
field_descriptor.type_base(item)
for item in getattr(model, field_name)
]
else:
obj_dict[field_name] = field_descriptor.type_base(
getattr(model, field_name)
)
ret_obj_dict[field_name] = getattr(model, field_name)
else:
obj_dict[field_name] = getattr(model, field_name)
ret_obj_dict[field_name] = getattr(model, field_name)
obj = self.entity_descriptor.type_(**obj_dict)
db_session.add(obj)
try:
await db_session.commit()
except IntegrityError as e:
if isinstance(e.orig.__cause__, ForeignKeyViolationError):
raise ValueError(e.orig.__cause__.detail)
raise ValueError("DB Integrity error")
except Exception as e:
logger.error(f"Error creating entity: {e}")
raise e
if "id" not in ret_obj_dict:
ret_obj_dict["id"] = obj.id
return self.schema(**ret_obj_dict)
async def update(
self, db_session: AsyncSession, user: "UserBase", id: int, model: BaseModel
) -> BaseModel:
if CrudCommand.UPDATE not in self.commands:
raise ForbiddenError(
f"Update command not allowed for entity {self.entity_descriptor.name}"
)
user_permissions = get_user_permissions(user, self.entity_descriptor)
if EntityPermission.UPDATE_ALL in user_permissions:
# TODO: check if entity values are valid
pass
elif EntityPermission.UPDATE_RLS in user_permissions:
# TODO: check if RLS fields are valid
# TODO: check if entity values are valid
pass
else:
raise ForbiddenError(
f"User {user.id} does not have permission to update entities"
)
entity = await self.entity_descriptor.type_.get(session=db_session, id=id)
if entity is None:
raise NotFoundError(f"Entity with id {id} not found")
for field_descriptor in self.entity_descriptor.fields_descriptors.values():
field_name = field_descriptor.field_name
if field_name in model.model_fields_set:
model_field_value = getattr(model, field_name)
if (
field_descriptor.is_list
and isinstance(field_descriptor.type_base, type)
and hasattr(field_descriptor.type_base, "bot_entity_descriptor")
):
items = (
await db_session.exec(
select(field_descriptor.type_base).where(
col(field_descriptor.type_base.id).in_(
model_field_value
)
)
)
).all()
setattr(entity, field_name, items)
elif isinstance(field_descriptor.type_base, type) and hasattr(
field_descriptor.type_base, "all_members"
):
if field_descriptor.is_list:
setattr(
entity,
field_name,
[
field_descriptor.type_base(item)
for item in model_field_value
],
)
else:
setattr(
entity,
field_name,
field_descriptor.type_base(model_field_value),
)
else:
setattr(entity, field_name, model_field_value)
try:
await db_session.commit()
except IntegrityError as e:
if isinstance(e.orig.__cause__, ForeignKeyViolationError):
raise ValueError(e.orig.__cause__.detail)
raise ValueError("DB Integrity error")
except Exception as e:
logger.error(f"Error updating entity: {e}")
raise e
return entity_to_schema(entity)
async def delete(
self, db_session: AsyncSession, user: "UserBase", id: int
) -> BaseModel:
if CrudCommand.DELETE not in self.commands:
raise ForbiddenError(
f"Delete command not allowed for entity {self.entity_descriptor.name}"
)
user_permissions = get_user_permissions(user, self.entity_descriptor)
if EntityPermission.DELETE_ALL in user_permissions:
pass
elif EntityPermission.DELETE_RLS in user_permissions:
# TODO: check if RLS fields are valid
pass
else:
raise ForbiddenError(
f"User {user.id} does not have permission to delete entities"
)
try:
entity = await self.entity_descriptor.type_.remove(
session=db_session, id=id, commit=True
)
except IntegrityError as e:
if isinstance(e.orig.__cause__, ForeignKeyViolationError):
raise ValueError(e.orig.__cause__.detail)
raise ValueError("DB Integrity error")
except Exception as e:
logger.error(f"Error deleting entity: {e}")
raise e
if entity is None:
raise NotFoundError(f"Entity with id {id} not found")
return entity_to_schema(entity)
# async def _create_from_schema(
# cls: type[BotEntity],
# *,
# session: AsyncSession | None = None,
# obj_in: BaseModel,
# ):
# """
# Create a new entity instance from a Pydantic model.
# Args:
# session: Database session (injected by session_dep)
# obj_in: Pydantic model to create the entity from
# Returns:
# The created entity instance
# """
# obj_dict = {}
# ret_obj_dict = {}
# for field_descriptor in cls.bot_entity_descriptor.fields_descriptors.values():
# # Only process fields present in the input model
# if field_descriptor.field_name in obj_in.__class__.model_fields:
# # Handle list fields that are relations to other BotEntities
# if (
# field_descriptor.is_list
# and isinstance(field_descriptor.type_base, type)
# and issubclass(field_descriptor.type_base, BotEntity)
# ):
# items = (
# await session.exec(
# select(field_descriptor.type_base).where(
# col(field_descriptor.type_base.id).in_(
# getattr(obj_in, field_descriptor.field_name)
# )
# )
# )
# ).all()
# obj_dict[field_descriptor.field_name] = items
# ret_obj_dict[field_descriptor.field_name] = [
# item.id for item in items
# ]
# else:
# obj_dict[field_descriptor.field_name] = getattr(
# obj_in, field_descriptor.field_name
# )
# ret_obj_dict[field_descriptor.field_name] = getattr(
# obj_in, field_descriptor.field_name
# )
# obj = cls(**obj_dict)
# session.add(obj)
# await session.commit()
# if "id" not in ret_obj_dict:
# ret_obj_dict["id"] = obj.id
# return cls.bot_entity_descriptor.schema_class(**ret_obj_dict)
# @classmethod
# def apply_filters(
# cls,
# select_statement: SelectOfScalar[Self],
# filters: Filter | FilterExpression | None = None,
# ) -> SelectOfScalar[Self]:
# """
# Apply filters to a select statement.
# Args:
# select_statement: SQLAlchemy select statement to modify
# filters: Filter or FilterExpression to apply
# Returns:
# Modified select statement with filter conditions
# """
# if filters is None:
# return select_statement
# condition = cls._build_filter_condition(filters)
# if condition is not None:
# return select_statement.where(condition)
# return select_statement

View File

@@ -0,0 +1,390 @@
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from aiogram.utils.i18n import I18n
from aiogram.utils.keyboard import InlineKeyboardBuilder
from typing import Any, Callable, TYPE_CHECKING, Literal, Union
from babel.support import LazyProxy
from dataclasses import dataclass, field
from fastapi.datastructures import State
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.orm import InstrumentedAttribute
from .role import RoleBase
from . import EntityPermission
from ..bot.handlers.context import ContextData
if TYPE_CHECKING:
from .bot_entity import BotEntity
from ..main import QuickBot
from .user import UserBase
from .crud_service import CrudService
from .bot_process import BotProcess
# EntityCaptionCallable = Callable[["EntityDescriptor"], str]
# EntityItemCaptionCallable = Callable[["EntityDescriptor", Any], str]
# EntityFieldCaptionCallable = Callable[["FieldDescriptor", Any, Any], str]
@dataclass
class FieldEditButton[T: "BotEntity"]:
field: str | Callable[[type[T]], InstrumentedAttribute]
caption: str | LazyProxy | Callable[[T, "BotContext"], str] | None = None
visibility: Callable[[T, "BotContext"], bool] | None = None
@dataclass
class CommandButton[T: "BotEntity"]:
command: ContextData | Callable[[T, "BotContext"], ContextData] | str
caption: str | LazyProxy | Callable[[T, "BotContext"], str] | None = None
visibility: Callable[[T, "BotContext"], bool] | None = None
@dataclass
class InlineButton[T: "BotEntity"]:
inline_button: (
InlineKeyboardButton | Callable[[T, "BotContext"], InlineKeyboardButton]
)
visibility: Callable[[T, "BotContext"], bool] | None = None
@dataclass
class Filter[T: "BotEntity"]:
field: str | Callable[[type[T]], InstrumentedAttribute]
operator: Literal[
"==",
"!=",
">",
"<",
">=",
"<=",
"in",
"not in",
"like",
"ilike",
"is none",
"is not none",
"contains",
]
value_type: Literal["const", "param"] = "const"
value: Any | None = None
param_index: int | None = None
def __or__(self, other: "Filter[T] | FilterExpression[T]") -> "FilterExpression[T]":
"""Create OR expression with another filter or expression"""
if isinstance(other, Filter):
return FilterExpression("or", [self, other])
elif isinstance(other, FilterExpression):
if other.operator == "or":
# Simplify: filter | (a | b) = (filter | a | b)
return FilterExpression("or", [self] + other.filters)
else:
return FilterExpression("or", [self, other])
else:
raise TypeError(f"Cannot combine Filter with {type(other)}")
def __and__(
self, other: "Filter[T] | FilterExpression[T]"
) -> "FilterExpression[T]":
"""Create AND expression with another filter or expression"""
if isinstance(other, Filter):
return FilterExpression("and", [self, other])
elif isinstance(other, FilterExpression):
if other.operator == "and":
# Simplify: filter & (a & b) = (filter & a & b)
return FilterExpression("and", [self] + other.filters)
else:
return FilterExpression("and", [self, other])
else:
raise TypeError(f"Cannot combine Filter with {type(other)}")
class FilterExpression[T: "BotEntity"]:
"""
Represents a logical expression combining multiple filters with AND/OR operations.
Supports expression simplification for optimal query building.
"""
def __init__(
self,
operator: Literal["or", "and"],
filters: list["Filter[T] | FilterExpression[T]"],
):
self.operator = operator
self.filters = self._simplify_filters(filters)
def _simplify_filters(
self, filters: list["Filter[T] | FilterExpression[T]"]
) -> list["Filter[T] | FilterExpression[T]"]:
"""Simplify filters by flattening nested expressions with the same operator"""
simplified = []
for filter_obj in filters:
if (
isinstance(filter_obj, FilterExpression)
and filter_obj.operator == self.operator
):
# Flatten nested expressions with the same operator
simplified.extend(filter_obj.filters)
else:
simplified.append(filter_obj)
return simplified
def __or__(self, other: "Filter[T] | FilterExpression[T]") -> "FilterExpression[T]":
"""Combine with another filter or expression using OR"""
if isinstance(other, (Filter, FilterExpression)):
if isinstance(other, FilterExpression) and other.operator == "or":
# Simplify: (a | b) | (c | d) = (a | b | c | d)
return FilterExpression("or", self.filters + other.filters)
else:
return FilterExpression("or", [self, other])
else:
raise TypeError(f"Cannot combine FilterExpression with {type(other)}")
def __and__(
self, other: "Filter[T] | FilterExpression[T]"
) -> "FilterExpression[T]":
"""Combine with another filter or expression using AND"""
if isinstance(other, (Filter, FilterExpression)):
if isinstance(other, FilterExpression) and other.operator == "and":
# Simplify: (a & b) & (c & d) = (a & b & c & d)
return FilterExpression("and", self.filters + other.filters)
else:
return FilterExpression("and", [self, other])
else:
raise TypeError(f"Cannot combine FilterExpression with {type(other)}")
@dataclass
class EntityList[T: "BotEntity"]:
caption: (
str | LazyProxy | Callable[["EntityDescriptor", "BotContext"], str] | None
) = None
item_repr: Callable[[T, "BotContext"], str] | None = None
show_add_new_button: bool = True
item_form: str | None = None
pagination: bool = True
static_filters: Filter[T] | FilterExpression[T] | None = None
filtering: bool = False
filtering_fields: list[str] = None
order_by: str | Any | None = None
@dataclass
class EntityForm[T: "BotEntity"]:
item_repr: Callable[[T, "BotContext"], str] | None = None
edit_field_sequence: list[str] = None
form_buttons: list[list[FieldEditButton | CommandButton | InlineButton]] = None
show_edit_button: bool = True
show_delete_button: bool = True
before_open: Callable[[T, "BotContext"], None] | None = None
@dataclass(kw_only=True)
class _BaseFieldDescriptor[T: "BotEntity"]:
icon: str = None
caption: (
str | LazyProxy | Callable[["FieldDescriptor", "BotContext"], str] | None
) = None
description: str | LazyProxy | None = PydanticUndefined
edit_prompt: (
str
| LazyProxy
| Callable[["FieldDescriptor", Union[T, Any], "BotContext"], str]
| None
) = None
caption_value: (
Callable[["FieldDescriptor", Union[T, Any], "BotContext"], str] | None
) = None
is_visible: bool | Callable[["FieldDescriptor", T, "BotContext"], bool] | None = (
None
)
is_visible_in_edit_form: (
bool | Callable[["FieldDescriptor", Union[T, 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"
ep_form: str | Callable[["BotContext"], str] | None = None
ep_parent_field: str | Callable[[type[T]], InstrumentedAttribute] | None = None
ep_child_field: str | Callable[[type[T]], InstrumentedAttribute] | None = None
dt_type: Literal["date", "datetime"] = "date"
options: (
list[list[Union[Any, tuple[Any, str]]]]
| Callable[[T, "BotContext"], list[list[Union[Any, tuple[Any, str]]]]]
| None
) = None
options_custom_value: bool = True
show_current_value_button: bool = True
show_skip_in_editor: Literal[False, "Auto"] = "Auto"
default: Any = PydanticUndefined
default_factory: Callable[[], Any] | None = None
@dataclass(kw_only=True)
class EntityField[T: "BotEntity"](_BaseFieldDescriptor[T]):
name: str | None = None
sm_descriptor: Any = None
@dataclass(kw_only=True)
class Setting(_BaseFieldDescriptor):
name: str | None = None
@dataclass(kw_only=True)
class FormField[T: "BotEntity"](_BaseFieldDescriptor[T]):
name: str | None = None
type_: type
@dataclass(kw_only=True)
class FieldDescriptor(_BaseFieldDescriptor):
name: str
field_name: str
type_: type
type_base: type = None
is_list: bool = False
is_optional: bool = False
entity_descriptor: "EntityDescriptor" = None
command: "BotCommand" = None
def __hash__(self):
return self.name.__hash__()
@dataclass(kw_only=True)
class _BaseEntityDescriptor[T: "BotEntity"]:
icon: str = "📘"
full_name: (
str | LazyProxy | Callable[["EntityDescriptor", "BotContext"], str] | None
) = None
full_name_plural: (
str | LazyProxy | Callable[["EntityDescriptor", "BotContext"], str] | None
) = None
description: str | None = None
ui_description: (
str | LazyProxy | Callable[["EntityDescriptor", "BotContext"], str] | None
) = None
item_repr: Callable[[T, "BotContext"], str] | None = None
default_list: EntityList = field(default_factory=EntityList)
default_form: EntityForm = field(default_factory=EntityForm)
lists: dict[str, EntityList] = field(default_factory=dict[str, EntityList])
forms: dict[str, EntityForm] = field(default_factory=dict[str, EntityForm])
show_in_entities_menu: bool = True
ownership_fields: dict[RoleBase, str] = field(default_factory=dict[RoleBase, str])
permissions: dict[EntityPermission, list[RoleBase]] = field(
default_factory=lambda: {
EntityPermission.LIST_RLS: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.READ_RLS: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.CREATE_RLS: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.UPDATE_RLS: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.DELETE_RLS: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.LIST_ALL: [RoleBase.SUPER_USER],
EntityPermission.READ_ALL: [RoleBase.SUPER_USER],
EntityPermission.CREATE_ALL: [RoleBase.SUPER_USER],
EntityPermission.UPDATE_ALL: [RoleBase.SUPER_USER],
EntityPermission.DELETE_ALL: [RoleBase.SUPER_USER],
}
)
rls_filters: Filter[T] | FilterExpression[T] | None = None
rls_filters_params: Callable[["UserBase"], list[Any]] = lambda user: [user.id]
before_create: Callable[["BotContext"], Union[bool, str]] | None = None
before_create_save: Callable[[T, "BotContext"], Union[bool, str]] | None = None
before_update_save: (
Callable[[dict[str, Any], dict[str, Any], "BotContext"], Union[bool, str]]
| None
) = None
before_delete: Callable[[T, "BotContext"], Union[bool, str]] | None = None
on_created: Callable[[T, "BotContext"], None] | None = None
on_deleted: Callable[[T, "BotContext"], None] | None = None
on_updated: Callable[[dict[str, Any], T, "BotContext"], None] | None = None
crud: Union["CrudService", None] = None
@dataclass(kw_only=True)
class Entity[T: "BotEntity"](_BaseEntityDescriptor[T]):
name: str | None = None
@dataclass
class EntityDescriptor(_BaseEntityDescriptor):
name: str
class_name: str
type_: type["BotEntity"]
fields_descriptors: dict[str, FieldDescriptor]
@dataclass(kw_only=True)
class CommandCallbackContext:
keyboard_builder: InlineKeyboardBuilder = field(
default_factory=InlineKeyboardBuilder
)
message_text: str | None = None
register_navigation: bool = True
clear_navigation: bool = False
message: Message | CallbackQuery
callback_data: ContextData
db_session: AsyncSession
user: "UserBase"
app: "QuickBot"
app_state: State
state_data: dict[str, Any]
state: FSMContext
form_data: dict[str, Any]
i18n: I18n
kwargs: dict[str, Any] = field(default_factory=dict)
@dataclass(kw_only=True)
class BotContext:
db_session: AsyncSession
app: "QuickBot"
app_state: State
user: "UserBase"
message: Message | CallbackQuery | None = None
default_handler: Callable[["BotEntity", "BotContext"], None] | None = None
@dataclass(kw_only=True)
class BotCommand:
name: str
caption: str | dict[str, str] | None = None
pre_check: Callable[[Union[Message, CallbackQuery], Any], bool] | None = None
show_in_bot_commands: bool = False
register_navigation: bool = True
clear_navigation: bool = False
clear_state: bool = True
param_form: dict[str, FieldDescriptor] | None = None
show_cancel_in_param_form: bool = True
show_back_in_param_form: bool = True
handler: Callable[[CommandCallbackContext], None]
@dataclass(kw_only=True)
class _BaseProcessDescriptor:
description: str | LazyProxy | None = None
roles: list[RoleBase] = field(
default_factory=lambda: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER]
)
icon: str | None = None
caption: str | LazyProxy | None = None
pre_check: Callable[[BotContext], bool | str] | None = None
show_in_bot_menu: bool = False
answer_message: Callable[[BotContext, BaseModel], str] | None = None
answer_inline_buttons: (
Callable[[BotContext, BaseModel], list[InlineKeyboardButton]] | None
) = None
@dataclass(kw_only=True)
class ProcessDescriptor(_BaseProcessDescriptor):
name: str
process_class: type["BotProcess"]
input_schema: type[BaseModel] | None = None
output_schema: type[BaseModel] | None = None
@dataclass(kw_only=True)
class Process(_BaseProcessDescriptor): ...

View File

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

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel
class ListSchema(BaseModel):
id: int
name: str

View File

@@ -0,0 +1,192 @@
from inspect import iscoroutinefunction
from quickbot.model.descriptors import EntityDescriptor, EntityPermission
from quickbot.model.descriptors import Filter, FilterExpression
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from quickbot.model.user import UserBase
from quickbot.model.bot_entity import BotEntity
def get_user_permissions(
user: "UserBase", entity_descriptor: EntityDescriptor
) -> list[EntityPermission]:
permissions = list[EntityPermission]()
for permission, roles in entity_descriptor.permissions.items():
for role in roles:
if role in user.roles:
permissions.append(permission)
break
return permissions
async def check_entity_permission(
entity: "BotEntity", user: "UserBase", permission: EntityPermission
) -> bool:
perm_mapping = {
EntityPermission.LIST_RLS: EntityPermission.LIST_ALL,
EntityPermission.READ_RLS: EntityPermission.READ_ALL,
EntityPermission.UPDATE_RLS: EntityPermission.UPDATE_ALL,
EntityPermission.CREATE_RLS: EntityPermission.CREATE_ALL,
EntityPermission.DELETE_RLS: EntityPermission.DELETE_ALL,
}
if permission not in perm_mapping:
raise ValueError(f"Invalid permission: {permission}")
entity_descriptor = entity.__class__.bot_entity_descriptor
permissions = get_user_permissions(user, entity_descriptor)
# Check if user has the corresponding ALL permission
if perm_mapping[permission] in permissions:
return True
# Check RLS filters if they exist
if entity_descriptor.rls_filters:
# Get parameters for RLS
params = []
if entity_descriptor.rls_filters_params:
if iscoroutinefunction(entity_descriptor.rls_filters_params):
params = await entity_descriptor.rls_filters_params(user)
else:
params = entity_descriptor.rls_filters_params(user)
# Create a copy of the RLS filters with parameter values substituted
rls_filters = entity.__class__._substitute_rls_parameters(
entity_descriptor.rls_filters, params
)
# Check if the entity matches the RLS filters by evaluating the condition
# against the entity's attributes
if _entity_matches_rls_filters(entity, rls_filters):
return True
# If no RLS filters are defined, check if user has the RLS permission
if permission in permissions:
return True
return False
def _entity_matches_rls_filters(
entity: "BotEntity", rls_filters: "Filter | FilterExpression"
) -> bool:
"""
Check if an entity matches the given RLS filters by evaluating the filters
against the entity's attributes.
Args:
entity: The entity to check
rls_filters: RLS filters to evaluate
Returns:
True if the entity matches the filters, False otherwise
"""
if isinstance(rls_filters, Filter):
return _evaluate_single_filter(entity, rls_filters)
elif isinstance(rls_filters, FilterExpression):
return _evaluate_filter_expression(entity, rls_filters)
else:
return False
def _evaluate_single_filter(entity: "BotEntity", filter_obj: "Filter") -> bool:
"""Evaluate a single filter against an entity"""
# Get the field value from the entity
if isinstance(filter_obj.field, str):
field_value = getattr(entity, filter_obj.field, None)
else:
# Handle callable field (should return the field name)
field_name = filter_obj.field(entity.__class__).key
field_value = getattr(entity, field_name, None)
# Apply the operator
if filter_obj.operator == "==":
return field_value == filter_obj.value
elif filter_obj.operator == "!=":
return field_value != filter_obj.value
elif filter_obj.operator == ">":
return field_value > filter_obj.value
elif filter_obj.operator == "<":
return field_value < filter_obj.value
elif filter_obj.operator == ">=":
return field_value >= filter_obj.value
elif filter_obj.operator == "<=":
return field_value <= filter_obj.value
elif filter_obj.operator == "in":
return field_value in filter_obj.value
elif filter_obj.operator == "not in":
return field_value not in filter_obj.value
elif filter_obj.operator == "like":
return str(field_value).find(str(filter_obj.value)) != -1
elif filter_obj.operator == "ilike":
return str(field_value).lower().find(str(filter_obj.value).lower()) != -1
elif filter_obj.operator == "is none":
return field_value is None
elif filter_obj.operator == "is not none":
return field_value is not None
elif filter_obj.operator == "contains":
return filter_obj.value in field_value
else:
return False
def _evaluate_filter_expression(
entity: "BotEntity", filter_expr: "FilterExpression"
) -> bool:
"""Evaluate a filter expression against an entity"""
results = []
for sub_filter in filter_expr.filters:
if isinstance(sub_filter, Filter):
result = _evaluate_single_filter(entity, sub_filter)
elif isinstance(sub_filter, FilterExpression):
result = _evaluate_filter_expression(entity, sub_filter)
else:
result = False
results.append(result)
if not results:
return False
# Apply the logical operator
if filter_expr.operator == "and":
return all(results)
elif filter_expr.operator == "or":
return any(results)
else:
return False
def _extract_rls_filter_fields(entity_descriptor: EntityDescriptor) -> set[str]:
return _extract_filter_fields(
entity_descriptor.rls_filters, entity_descriptor.type_
)
def _extract_filter_fields(
filter: Filter | FilterExpression | None, entity_type: type
) -> set[str]:
fields = set()
if filter:
if isinstance(filter, Filter):
if filter.operator == "==":
if isinstance(filter.field, str):
fields.add(filter.field)
else:
fields.add(filter.field(entity_type).key)
elif isinstance(filter, FilterExpression):
if (
filter.operator == "and"
and all(isinstance(sub_filter, Filter) for sub_filter in filter.filters)
and all(sub_filter.operator == "==" for sub_filter in filter.filters)
):
for sub_filter in filter.filters:
if isinstance(sub_filter.field, str):
fields.add(sub_filter.field)
else:
fields.add(sub_filter.field(entity_type).key)
return fields

View File

@@ -0,0 +1,53 @@
from typing import Any, Type
from pydantic import BaseModel
from sqlmodel import TypeDecorator, JSON
class PydanticJSON(TypeDecorator):
"""
SQLAlchemy-compatible JSON type for storing Pydantic models
(including nested ones). Automatically serializes on insert
and deserializes on read.
"""
impl = JSON
cache_ok = True
def __init__(self, model_class: Type[BaseModel], *args, **kwargs):
if not issubclass(model_class, BaseModel):
raise TypeError("PydanticJSON expects a Pydantic BaseModel subclass")
self.model_class = model_class
super().__init__(*args, **kwargs)
def process_bind_param(self, value: Any, dialect) -> Any:
"""
Serialize Python object to JSON-compatible form before saving to DB.
"""
if value is None:
return None
if isinstance(value, list):
return [
item.model_dump(mode="json") if isinstance(item, BaseModel) else item
for item in value
]
if isinstance(value, BaseModel):
return value.model_dump(mode="json")
return value # assume already JSON-serializable
def process_result_value(self, value: Any, dialect) -> Any:
"""
Deserialize JSON data from DB back into Python object.
"""
if value is None:
return None
if isinstance(value, list):
return [self.model_class(**item) for item in value]
if isinstance(value, dict):
return self.model_class(**value)
raise TypeError(f"Unsupported value type for deserialization: {type(value)}")

View File

@@ -0,0 +1,6 @@
from .bot_enum import BotEnum, EnumMember
class RoleBase(BotEnum):
SUPER_USER = EnumMember("super_user", {"default": "admin"})
DEFAULT_USER = EnumMember("default_user", {"default": "user"})

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,18 +226,17 @@ class Settings(metaclass=SettingsMetaclass):
return ret_val
@classmethod
async def load_param(cls, param: FieldDescriptor) -> Any:
async with async_session() as session:
db_setting = (
await session.exec(
select(DbSettings).where(DbSettings.name == param.field_name)
)
).first()
async def load_param(cls, session: AsyncSession, param: FieldDescriptor) -> Any:
db_setting = (
await session.exec(
select(DbSettings).where(DbSettings.name == param.field_name)
)
).first()
if db_setting:
return await deserialize(
session=session, type_=param.type_, value=db_setting.value
)
if db_setting:
return await deserialize(
session=session, type_=param.type_, value=db_setting.value
)
return (
param.default_factory()
@@ -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

@@ -0,0 +1,38 @@
from sqlalchemy import BigInteger
from sqlmodel import Field, ARRAY
from .bot_entity import BotEntity
from .bot_enum import EnumType
from .language import LanguageBase
from .role import RoleBase
from .descriptors import EntityField
from .settings import DbSettings as DbSettings
from .fsm_storage import FSMStorage as FSMStorage
from .view_setting import ViewSetting as ViewSetting
class UserBase(BotEntity, table=False):
__tablename__ = "user"
id: int = EntityField(
description="User Telegram ID",
sm_descriptor=Field(primary_key=True, sa_type=BigInteger),
is_visible=False,
)
lang: LanguageBase = Field(
description="User language",
sa_type=EnumType(LanguageBase),
default_factory=lambda: LanguageBase.DEFAULT,
)
is_active: bool = EntityField(description="User is active", default=True)
name: str = EntityField(description="User name")
roles: list[RoleBase] = Field(
description="User roles",
sa_type=ARRAY(EnumType(RoleBase)),
default_factory=lambda: [RoleBase.DEFAULT_USER],
)

364
src/quickbot/model/utils.py Normal file
View File

@@ -0,0 +1,364 @@
from inspect import iscoroutinefunction
from typing import Any, Optional, get_origin
from pydantic import BaseModel, Field
from pydantic.fields import _Unset
from pydantic_core import PydanticUndefined
from typing_extensions import Literal
from sqlmodel import SQLModel, col, column
from sqlmodel.sql.expression import SelectOfScalar
from typing import TYPE_CHECKING
from quickbot.model.list_schema import ListSchema
from quickbot.model.descriptors import (
EntityDescriptor,
Filter,
FilterExpression,
BotContext,
)
from quickbot.utils.main import get_entity_item_repr
if TYPE_CHECKING:
from quickbot import BotEntity, BotEnum
from quickbot.model.user import UserBase
def entity_to_schema(entity: "BotEntity") -> BaseModel:
entity_data = {}
for field_descriptor in entity.bot_entity_descriptor.fields_descriptors.values():
if field_descriptor.is_list and is_bot_entity(field_descriptor.type_base):
entity_data[field_descriptor.field_name] = [
item.id for item in getattr(entity, field_descriptor.field_name)
]
elif field_descriptor.is_list and is_bot_enum(field_descriptor.type_base):
entity_data[field_descriptor.field_name] = [
item.value for item in getattr(entity, field_descriptor.field_name)
]
elif not field_descriptor.is_list and is_bot_entity(field_descriptor.type_base):
continue
elif not field_descriptor.is_list and is_bot_enum(field_descriptor.type_base):
val: "BotEnum" | None = getattr(entity, field_descriptor.field_name)
entity_data[field_descriptor.field_name] = val.value if val else None
else:
entity_data[field_descriptor.field_name] = getattr(
entity, field_descriptor.field_name
)
return (
entity.bot_entity_descriptor.crud.schema(**entity_data)
if entity.bot_entity_descriptor.crud
else entity.bot_entity_descriptor.schema(**entity_data)
)
async def entity_to_list_schema(
entity: "BotEntity", context: "BotContext"
) -> ListSchema:
entity_repr = await get_entity_item_repr(
entity, context, entity.bot_entity_descriptor.item_repr
)
return ListSchema(id=entity.id, name=entity_repr)
def _pydantic_model_fields(
namespace: dict[str, Any],
entity_descriptor: EntityDescriptor,
schema_type: Literal["schema", "create", "update"] = "schema",
) -> dict[str, Any]:
namespace["__annotations__"] = {}
for field_descriptor in entity_descriptor.fields_descriptors.values():
type_origin = get_origin(field_descriptor.type_base)
if (
type_origin is not list
and not field_descriptor.is_list
and (
issubclass(field_descriptor.type_base, SQLModel)
or isinstance(field_descriptor.type_base, str)
)
) or (
schema_type in ["create", "update"]
and field_descriptor.field_name == "id"
and field_descriptor.default is None
):
continue
if (
type_origin is not list
and field_descriptor.is_list
and (
issubclass(field_descriptor.type_base, SQLModel)
or isinstance(field_descriptor.type_base, str)
)
):
namespace["__annotations__"][field_descriptor.field_name] = list[int]
elif type_origin is not list and is_bot_enum(field_descriptor.type_base):
enum_values = [
member.value
for member in field_descriptor.type_base.all_members.values()
]
enum_annotation = (
list[Literal[*enum_values]]
if field_descriptor.is_list
else Literal[*enum_values]
)
if field_descriptor.is_optional:
enum_annotation = Optional[enum_annotation]
namespace["__annotations__"][field_descriptor.field_name] = enum_annotation
else:
namespace["__annotations__"][field_descriptor.field_name] = (
Optional[field_descriptor.type_base]
if field_descriptor.is_optional
else field_descriptor.type_
)
description = (
field_descriptor.description if field_descriptor.description else _Unset
)
if schema_type == "schema" and field_descriptor.is_optional:
namespace[field_descriptor.field_name] = Field(description=description)
elif schema_type == "create":
if field_descriptor.default is not PydanticUndefined:
namespace[field_descriptor.field_name] = Field(
default=field_descriptor.default, description=description
)
elif field_descriptor.default_factory is not None:
namespace[field_descriptor.field_name] = Field(
default_factory=field_descriptor.default_factory,
description=description,
)
elif field_descriptor.is_optional:
namespace[field_descriptor.field_name] = Field(
default=None, description=description
)
else:
namespace[field_descriptor.field_name] = Field(description=description)
elif schema_type == "update":
namespace[field_descriptor.field_name] = Field(
default=None, description=description
)
else:
namespace[field_descriptor.field_name] = Field(description=description)
def pydantic_model(
entity_descriptor: EntityDescriptor,
module_name: str,
schema_type: Literal["schema", "create", "update"] = "schema",
) -> type[BaseModel]:
namespace = {
"__module__": module_name,
}
_pydantic_model_fields(namespace, entity_descriptor, schema_type)
return type(
f"{entity_descriptor.class_name}{schema_type.capitalize() if schema_type != 'schema' else ''}Schema",
(BaseModel,),
namespace,
)
def _build_filter_condition(
cls: type["BotEntity"], filter_obj: Filter | FilterExpression
) -> Any:
"""
Build SQLAlchemy condition from a Filter or FilterExpression object.
Args:
filter_obj: Filter or FilterExpression object to convert
Returns:
SQLAlchemy condition
"""
# --- Handle single Filter object ---
if isinstance(filter_obj, Filter):
# Support both string field names and callables for custom columns
if isinstance(filter_obj.field, str):
column = getattr(cls, filter_obj.field)
else:
column = filter_obj.field(cls)
# Map filter operator to SQLAlchemy expression
if filter_obj.operator == "==":
return column.__eq__(filter_obj.value)
elif filter_obj.operator == "!=":
return column.__ne__(filter_obj.value)
elif filter_obj.operator == "<":
return column.__lt__(filter_obj.value)
elif filter_obj.operator == "<=":
return column.__le__(filter_obj.value)
elif filter_obj.operator == ">":
return column.__gt__(filter_obj.value)
elif filter_obj.operator == ">=":
return column.__ge__(filter_obj.value)
elif filter_obj.operator == "ilike":
return col(column).ilike(f"%{filter_obj.value}%")
elif filter_obj.operator == "like":
return col(column).like(f"%{filter_obj.value}%")
elif filter_obj.operator == "in":
return col(column).in_(filter_obj.value)
elif filter_obj.operator == "not in":
return col(column).notin_(filter_obj.value)
elif filter_obj.operator == "is none":
return col(column).is_(None)
elif filter_obj.operator == "is not none":
return col(column).isnot(None)
elif filter_obj.operator == "contains":
return filter_obj.value == col(column).any_()
else:
# Unknown operator, return None (no condition)
return None
# --- Handle FilterExpression object (logical AND/OR of filters) ---
elif isinstance(filter_obj, FilterExpression):
operator = filter_obj.operator
filters = filter_obj.filters
# Recursively build conditions for all sub-filters
conditions = []
for sub_filter in filters:
condition = _build_filter_condition(cls, sub_filter)
if condition is not None:
conditions.append(condition)
if not conditions:
return None
# Combine conditions using logical AND/OR
if operator == "and":
res_condition = conditions[0]
if len(conditions) > 1:
for condition in conditions[1:]:
res_condition = res_condition & condition
return res_condition
elif operator == "or":
res_condition = conditions[0]
if len(conditions) > 1:
for condition in conditions[1:]:
res_condition = res_condition | condition
return res_condition
def _static_filter_condition(
cls,
select_statement: SelectOfScalar,
static_filter: Filter | FilterExpression,
):
"""
Apply static filters to a select statement.
Static filters are predefined conditions that don't depend on user input.
Supports both Filter and FilterExpression objects with logical operations.
Args:
select_statement: SQLAlchemy select statement to modify
static_filter: filter condition to apply
Returns:
Modified select statement with filter conditions
"""
condition = _build_filter_condition(cls, static_filter)
if condition is not None:
select_statement = select_statement.where(condition)
return select_statement
def _filter_condition(
select_statement: SelectOfScalar,
filter: str,
filter_fields: list[str],
):
"""
Apply text-based search filters to a select statement.
Creates a case-insensitive LIKE search across multiple fields.
Args:
select_statement: SQLAlchemy select statement to modify
filter: Search text to look for
filter_fields: List of field names to search in
Returns:
Modified select statement with search conditions
"""
condition = None
for field in filter_fields:
if condition is not None:
condition = condition | (column(field).ilike(f"%{filter}%"))
else:
condition = column(field).ilike(f"%{filter}%")
return select_statement.where(condition)
async def _apply_rls_filters(
cls: type["BotEntity"], select_statement: SelectOfScalar, user: "UserBase"
):
"""
Apply Row Level Security (RLS) filters to restrict access based on user roles.
This method uses the entity's rls_filters and rls_filters_params to apply
dynamic filtering conditions based on the user's roles and permissions.
Args:
select_statement: SQLAlchemy select statement to modify
user: User whose access should be restricted
Returns:
Modified select statement with RLS conditions
"""
# --- Check if RLS filters are defined for this entity ---
if cls.bot_entity_descriptor.rls_filters:
# Get parameters for RLS filters (may be sync or async)
params = []
if cls.bot_entity_descriptor.rls_filters_params:
if iscoroutinefunction(cls.bot_entity_descriptor.rls_filters_params):
params = await cls.bot_entity_descriptor.rls_filters_params(user)
else:
params = cls.bot_entity_descriptor.rls_filters_params(user)
# Create a copy of the RLS filters with parameter values substituted
rls_filters = _substitute_rls_parameters(
cls.bot_entity_descriptor.rls_filters, params
)
# Apply RLS filters with parameters
condition = _build_filter_condition(cls, rls_filters)
if condition is not None:
return select_statement.where(condition)
return select_statement
def _substitute_rls_parameters(
rls_filters: Filter | FilterExpression, params: list[Any]
) -> Filter | FilterExpression:
"""
Substitute parameter placeholders in RLS filters with actual values.
Args:
rls_filters: RLS filters that may contain parameter placeholders
params: List of parameter values to substitute
Returns:
RLS filters with parameters substituted
"""
# --- Substitute parameter in single filter ---
if isinstance(rls_filters, Filter):
if rls_filters.value_type == "param" and rls_filters.param_index is not None:
if 0 <= rls_filters.param_index < len(params):
# Create a new filter with the parameter value substituted
return Filter(
field=rls_filters.field,
operator=rls_filters.operator,
value_type="const",
value=params[rls_filters.param_index],
param_index=None,
)
return rls_filters
# --- Recursively substitute parameters in all sub-filters ---
elif isinstance(rls_filters, FilterExpression):
substituted_filters = []
for sub_filter in rls_filters.filters:
substituted_filter = _substitute_rls_parameters(sub_filter, params)
substituted_filters.append(substituted_filter)
return FilterExpression(rls_filters.operator, substituted_filters)
def is_bot_entity(type_: type) -> bool:
return hasattr(type_, "bot_entity_descriptor")
def is_bot_enum(type_: type) -> bool:
return hasattr(type_, "all_members")

9
src/quickbot/plugin.py Normal file
View File

@@ -0,0 +1,9 @@
from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from quickbot import QuickBot
@runtime_checkable
class Registerable(Protocol):
def register(self, app: "QuickBot") -> None: ...

323
src/quickbot/utils/main.py Normal file
View File

@@ -0,0 +1,323 @@
from babel.support import LazyProxy
from inspect import iscoroutinefunction, signature
from aiogram.types import Message, CallbackQuery
from aiogram.utils.i18n import I18n
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any, TYPE_CHECKING, Callable
import ujson as json
from quickbot.utils.serialization import deserialize
from quickbot.model.permissions import (
_extract_rls_filter_fields,
get_user_permissions,
_extract_filter_fields,
)
from ..model.settings import Settings
from ..model.descriptors import (
BotContext,
EntityList,
FieldDescriptor,
EntityDescriptor,
EntityPermission,
_BaseFieldDescriptor,
_BaseEntityDescriptor,
Filter,
)
from ..bot.handlers.context import CallbackCommand, ContextData, CommandContext
if TYPE_CHECKING:
from ..model.bot_entity import BotEntity
from ..model.user import UserBase
from ..main import QuickBot
def get_local_text(text: str, locale: str = None) -> str:
if not locale:
i18n = I18n.get_current(no_error=True)
if i18n:
locale = i18n.current_locale
else:
locale = "en"
try:
obj = json.loads(text) # @IgnoreException
except Exception:
return text
else:
return obj.get(locale, obj[list(obj.keys())[0]])
def get_send_message(message: Message | CallbackQuery):
if isinstance(message, Message):
return message.answer
else:
return message.message.edit_text
def clear_state(state_data: dict, clear_nav: bool = False):
if clear_nav:
state_data.clear()
else:
stack = state_data.get("navigation_stack")
context = state_data.get("navigation_context")
state_data.clear()
if stack:
state_data["navigation_stack"] = stack
if context:
state_data["navigation_context"] = context
async def get_entity_item_repr(
entity: "BotEntity",
context: BotContext,
item_repr: Callable[["BotEntity", BotContext], str] | None = None,
) -> str:
descr = entity.bot_entity_descriptor
if not item_repr:
item_repr = descr.item_repr
if item_repr:
if iscoroutinefunction(item_repr):
return await item_repr(entity, context)
else:
return item_repr(entity, context)
return f"{
await get_callable_str(
callable_str=descr.full_name,
context=context,
descriptor=descr,
entity=entity,
)
if descr.full_name
else descr.name
}: {str(entity.id)}"
async def get_value_repr(
value: Any,
field_descriptor: FieldDescriptor,
context: BotContext,
locale: str | None = None,
) -> str:
if value is None:
return ""
type_ = field_descriptor.type_base
if isinstance(value, bool):
return "[✓]" if value else "[ ]"
elif field_descriptor.is_list:
if hasattr(type_, "bot_entity_descriptor"):
return f"[{
', '.join(
[
await get_entity_item_repr(entity=item, context=context)
for item in value
]
)
}]"
elif hasattr(type_, "all_members"):
return f"[{', '.join(item.localized(locale) for item in value)}]"
elif type_ is str:
return f"[{', '.join([f'"{item}"' for item in value])}]"
else:
return f"[{', '.join([str(item) for item in value])}]"
elif hasattr(type_, "bot_entity_descriptor"):
return await get_entity_item_repr(entity=value, context=context)
elif hasattr(type_, "all_members"):
return value.localized(locale)
elif isinstance(value, str):
if field_descriptor and field_descriptor.localizable:
return get_local_text(text=value, locale=locale)
return value
elif isinstance(value, int):
return str(value)
elif isinstance(value, float):
return str(value)
else:
return str(value)
async def get_callable_str(
callable_str: (
str
| LazyProxy
| Callable[[EntityDescriptor, BotContext], str]
| Callable[["BotEntity", BotContext], str]
| Callable[[FieldDescriptor, "BotEntity", BotContext], str]
),
context: BotContext,
descriptor: FieldDescriptor | EntityDescriptor | None = None,
entity: "BotEntity | Any" = None,
) -> str:
if isinstance(callable_str, str):
return callable_str
elif isinstance(callable_str, LazyProxy):
return callable_str.value
elif callable(callable_str):
args = signature(callable_str).parameters
if iscoroutinefunction(callable_str):
if len(args) == 3:
return await callable_str(descriptor, entity, context)
else:
param = args[next(iter(args))]
if not isinstance(param.annotation, str) and (
issubclass(param.annotation, _BaseFieldDescriptor)
or issubclass(param.annotation, _BaseEntityDescriptor)
):
return await callable_str(descriptor, context)
else:
return await callable_str(entity, context)
else:
if len(args) == 3:
return callable_str(descriptor, entity, context)
else:
return callable_str(entity or descriptor, context)
def get_entity_descriptor(
app: "QuickBot", callback_data: ContextData
) -> EntityDescriptor:
if callback_data.entity_name:
return app.bot_metadata.entity_descriptors[callback_data.entity_name]
return None
def get_field_descriptor(
app: "QuickBot", callback_data: ContextData
) -> FieldDescriptor | None:
if callback_data.context == CommandContext.SETTING_EDIT:
return Settings.list_params()[callback_data.field_name]
elif callback_data.context == CommandContext.COMMAND_FORM:
command = app.bot_commands[callback_data.user_command.split("&")[0]]
if (
command
and command.param_form
and callback_data.field_name in command.param_form
):
return command.param_form[callback_data.field_name]
elif callback_data.context in [
CommandContext.ENTITY_CREATE,
CommandContext.ENTITY_EDIT,
CommandContext.ENTITY_FIELD_EDIT,
]:
entity_descriptor = get_entity_descriptor(app, callback_data)
if entity_descriptor:
return entity_descriptor.fields_descriptors.get(callback_data.field_name)
return None
async def build_field_sequence(
entity_descriptor: EntityDescriptor,
user: "UserBase",
callback_data: ContextData,
state_data: dict,
context: BotContext,
):
prev_form_list = None
stack = state_data.get("navigation_stack", [])
prev_callback_data = ContextData.unpack(stack[-1]) if stack else None
if (
prev_callback_data
and prev_callback_data.command == CallbackCommand.ENTITY_LIST
and prev_callback_data.entity_name == entity_descriptor.name
):
prev_form_name = (
prev_callback_data.form_params.split("&")[0]
if prev_callback_data.form_params
else None
)
prev_form_list: EntityList = entity_descriptor.lists.get(
prev_form_name, entity_descriptor.default_list
)
entity_data = state_data.get("entity_data", {})
field_sequence = list[str]()
# exclude RLS 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
or fd.field_name == "id"
or (
fd.field_name[-3:] == "_id"
and fd.field_name[:-3] in entity_descriptor.fields_descriptors
)
or fd.default is not None
or fd.default_factory is not None
):
skip = True
# Check RLS filters for field visibility
if entity_descriptor.rls_filters:
# Get RLS filter fields that should be auto-filled and hidden from user
rls_filter_fields = _extract_rls_filter_fields(entity_descriptor)
if fd.field_name in rls_filter_fields 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
if prev_form_list and prev_form_list.static_filters:
static_filter_fields = _extract_filter_fields(
prev_form_list.static_filters, entity_descriptor.type_
)
if fd.field_name.rstrip("_id") in [
f.rstrip("_id") for f in static_filter_fields
]:
skip = True
if not skip:
field_sequence.append(fd.field_name)
return field_sequence
async def prepare_static_filter(
db_session: AsyncSession,
entity_descriptor: EntityDescriptor,
static_filters: list[Filter],
params: list[str],
) -> list[Filter]:
return (
[
Filter(
field_name=f.field_name,
operator=f.operator,
value_type="const",
value=(
f.value
if f.value_type == "const"
else await deserialize(
session=db_session,
type_=entity_descriptor.fields_descriptors[
f.field_name
].type_base,
value=params[f.param_index],
)
),
)
for f in static_filters
]
if static_filters
else None
)

View File

@@ -6,9 +6,7 @@ from typing import Any, Union, get_origin, get_args
from types import UnionType, NoneType
import ujson as json
from ..model.bot_entity import BotEntity
from ..model.bot_enum import BotEnum
from ..model.descriptors import FieldDescriptor
from quickbot.model.descriptors import FieldDescriptor
async def deserialize[T](session: AsyncSession, type_: type[T], value: str = None) -> T:
@@ -28,7 +26,7 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non
arg_type = args[0]
values = json.loads(value) if value else []
if arg_type:
if issubclass(arg_type, BotEntity):
if hasattr(arg_type, "bot_entity_descriptor"):
ret = list[arg_type]()
items = (
await session.exec(select(arg_type).where(column("id").in_(values)))
@@ -36,17 +34,17 @@ async def deserialize[T](session: AsyncSession, type_: type[T], value: str = Non
for item in items:
ret.append(item)
return ret
elif issubclass(arg_type, BotEnum):
elif hasattr(arg_type, "all_members"):
return [arg_type(value) for value in values]
else:
return [arg_type(value) for value in values]
else:
return values
elif issubclass(type_, BotEntity):
elif hasattr(type_, "bot_entity_descriptor"):
if is_optional and not value:
return None
return await session.get(type_, int(value))
elif issubclass(type_, BotEnum):
elif hasattr(type_, "all_members"):
if is_optional and not value:
return None
return type_(value)
@@ -79,12 +77,12 @@ def serialize(value: Any, field_descriptor: FieldDescriptor) -> str:
type_ = field_descriptor.type_base
if field_descriptor.is_list:
if issubclass(type_, BotEntity):
if hasattr(type_, "bot_entity_descriptor"):
return json.dumps([item.id for item in value], ensure_ascii=False)
elif issubclass(type_, BotEnum):
elif hasattr(type_, "all_members"):
return json.dumps([item.value for item in value], ensure_ascii=False)
else:
return json.dumps(value, ensure_ascii=False)
elif issubclass(type_, BotEntity):
elif hasattr(type_, "bot_entity_descriptor"):
return str(value.id) if value else ""
return str(value)

View File

@@ -1,5 +1,5 @@
from qbot import (
QBotApp,
from quickbot import (
QuickBot,
BotEntity,
Entity,
EntityForm,
@@ -10,9 +10,9 @@ from qbot import (
ContextData,
CallbackCommand,
)
from qbot.model.user import UserBase
from qbot.model.descriptors import Filter
from qbot.model.role import RoleBase
from quickbot.model.user import UserBase
from quickbot.model.descriptors import Filter
from quickbot.model.role import RoleBase
from aiogram.types import InlineKeyboardButton
from datetime import datetime
@@ -116,7 +116,7 @@ class Entity(BotEntity):
)
app = QBotApp(
app = QuickBot(
user_class=User,
)

1366
uv.lock generated

File diff suppressed because it is too large Load Diff