Compare commits

...

36 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
62 changed files with 4106 additions and 2449 deletions

View File

@@ -1,11 +1,11 @@
<p align="center"> <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>
<p align="center"> <p align="center">
<em>Telegram Bots Rapid Application Development (RAD) Framework.</em> <em>Telegram Bots Rapid Application Development (RAD) Framework.</em>
</p> </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 ## Key Features
@@ -16,13 +16,13 @@
- **Context Preservation** Store navigation stacks and user interaction states in the database. - **Context Preservation** Store navigation stacks and user interaction states in the database.
- **Internationalization Support** Localizable UI and string fields for multilingual bots. - **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 ## Benefits
- **Faster Development** Automate repetitive tasks and build bots in record time. - **Faster Development** Automate repetitive tasks and build bots in record time.
- **Highly Modular** Easily extend and customize functionality. - **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. - **Enterprise-Grade Structure** Scalable, maintainable, and optimized for real-world usage.
## Example ## Example
@@ -38,12 +38,12 @@ class AppEntity(BotEntity):
) )
name: str # entity field with default sqlmodel's FieldInfo descriptor 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 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", 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 # providing Telegram API webhook handler
@app.command( # decorator for bot commands definition @app.command( # decorator for bot commands definition
@@ -92,7 +92,7 @@ async def menu(context: CommandCallbackContext):
``` ```
## Result ## 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) Here you can see the result - [YouTube Video with Bot](https://www.youtube.com/shorts/ptTnoppkYfM)

View File

@@ -66,14 +66,14 @@ class AppEntity(BotEntity):
back_populates="entities", back_populates="entities",
sa_relationship_kwargs={ sa_relationship_kwargs={
"lazy": "selectin", "lazy": "selectin",
"foreign_keys": "Entity.user_id", "foreign_keys": "AppEntity.user_id",
} }
), ),
caption="User", caption="User",
) )
app = QBotApp() # bot application based on FastAPI application app = QuickBot() # bot application based on FastAPI application
# providing Telegram API webhook handler # providing Telegram API webhook handler
@app.command( # decorator for bot commands definition @app.command( # decorator for bot commands definition

View File

@@ -1,5 +1,5 @@
site_name: QBot Framework site_name: QuickBot Framework
site_url: https://qbot.botforge.biz site_url: https://quickbot.botforge.biz
theme: theme:
name: material name: material
palette: palette:
@@ -10,5 +10,5 @@ theme:
code: 'Roboto Mono' code: 'Roboto Mono'
logo: 'img/qbot_1_1.svg' logo: 'img/qbot_1_1.svg'
favicon: 'img/qbot_1_1.svg' favicon: 'img/qbot_1_1.svg'
repo_name: botforge/qbot repo_name: botforge/quickbot
repo_url: https://git.botforge.biz/botforge/qbot 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] [project]
name = "quickbot" name = "quickbot"
version = "0.1.0" version = "0.1.1"
description = "QBot - Rapid Application Development Framework for Telegram Bots" description = "quickbot - Rapid Application Development Framework for Telegram Bots"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.13"
classifiers = [ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Operating System :: OS Independent", "Operating System :: OS Independent",
@@ -15,15 +19,25 @@ authors = [
license = { file = "LICENSE" } license = { file = "LICENSE" }
dependencies = [ dependencies = [
"aiogram>=3.17.0", "aiogram>=3.22.0",
"babel>=2.17.0", "asyncpg>=0.30.0",
"fastapi[standard]>=0.115.8", "fastapi[standard]>=0.115.8",
"greenlet>=3.1.1", "greenlet>=3.1.1",
"mkdocs-material>=9.6.5",
"pydantic-settings>=2.7.1", "pydantic-settings>=2.7.1",
"pyngrok>=7.2.3", "sqlmodel>=0.0.24",
"pytest>=8.3.4",
"ruff>=0.9.6",
"sqlmodel>=0.0.22",
"ujson>=5.10.0", "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 +1,51 @@
from .main import QBotApp as QBotApp, Config as Config from .main import QuickBot, Config
from .router import Router as Router from .router import Router
from .model.bot_entity import BotEntity as BotEntity from .model.bot_entity import BotEntity
from .model.bot_enum import BotEnum as BotEnum, EnumMember as EnumMember from .model.bot_process import BotProcess
from .model import BotEnum, EnumMember
from .bot.handlers.context import ( from .bot.handlers.context import (
ContextData as ContextData, ContextData,
CallbackCommand as CallbackCommand, CallbackCommand,
CommandContext as CommandContext, CommandContext,
) )
from .model.descriptors import ( from .model.descriptors import (
Entity as Entity, Entity,
EntityField as EntityField, EntityField,
EntityForm as EntityForm, EntityForm,
EntityList as EntityList, EntityList,
Filter as Filter, Filter,
EntityPermission as EntityPermission, EntityPermission,
CommandCallbackContext as CommandCallbackContext, CommandCallbackContext,
EntityEventContext as EntityEventContext, BotContext,
CommandButton as CommandButton, CommandButton,
FieldEditButton as FieldEditButton, FieldEditButton,
InlineButton as InlineButton, 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

@@ -1,11 +1,13 @@
from typing import Annotated from aiogram.types import Update
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Request, Response, Depends, HTTPException, Body
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..main import QBotApp from typing import Annotated
from ..db import get_db from ..db import get_db
from aiogram.types import Update from ..main import QuickBot
from ..auth.telegram import check_telegram_auth
from ..auth.jwt import create_access_token
from logging import getLogger from logging import getLogger
@@ -13,15 +15,19 @@ logger = getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.post("/webhook") @router.post("/webhook", name="telegram_webhook")
async def telegram_webhook( async def telegram_webhook(
db_session: Annotated[AsyncSession, Depends(get_db)], request: Request db_session: Annotated[AsyncSession, Depends(get_db)],
request: Request,
): ):
logger.debug("Webhook request %s", await request.json()) logger.debug("Webhook request %s", await request.json())
app: QBotApp = request.app 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") request_token = request.headers.get("X-Telegram-Bot-Api-Secret-Token")
if request_token != app.bot_auth_token: if request_token != app.config.TELEGRAM_WEBHOOK_AUTH_KEY:
logger.warning("Unauthorized request %s", request) logger.warning("Unauthorized request %s", request)
return Response(status_code=403) return Response(status_code=403)
try: try:
@@ -29,16 +35,45 @@ async def telegram_webhook(
except Exception: except Exception:
logger.error("Invalid request", exc_info=True) logger.error("Invalid request", exc_info=True)
return Response(status_code=400) return Response(status_code=400)
try:
state_kw = request.state._state # TODO: avoid accessing private attribute
try:
await app.dp.feed_webhook_update( await app.dp.feed_webhook_update(
app.bot, bot=app.bot,
update, update=update,
db_session=db_session, db_session=db_session,
app=app, app=app,
**(state_kw or {}), app_state=request.state,
) )
except Exception: except Exception:
logger.error("Error processing update", exc_info=True) logger.error("Error processing update", exc_info=True)
return Response(status_code=500)
return Response(status_code=200) 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.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder 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 ....utils.main import get_callable_str
from ..context import ContextData, CallbackCommand from ..context import ContextData, CallbackCommand
@@ -9,6 +9,7 @@ from ..context import ContextData, CallbackCommand
async def add_filter_controls( async def add_filter_controls(
keyboard_builder: InlineKeyboardBuilder, keyboard_builder: InlineKeyboardBuilder,
entity_descriptor: EntityDescriptor, entity_descriptor: EntityDescriptor,
context: BotContext,
filter: str = None, filter: str = None,
filtering_fields: list[str] = None, filtering_fields: list[str] = None,
page: int = 1, page: int = 1,
@@ -16,8 +17,9 @@ async def add_filter_controls(
caption = ", ".join( caption = ", ".join(
[ [
await get_callable_str( await get_callable_str(
entity_descriptor.fields_descriptors[field_name].caption, callable_str=entity_descriptor.fields_descriptors[field_name].caption,
entity_descriptor, context=context,
descriptor=entity_descriptor.fields_descriptors[field_name],
) )
if entity_descriptor.fields_descriptors[field_name].caption if entity_descriptor.fields_descriptors[field_name].caption
else field_name else field_name

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,15 @@
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from decimal import Decimal from decimal import Decimal
from datetime import datetime, time 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_entity import BotEntity
from ....model.bot_enum import BotEnum from ....model.bot_enum import BotEnum
from ....model.descriptors import FieldDescriptor from ....model.descriptors import BotContext, FieldDescriptor
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase from ....model.user import UserBase
from ....utils.main import get_callable_str, get_value_repr 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"] user: UserBase = kwargs["user"]
callback_data: ContextData = kwargs.get("callback_data", None) callback_data: ContextData = kwargs.get("callback_data", None)
state_data: dict = kwargs["state_data"] state_data: dict = kwargs["state_data"]
db_session = kwargs["db_session"]
app: "QuickBot" = kwargs["app"]
value_type = field_descriptor.type_base 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: if field_descriptor.edit_prompt:
edit_prompt = await get_callable_str( edit_prompt = await get_callable_str(
field_descriptor.edit_prompt, callable_str=field_descriptor.edit_prompt,
field_descriptor, context=context,
callback_data descriptor=field_descriptor,
if callback_data.context == CommandContext.COMMAND_FORM entity=entity_data,
else None,
current_value,
) )
else: else:
if field_descriptor.caption: if field_descriptor.caption:
caption_str = await get_callable_str( caption_str = await get_callable_str(
field_descriptor.caption, field_descriptor.caption,
field_descriptor, context=context,
callback_data descriptor=field_descriptor,
if callback_data.context == CommandContext.COMMAND_FORM
else None,
current_value,
) )
else: else:
caption_str = field_descriptor.name caption_str = field_descriptor.name
if callback_data.context == CommandContext.ENTITY_EDIT: if callback_data.context == CommandContext.ENTITY_EDIT:
db_session = kwargs["db_session"]
app = kwargs["app"]
edit_prompt = ( edit_prompt = (
await Settings.get( await Settings.get(
Settings.APP_STRINGS_FIELD_EDIT_PROMPT_TEMPLATE_P_NAME_VALUE Settings.APP_STRINGS_FIELD_EDIT_PROMPT_TEMPLATE_P_NAME_VALUE
) )
).format( ).format(
name=caption_str, 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: else:
edit_prompt = ( edit_prompt = (
@@ -61,6 +119,7 @@ async def show_editor(message: Message | CallbackQuery, **kwargs):
) )
).format(name=caption_str) ).format(name=caption_str)
kwargs["entity_data"] = entity_data
kwargs["edit_prompt"] = edit_prompt kwargs["edit_prompt"] = edit_prompt
if value_type not in [int, float, Decimal, str]: 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 logging import getLogger
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ....model.descriptors import FieldDescriptor from ....model.descriptors import BotContext, FieldDescriptor
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase from ....model.user import UserBase
from ..context import ContextData, CallbackCommand from ..context import ContextData, CallbackCommand
@@ -14,7 +14,7 @@ from ....utils.main import get_send_message, get_field_descriptor
from .wrapper import wrap_editor from .wrapper import wrap_editor
if TYPE_CHECKING: if TYPE_CHECKING:
from ....main import QBotApp from ....main import QuickBot
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -23,7 +23,7 @@ router = Router()
@router.callback_query(ContextData.filter(F.command == CallbackCommand.TIME_PICKER)) @router.callback_query(ContextData.filter(F.command == CallbackCommand.TIME_PICKER))
async def time_picker_callback( 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: if not callback_data.data:
return return
@@ -158,6 +158,13 @@ async def time_picker(
) )
state_data = kwargs["state_data"] 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( await wrap_editor(
keyboard_builder=keyboard_builder, keyboard_builder=keyboard_builder,
@@ -165,6 +172,7 @@ async def time_picker(
callback_data=callback_data, callback_data=callback_data,
state_data=state_data, state_data=state_data,
user=user, user=user,
context=context,
) )
await state.set_data(state_data) await state.set_data(state_data)
@@ -272,12 +280,21 @@ async def date_picker(
state_data = kwargs["state_data"] 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( await wrap_editor(
keyboard_builder=keyboard_builder, keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor, field_descriptor=field_descriptor,
callback_data=callback_data, callback_data=callback_data,
state_data=state_data, state_data=state_data,
user=user, user=user,
context=context,
) )
await state.set_data(state_data) await state.set_data(state_data)
@@ -295,11 +312,11 @@ async def date_picker(
async def date_picker_year( async def date_picker_year(
query: CallbackQuery, query: CallbackQuery,
callback_data: ContextData, callback_data: ContextData,
app: "QBotApp",
state: FSMContext, state: FSMContext,
user: UserBase, user: UserBase,
**kwargs, **kwargs,
): ):
app: "QuickBot" = kwargs["app"]
start_date = datetime.strptime(callback_data.data, "%Y-%m-%d %H-%M") start_date = datetime.strptime(callback_data.data, "%Y-%m-%d %H-%M")
state_data = await state.get_data() state_data = await state.get_data()
@@ -366,12 +383,21 @@ async def date_picker_year(
field_descriptor = get_field_descriptor(app, callback_data) 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( await wrap_editor(
keyboard_builder=keyboard_builder, keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor, field_descriptor=field_descriptor,
callback_data=callback_data, callback_data=callback_data,
state_data=state_data, state_data=state_data,
user=user, user=user,
context=context,
) )
await query.message.edit_reply_markup(reply_markup=keyboard_builder.as_markup()) await query.message.edit_reply_markup(reply_markup=keyboard_builder.as_markup())
@@ -380,9 +406,8 @@ async def date_picker_year(
@router.callback_query( @router.callback_query(
ContextData.filter(F.command == CallbackCommand.DATE_PICKER_MONTH) ContextData.filter(F.command == CallbackCommand.DATE_PICKER_MONTH)
) )
async def date_picker_month( async def date_picker_month(query: CallbackQuery, callback_data: ContextData, **kwargs):
query: CallbackQuery, callback_data: ContextData, app: "QBotApp", **kwargs app: "QuickBot" = kwargs["app"]
):
field_descriptor = get_field_descriptor(app, callback_data) field_descriptor = get_field_descriptor(app, callback_data)
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = await state.get_data() state_data = await state.get_data()

View File

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

View File

@@ -1,16 +1,21 @@
from inspect import iscoroutinefunction
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from aiogram import Router, F from aiogram import Router, F
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from logging import getLogger from logging import getLogger
from sqlalchemy.orm.collections import InstrumentedList
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from quickbot.model.descriptors import BotContext, EntityForm
from ....model import EntityPermission from ....model import EntityPermission
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase from ....model.user import UserBase
from ....model.permissions import check_entity_permission
from ....utils.main import ( from ....utils.main import (
check_entity_permission, build_field_sequence,
get_field_descriptor, get_field_descriptor,
clear_state,
) )
from ....utils.serialization import deserialize, serialize from ....utils.serialization import deserialize, serialize
from ..context import ContextData, CallbackCommand, CommandContext from ..context import ContextData, CallbackCommand, CommandContext
@@ -29,7 +34,7 @@ from .boolean import router as bool_editor_router
from .entity import router as entity_picker_router from .entity import router as entity_picker_router
if TYPE_CHECKING: if TYPE_CHECKING:
from ....main import QBotApp from ....main import QuickBot
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -49,8 +54,8 @@ async def field_editor(message: Message | CallbackQuery, **kwargs):
callback_data: ContextData = kwargs.get("callback_data", None) callback_data: ContextData = kwargs.get("callback_data", None)
db_session: AsyncSession = kwargs["db_session"] db_session: AsyncSession = kwargs["db_session"]
user: UserBase = kwargs["user"] user: UserBase = kwargs["user"]
app: "QBotApp" = kwargs["app"] app: "QuickBot" = kwargs["app"]
# state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data: dict = kwargs["state_data"] state_data: dict = kwargs["state_data"]
entity_data = state_data.get("entity_data") entity_data = state_data.get("entity_data")
@@ -89,6 +94,14 @@ async def field_editor(message: Message | CallbackQuery, **kwargs):
current_value = None current_value = None
context = BotContext(
db_session=db_session,
app=app,
app_state=kwargs["app_state"],
user=user,
message=message,
)
if ( if (
field_descriptor.type_base is bool field_descriptor.type_base is bool
and callback_data.context == CommandContext.ENTITY_FIELD_EDIT and callback_data.context == CommandContext.ENTITY_FIELD_EDIT
@@ -96,23 +109,78 @@ async def field_editor(message: Message | CallbackQuery, **kwargs):
entity = await entity_descriptor.type_.get( entity = await entity_descriptor.type_.get(
session=db_session, id=int(callback_data.entity_id) session=db_session, id=int(callback_data.entity_id)
) )
if check_entity_permission( if await check_entity_permission(
entity=entity, user=user, permission=EntityPermission.UPDATE 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 = ( current_value: bool = (
getattr(entity, field_descriptor.field_name) or False getattr(entity, field_descriptor.field_name) or False
) )
setattr(entity, field_descriptor.field_name, not current_value) 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() await db_session.commit()
stack, context = get_navigation_context(state_data=state_data)
kwargs.update({"callback_data": context}) 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( return await entity_item(
query=message, navigation_stack=stack, **kwargs query=message, navigation_stack=stack, **kwargs
) )
return
if not entity_data and callback_data.context in [ if not entity_data and callback_data.context in [
CommandContext.ENTITY_EDIT, CommandContext.ENTITY_EDIT,
CommandContext.ENTITY_FIELD_EDIT, CommandContext.ENTITY_FIELD_EDIT,
@@ -120,31 +188,62 @@ async def field_editor(message: Message | CallbackQuery, **kwargs):
entity = await entity_descriptor.type_.get( entity = await entity_descriptor.type_.get(
session=kwargs["db_session"], id=int(callback_data.entity_id) session=kwargs["db_session"], id=int(callback_data.entity_id)
) )
if check_entity_permission( if await check_entity_permission(
entity=entity, user=user, permission=EntityPermission.READ entity=entity, user=user, permission=EntityPermission.READ_RLS
): ):
if entity: if entity:
form_name = ( form_name = (
callback_data.form_params.split("&")[0] callback_data.form_params.split("&")[0]
if callback_data.form_params 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 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 = { entity_data = {
key: serialize( key: serialize(
getattr(entity, key), getattr(
entity,
entity_descriptor.fields_descriptors[key].field_name,
),
entity_descriptor.fields_descriptors[key], entity_descriptor.fields_descriptors[key],
) )
for key in ( for key in (
form.edit_field_sequence field_sequence
if callback_data.context == CommandContext.ENTITY_EDIT if callback_data.context == CommandContext.ENTITY_EDIT
else [callback_data.field_name] else [callback_data.field_name]
) )
} }
state_data.update({"entity_data": entity_data}) 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: if entity_data:
current_value = await deserialize( current_value = await deserialize(
session=db_session, session=db_session,
@@ -158,6 +257,18 @@ async def field_editor(message: Message | CallbackQuery, **kwargs):
await show_editor(message=message, current_value=current_value, **kwargs) 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( router.include_routers(
string_editor_router, string_editor_router,
date_picker_router, date_picker_router,

View File

@@ -2,23 +2,31 @@ from inspect import iscoroutinefunction
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from sqlalchemy.orm.collections import InstrumentedList
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from decimal import Decimal from decimal import Decimal
import json import json
from ..context import ContextData, CallbackCommand, CommandContext from ..context import ContextData, CallbackCommand, CommandContext
from ...command_context_filter import CallbackCommandFilter 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 import EntityPermission
from ....model.user import UserBase from ....model.user import UserBase
from ....model.settings import Settings 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 ....model.language import LanguageBase
from ....auth import authorize_command from ....auth import authorize_command
from ....model.permissions import check_entity_permission
from ....utils.main import ( from ....utils.main import (
get_user_permissions, get_user_permissions,
check_entity_permission,
clear_state, clear_state,
get_entity_descriptor, get_entity_descriptor,
get_field_descriptor, get_field_descriptor,
@@ -29,31 +37,90 @@ from ..common.routing import route_callback
from .common import show_editor from .common import show_editor
if TYPE_CHECKING: if TYPE_CHECKING:
from ....main import QBotApp from ....main import QuickBot
router = Router() 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.message(CallbackCommandFilter(CallbackCommand.FIELD_EDITOR_CALLBACK))
@router.callback_query( @router.callback_query(
ContextData.filter(F.command == CallbackCommand.FIELD_EDITOR_CALLBACK) ContextData.filter(F.command == CallbackCommand.FIELD_EDITOR_CALLBACK)
) )
async def field_editor_callback(message: Message | CallbackQuery, **kwargs): async def field_editor_callback(message: Message | CallbackQuery, **kwargs):
app: "QBotApp" = kwargs["app"] app: "QuickBot" = kwargs["app"]
state: FSMContext = kwargs["state"] callback_data: ContextData = kwargs.get("callback_data", None)
state: FSMContext = kwargs["state"]
state_data = await state.get_data() state_data = await state.get_data()
kwargs["state_data"] = state_data kwargs["state_data"] = state_data
if isinstance(message, Message): if isinstance(message, Message):
callback_data: ContextData = kwargs.get("callback_data", None)
context_data = state_data.get("context_data") context_data = state_data.get("context_data")
if context_data: if context_data:
callback_data = ContextData.unpack(context_data) callback_data = ContextData.unpack(context_data)
value = message.text
field_descriptor = get_field_descriptor(app, callback_data) 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 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: if type_base is str and field_descriptor.localizable:
locale_index = int(state_data.get("locale_index")) 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") current_value = state_data.get("current_value")
state_data.update({"value": 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}) kwargs.update({"callback_data": callback_data})
return await show_editor( return await show_editor(
message=message, message=message,
locale_index=locale_index + 1, locale_index=locale_index + 1,
field_descriptor=field_descriptor, field_descriptor=field_descriptor,
entity_descriptor=entity_descriptor, # entity_descriptor=entity_descriptor,
current_value=current_value, current_value=current_value,
value=value, value=value,
**kwargs, **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: else:
callback_data: ContextData = kwargs["callback_data"] field_descriptor = get_field_descriptor(app, callback_data)
if callback_data.data: if callback_data.data:
if callback_data.data == "skip": if callback_data.data == "skip":
value = None value = None
@@ -111,7 +163,6 @@ async def field_editor_callback(message: Message | CallbackQuery, **kwargs):
value = callback_data.data value = callback_data.data
else: else:
value = state_data.get("value") value = state_data.get("value")
field_descriptor = get_field_descriptor(app, callback_data)
kwargs.update( kwargs.update(
{ {
@@ -144,6 +195,8 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
) )
clear_state(state_data=state_data)
return await route_callback(message=message, back=True, **kwargs) return await route_callback(message=message, back=True, **kwargs)
elif callback_data.context in [ elif callback_data.context in [
@@ -152,9 +205,12 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
CommandContext.ENTITY_FIELD_EDIT, CommandContext.ENTITY_FIELD_EDIT,
CommandContext.COMMAND_FORM, CommandContext.COMMAND_FORM,
]: ]:
app: "QBotApp" = kwargs["app"] app: "QuickBot" = kwargs["app"]
entity_descriptor = get_entity_descriptor(app, callback_data) 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: if callback_data.context == CommandContext.COMMAND_FORM:
field_sequence = list(field_descriptor.command.param_form.keys()) field_sequence = list(field_descriptor.command.param_form.keys())
current_index = field_sequence.index(callback_data.field_name) 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 = ( form_name = (
callback_data.form_params.split("&")[0] callback_data.form_params.split("&")[0]
if callback_data.form_params 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 form_name, entity_descriptor.default_form
) )
if form.edit_field_sequence: if form.edit_field_sequence:
field_sequence = form.edit_field_sequence field_sequence = form.edit_field_sequence
else: 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, entity_descriptor=entity_descriptor,
user=user, user=user,
callback_data=callback_data, callback_data=callback_data,
state_data=state_data,
context=context,
) )
current_index = ( current_index = (
@@ -186,9 +251,7 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
) )
field_descriptors = entity_descriptor.fields_descriptors field_descriptors = entity_descriptor.fields_descriptors
entity_data = state_data.get("entity_data", {}) if callback_data.context == CommandContext.ENTITY_CREATE:
if callback_data.context == CommandContext.ENTITY_CREATE and not entity_data:
stack = state_data.get("navigation_stack", []) stack = state_data.get("navigation_stack", [])
prev_callback_data = ContextData.unpack(stack[-1]) if stack else None prev_callback_data = ContextData.unpack(stack[-1]) if stack else None
if ( if (
@@ -199,15 +262,15 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
prev_form_name = ( prev_form_name = (
prev_callback_data.form_params.split("&")[0] prev_callback_data.form_params.split("&")[0]
if prev_callback_data.form_params if prev_callback_data.form_params
else "default" else None
) )
prev_form_params = ( prev_form_params = (
prev_callback_data.form_params.split("&")[1:] prev_callback_data.form_params.split("&")[1:]
if prev_callback_data.form_params if prev_callback_data.form_params
else [] else []
) )
prev_form_list = entity_descriptor.lists.get( prev_form_list: EntityList = entity_descriptor.lists.get(
prev_form_name or "default", entity_descriptor.default_list prev_form_name, entity_descriptor.default_list
) )
if prev_form_list.static_filters: 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 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}) state_data.update({"entity_data": entity_data})
next_field_name = field_sequence[current_index + 1] next_field_name = field_sequence[current_index + 1]
@@ -256,7 +319,7 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
) )
else: 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 # 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 # if user has no CREATE_ALL permission
@@ -267,12 +330,39 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
]: ]:
user_permissions = get_user_permissions(user, entity_descriptor) user_permissions = get_user_permissions(user, entity_descriptor)
for role in user.roles: if entity_descriptor.rls_filters:
if ( filters = []
role in entity_descriptor.ownership_fields if isinstance(entity_descriptor.rls_filters, Filter):
and EntityPermission.CREATE_ALL not in user_permissions 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
)
): ):
entity_data[entity_descriptor.ownership_fields[role]] = user.id 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 = { deser_entity_data = {
key: await deserialize( key: await deserialize(
@@ -283,20 +373,52 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
for key, value in entity_data.items() 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: if callback_data.context == CommandContext.ENTITY_CREATE:
entity_type = entity_descriptor.type_ entity_type = entity_descriptor.type_
user_permissions = get_user_permissions(user, entity_descriptor) user_permissions = get_user_permissions(user, entity_descriptor)
if ( if (
EntityPermission.CREATE not in user_permissions EntityPermission.CREATE_RLS not in user_permissions
and EntityPermission.CREATE_ALL not in user_permissions and EntityPermission.CREATE_ALL not in user_permissions
): ):
return await message.answer( return await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
) )
new_entity = entity_type(**deser_entity_data)
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,
context,
)
else:
can_create = entity_descriptor.before_create_save(
new_entity,
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},
)
if isinstance(can_create, bool) and can_create:
new_entity = await entity_type.create( new_entity = await entity_type.create(
session=db_session, session=db_session,
obj_in=entity_type(**deser_entity_data), obj_in=new_entity,
commit=True, commit=True,
) )
@@ -304,25 +426,21 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
if iscoroutinefunction(entity_descriptor.on_created): if iscoroutinefunction(entity_descriptor.on_created):
await entity_descriptor.on_created( await entity_descriptor.on_created(
new_entity, new_entity,
EntityEventContext( context,
db_session=db_session, app=app, message=message
),
) )
else: else:
entity_descriptor.on_created( entity_descriptor.on_created(
new_entity, new_entity,
EntityEventContext( context,
db_session=db_session, app=app, message=message
),
) )
form_name = ( form_name = (
callback_data.form_params.split("&")[0] callback_data.form_params.split("&")[0]
if callback_data.form_params if callback_data.form_params
else "default" else None
) )
form_list = entity_descriptor.lists.get( form_list = entity_descriptor.lists.get(
form_name or "default", entity_descriptor.default_list form_name, entity_descriptor.default_list
) )
state_data["navigation_context"] = ContextData( state_data["navigation_context"] = ContextData(
@@ -349,15 +467,55 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
text=(await Settings.get(Settings.APP_STRINGS_NOT_FOUND)) text=(await Settings.get(Settings.APP_STRINGS_NOT_FOUND))
) )
if not check_entity_permission( if not await check_entity_permission(
entity=entity, user=user, permission=EntityPermission.UPDATE entity=entity, user=user, permission=EntityPermission.UPDATE_RLS
): ):
return await message.answer( return await message.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) 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(): for key, value in deser_entity_data.items():
setattr(entity, key, value) new_values[
entity.bot_entity_descriptor.fields_descriptors[key].field_name
] = 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() await db_session.commit()
await db_session.refresh(entity) await db_session.refresh(entity)
@@ -365,18 +523,18 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
if entity_descriptor.on_updated: if entity_descriptor.on_updated:
if iscoroutinefunction(entity_descriptor.on_updated): if iscoroutinefunction(entity_descriptor.on_updated):
await entity_descriptor.on_updated( await entity_descriptor.on_updated(
old_values,
entity, entity,
EntityEventContext( context,
db_session=db_session, app=app, message=message
),
) )
else: else:
entity_descriptor.on_updated( entity_descriptor.on_updated(
old_values,
entity, entity,
EntityEventContext( context,
db_session=db_session, app=app, message=message
),
) )
else:
await db_session.rollback()
elif callback_data.context == CommandContext.COMMAND_FORM: elif callback_data.context == CommandContext.COMMAND_FORM:
clear_state(state_data=state_data) clear_state(state_data=state_data)
@@ -393,9 +551,8 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
cmd = app.bot_commands.get(callback_data.user_command.split("&")[0]) 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) 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) 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 logging import getLogger
from typing import Any from typing import Any
from ....model.descriptors import FieldDescriptor from ....model.descriptors import BotContext, FieldDescriptor
from ....model.language import LanguageBase from ....model.language import LanguageBase
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase from ....model.user import UserBase
@@ -74,25 +74,39 @@ async def string_editor(
state_data.update({"context_data": context_data.pack()}) 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 = ( _current_value_caption = (
f"{_current_value[:30]}..." if len(_current_value) > 30 else _current_value f"{_current_value[:30]}..." if len(_current_value) > 30 else _current_value
) )
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=_current_value_caption, text=_current_value_caption,
copy_text=CopyTextButton(text=_current_value), copy_text=CopyTextButton(text=_current_value[:256]),
) )
) )
state_data = kwargs["state_data"] 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( await wrap_editor(
keyboard_builder=keyboard_builder, keyboard_builder=keyboard_builder,
field_descriptor=field_descriptor, field_descriptor=field_descriptor,
callback_data=callback_data, callback_data=callback_data,
state_data=state_data, state_data=state_data,
user=user, user=user,
context=context,
entity=kwargs.get("entity_data"),
) )
await state.set_data(state_data) await state.set_data(state_data)

View File

@@ -1,12 +1,17 @@
from aiogram.types import InlineKeyboardButton from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder 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.settings import Settings
from ....model.descriptors import FieldDescriptor from ....model.descriptors import BotContext, EntityForm, FieldDescriptor
from ....model.user import UserBase from ....model.user import UserBase
from ..context import ContextData, CallbackCommand, CommandContext from ..context import ContextData, CallbackCommand, CommandContext
from ....utils.navigation import get_navigation_context, pop_navigation_context 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( async def wrap_editor(
@@ -15,6 +20,8 @@ async def wrap_editor(
callback_data: ContextData, callback_data: ContextData,
state_data: dict, state_data: dict,
user: UserBase, user: UserBase,
context: BotContext,
entity: BotEntity | Any = None,
): ):
if callback_data.context in [ if callback_data.context in [
CommandContext.ENTITY_CREATE, CommandContext.ENTITY_CREATE,
@@ -22,9 +29,9 @@ async def wrap_editor(
CommandContext.ENTITY_FIELD_EDIT, CommandContext.ENTITY_FIELD_EDIT,
CommandContext.COMMAND_FORM, CommandContext.COMMAND_FORM,
]: ]:
btns = []
show_back = True show_back = True
show_cancel = True show_cancel = True
if callback_data.context == CommandContext.COMMAND_FORM: if callback_data.context == CommandContext.COMMAND_FORM:
field_sequence = list(field_descriptor.command.param_form.keys()) field_sequence = list(field_descriptor.command.param_form.keys())
field_index = field_sequence.index(callback_data.field_name) field_index = field_sequence.index(callback_data.field_name)
@@ -34,18 +41,20 @@ async def wrap_editor(
form_name = ( form_name = (
callback_data.form_params.split("&")[0] callback_data.form_params.split("&")[0]
if callback_data.form_params 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 form_name, field_descriptor.entity_descriptor.default_form
) )
if form.edit_field_sequence: if form.edit_field_sequence:
field_sequence = form.edit_field_sequence field_sequence = form.edit_field_sequence
else: else:
field_sequence = build_field_sequence( field_sequence = await build_field_sequence(
entity_descriptor=field_descriptor.entity_descriptor, entity_descriptor=field_descriptor.entity_descriptor,
user=user, user=user,
callback_data=callback_data, callback_data=callback_data,
state_data=state_data,
context=context,
) )
field_index = ( field_index = (
field_sequence.index(field_descriptor.name) field_sequence.index(field_descriptor.name)
@@ -54,8 +63,55 @@ async def wrap_editor(
else 0 else 0
) )
stack, context = get_navigation_context(state_data=state_data) stack, navigation_context = get_navigation_context(state_data=state_data)
context = pop_navigation_context(stack) 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: if field_index > 0 and show_back:
btns.append( 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( btns.append(
InlineKeyboardButton( InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_SKIP_BTN)), text=(await Settings.get(Settings.APP_STRINGS_SKIP_BTN)),
@@ -96,7 +155,11 @@ async def wrap_editor(
keyboard_builder.row( keyboard_builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_CANCEL_BTN)), 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 sqlmodel.ext.asyncio.session import AsyncSession
from ....model.descriptors import ( from ....model.descriptors import (
EntityForm,
FieldEditButton, FieldEditButton,
CommandButton, CommandButton,
InlineButton, InlineButton,
EntityEventContext, BotContext,
) )
from ....model.bot_entity import BotEntity
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase from ....model.user import UserBase
from ....model import EntityPermission from ....model import EntityPermission
from ....model.permissions import check_entity_permission
from ....utils.main import ( from ....utils.main import (
check_entity_permission,
get_send_message, get_send_message,
clear_state, clear_state,
get_value_repr, get_value_repr,
get_callable_str, get_callable_str,
get_entity_descriptor, get_entity_descriptor,
build_field_sequence, build_field_sequence,
get_user_permissions,
) )
from ..context import ContextData, CallbackCommand, CommandContext from ..context import ContextData, CallbackCommand, CommandContext
from ....utils.navigation import ( from ....utils.navigation import (
@@ -33,7 +34,7 @@ from ....utils.navigation import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from ....main import QBotApp from ....main import QuickBot
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -49,6 +50,7 @@ async def entity_item_callback(query: CallbackQuery, **kwargs):
clear_state(state_data=state_data) clear_state(state_data=state_data)
stack = save_navigation_context(callback_data=callback_data, 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) await entity_item(query=query, navigation_stack=stack, **kwargs)
@@ -58,7 +60,7 @@ async def entity_item(
callback_data: ContextData, callback_data: ContextData,
db_session: AsyncSession, db_session: AsyncSession,
user: UserBase, user: UserBase,
app: "QBotApp", app: "QuickBot",
navigation_stack: list[ContextData], navigation_stack: list[ContextData],
**kwargs, **kwargs,
): ):
@@ -70,34 +72,48 @@ async def entity_item(
entity_item = await entity_type.get(session=db_session, id=callback_data.entity_id) 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"] 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( return await query.answer(
text=(await Settings.get(Settings.APP_STRINGS_NOT_FOUND)) text=(await Settings.get(Settings.APP_STRINGS_NOT_FOUND))
) )
# is_owned = issubclass(entity_type, OwnedBotEntity) # is_owned = issubclass(entity_type, OwnedBotEntity)
if not check_entity_permission( if query and not await check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.READ entity=entity_item, user=user, permission=EntityPermission.READ_RLS
): ):
return await query.answer( return await query.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
) )
can_edit = check_entity_permission( can_edit = await check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.UPDATE entity=entity_item, user=user, permission=EntityPermission.UPDATE_RLS
) )
form = entity_descriptor.forms.get( form: EntityForm = entity_descriptor.forms.get(
callback_data.form_params or "default", entity_descriptor.default_form 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: if form.form_buttons:
context = EntityEventContext(db_session=db_session, app=app, message=query)
for edit_buttons_row in form.form_buttons: for edit_buttons_row in form.form_buttons:
btn_row = [] btn_row = []
for button in edit_buttons_row: for button in edit_buttons_row:
@@ -105,7 +121,14 @@ async def entity_item(
continue continue
if isinstance(button, FieldEditButton) and can_edit: 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 btn_caption = button.caption
if field_name in entity_descriptor.fields_descriptors: if field_name in entity_descriptor.fields_descriptors:
field_descriptor = 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) field_value = getattr(entity_item, field_descriptor.field_name)
if btn_caption: if btn_caption:
btn_text = await get_callable_str( btn_text = await get_callable_str(
btn_caption, field_descriptor, entity_item, field_value callable_str=btn_caption,
context=context,
entity=entity_item,
) )
else: else:
if field_descriptor.type_base is bool: 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( await get_callable_str(
field_descriptor.caption, callable_str=field_descriptor.caption,
field_descriptor, context=context,
entity_item, descriptor=field_descriptor,
field_value,
) )
if field_descriptor.caption if field_descriptor.caption
else field_name else field_name
@@ -135,10 +159,9 @@ async def entity_item(
else '✏️' else '✏️'
} { } {
await get_callable_str( await get_callable_str(
field_descriptor.caption, callable_str=field_descriptor.caption,
field_descriptor, context=context,
entity_item, descriptor=field_descriptor,
field_value,
) )
if field_descriptor.caption if field_descriptor.caption
else field_name else field_name
@@ -160,7 +183,9 @@ async def entity_item(
btn_caption = button.caption btn_caption = button.caption
btn_text = await get_callable_str( 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): if isinstance(button.command, ContextData):
@@ -202,10 +227,12 @@ async def entity_item(
if form.edit_field_sequence: if form.edit_field_sequence:
field_sequence = form.edit_field_sequence field_sequence = form.edit_field_sequence
else: else:
field_sequence = build_field_sequence( field_sequence = await build_field_sequence(
entity_descriptor=entity_descriptor, entity_descriptor=entity_descriptor,
user=user, user=user,
callback_data=callback_data, callback_data=callback_data,
state_data=state_data,
context=context,
) )
edit_delete_row.append( edit_delete_row.append(
InlineKeyboardButton( InlineKeyboardButton(
@@ -222,8 +249,8 @@ async def entity_item(
) )
if ( if (
check_entity_permission( await check_entity_permission(
entity=entity_item, user=user, permission=EntityPermission.DELETE entity=entity_item, user=user, permission=EntityPermission.DELETE_RLS
) )
and form.show_delete_button and form.show_delete_button
): ):
@@ -243,67 +270,13 @@ async def entity_item(
keyboard_builder.row(*edit_delete_row) keyboard_builder.row(*edit_delete_row)
if form.item_repr: if form.item_repr:
item_text = form.item_repr(entity_descriptor, entity_item) item_text = await get_callable_str(
else: callable_str=form.item_repr,
entity_caption = ( context=context,
await get_callable_str( entity=entity_item,
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: else:
value = await get_value_repr( item_text = await item_repr(entity_item=entity_item, context=context)
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 ''}"
context = pop_navigation_context(navigation_stack) context = pop_navigation_context(navigation_stack)
if context: if context:
@@ -318,6 +291,87 @@ async def entity_item(
# state_data = kwargs["state_data"] # state_data = kwargs["state_data"]
# await state.set_data(state_data) # await state.set_data(state_data)
if query:
send_message = get_send_message(query) send_message = get_send_message(query)
await send_message(text=item_text, reply_markup=keyboard_builder.as_markup()) 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(),
)
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 sqlmodel.ext.asyncio.session import AsyncSession
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from quickbot.model.descriptors import EntityEventContext from quickbot.model.descriptors import BotContext
from ..context import ContextData, CallbackCommand from ..context import ContextData, CallbackCommand
from ....model.user import UserBase from ....model.user import UserBase
from ....model.settings import Settings from ....model.settings import Settings
from ....model import EntityPermission from ....model import EntityPermission
from ....model.permissions import check_entity_permission
from ....utils.main import ( from ....utils.main import (
check_entity_permission,
get_entity_item_repr, get_entity_item_repr,
get_entity_descriptor, get_entity_descriptor,
) )
from ..common.routing import route_callback from ..common.routing import route_callback
if TYPE_CHECKING: if TYPE_CHECKING:
from ....main import QBotApp from ....main import QuickBot
router = Router() router = Router()
@@ -31,7 +31,7 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
callback_data: ContextData = kwargs["callback_data"] callback_data: ContextData = kwargs["callback_data"]
user: UserBase = kwargs["user"] user: UserBase = kwargs["user"]
db_session: AsyncSession = kwargs["db_session"] db_session: AsyncSession = kwargs["db_session"]
app: "QBotApp" = kwargs["app"] app: "QuickBot" = kwargs["app"]
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]
state_data = await state.get_data() state_data = await state.get_data()
kwargs["state_data"] = state_data kwargs["state_data"] = state_data
@@ -42,32 +42,64 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
session=db_session, id=int(callback_data.entity_id) session=db_session, id=int(callback_data.entity_id)
) )
if not check_entity_permission( if not await check_entity_permission(
entity=entity, user=user, permission=EntityPermission.DELETE entity=entity, user=user, permission=EntityPermission.DELETE_RLS
): ):
return await query.answer( return await query.answer(
text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN)) text=(await Settings.get(Settings.APP_STRINGS_FORBIDDEN))
) )
if callback_data.data == "yes": context = BotContext(
entity = await entity_descriptor.type_.remove( db_session=db_session,
session=db_session, id=int(callback_data.entity_id), commit=True app=app,
app_state=kwargs["app_state"],
user=user,
message=query,
) )
if entity_descriptor.on_deleted: if callback_data.data == "yes":
if iscoroutinefunction(entity_descriptor.on_created): can_delete = True
await entity_descriptor.on_deleted(
if entity_descriptor.before_delete:
if iscoroutinefunction(entity_descriptor.before_delete):
can_delete = await entity_descriptor.before_delete(
entity, entity,
EntityEventContext(db_session=db_session, app=app, message=query), context,
) )
else: else:
entity_descriptor.on_deleted( can_delete = entity_descriptor.before_delete(
entity, 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,
)
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) await route_callback(message=query, **kwargs)
else:
await route_callback(message=query, back=False, **kwargs)
elif not callback_data.data: elif not callback_data.data:
entity = await entity_descriptor.type_.get( entity = await entity_descriptor.type_.get(
session=db_session, id=int(callback_data.entity_id) session=db_session, id=int(callback_data.entity_id)
@@ -76,7 +108,12 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
return await query.message.edit_text( return await query.message.edit_text(
text=( text=(
await Settings.get(Settings.APP_STRINGS_CONFIRM_DELETE_P_NAME) 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() reply_markup=InlineKeyboardBuilder()
.row( .row(
InlineKeyboardButton( InlineKeyboardButton(

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,123 @@
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from inspect import iscoroutinefunction
from typing import TYPE_CHECKING
from quickbot.utils.main import clear_state
from quickbot.utils.navigation import (
get_navigation_context,
pop_navigation_context,
)
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 quickbot.main import QuickBot
from quickbot.model.user import UserBase
from ..context import ContextData, CallbackCommand, CommandContext
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: "QuickBot" = kwargs["app"]
user: "UserBase" = kwargs["user"]
entity_data_dict: dict = state_data.get("entity_data")
form_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
)
callback_context = CommandCallbackContext(
message=message,
callback_data=callback_data,
form_data=form_data,
db_session=kwargs["db_session"],
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,
)
if cmd.pre_check and (not cmd.param_form or (cmd.param_form and form_data is None)):
if iscoroutinefunction(cmd.pre_check):
if not await cmd.pre_check(callback_context):
return
else:
if not cmd.pre_check(callback_context):
return
if form_data is None and cmd.param_form:
field_descriptor = list(cmd.param_form.values())[0]
kwargs["callback_data"] = ContextData(
command=CallbackCommand.FIELD_EDITOR,
context=CommandContext.COMMAND_FORM,
field_name=field_descriptor.name,
user_command=callback_data.user_command,
)
return await field_editor(message=message, **kwargs)
await cmd.handler(callback_context)
if callback_context.register_navigation:
await state.set_data(state_data)
stack, navigation_context = get_navigation_context(state_data=state_data)
back_callback_data = pop_navigation_context(stack=stack)
if back_callback_data:
callback_context.keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data=back_callback_data.pack(),
)
)
if message:
send_message = get_send_message(message)
if callback_context.message_text:
await send_message(
text=callback_context.message_text,
reply_markup=callback_context.keyboard_builder.as_markup(),
)
elif isinstance(message, CallbackQuery):
await message.message.edit_reply_markup(
reply_markup=callback_context.keyboard_builder.as_markup()
)
else:
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

@@ -1,27 +1,16 @@
from aiogram import Router, F from aiogram import Router, F
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton from aiogram.types import Message, CallbackQuery
from inspect import iscoroutinefunction
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from quickbot.utils.main import clear_state from quickbot.utils.main import clear_state
from quickbot.utils.navigation import ( from quickbot.utils.navigation import save_navigation_context
save_navigation_context,
get_navigation_context,
pop_navigation_context,
)
from quickbot.bot.handlers.editors.main import field_editor
from quickbot.bot.handlers.common.routing import route_callback
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: if TYPE_CHECKING:
from quickbot.main import QBotApp from quickbot.main import QuickBot
from ..context import ContextData, CallbackCommand, CommandContext
from ..context import ContextData, CallbackCommand
from .command_handler import command_handler
router = Router() router = Router()
@@ -54,7 +43,7 @@ async def command_callback(message: CallbackQuery, **kwargs):
async def process_command_handler(message: Message | CallbackQuery, **kwargs): async def process_command_handler(message: Message | CallbackQuery, **kwargs):
state_data: dict = kwargs["state_data"] state_data: dict = kwargs["state_data"]
callback_data: ContextData = kwargs["callback_data"] callback_data: ContextData = kwargs["callback_data"]
app: "QBotApp" = kwargs["app"] app: "QuickBot" = kwargs["app"]
cmd = app.bot_commands.get(callback_data.user_command.split("&")[0]) cmd = app.bot_commands.get(callback_data.user_command.split("&")[0])
if cmd is None: if cmd is None:
@@ -68,92 +57,4 @@ async def process_command_handler(message: Message | CallbackQuery, **kwargs):
clear_state(state_data=state_data) clear_state(state_data=state_data)
save_navigation_context(callback_data=callback_data, state_data=state_data) save_navigation_context(callback_data=callback_data, state_data=state_data)
await cammand_handler(message=message, cmd=cmd, **kwargs) await command_handler(message=message, cmd=cmd, **kwargs)
async def cammand_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"]
entity_data_dict: dict = state_data.get("entity_data")
form_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
)
callback_context = CommandCallbackContext(
message=message,
callback_data=callback_data,
form_data=form_data,
db_session=kwargs["db_session"],
user=kwargs["user"],
app=app,
state_data=state_data,
state=state,
i18n=kwargs["i18n"],
register_navigation=cmd.register_navigation,
kwargs=kwargs,
)
if cmd.pre_check and (not cmd.param_form or (cmd.param_form and form_data is None)):
if iscoroutinefunction(cmd.pre_check):
if not await cmd.pre_check(callback_context):
return
else:
if not cmd.pre_check(callback_context):
return
if form_data is None and cmd.param_form:
field_descriptor = list(cmd.param_form.values())[0]
kwargs["callback_data"] = ContextData(
command=CallbackCommand.FIELD_EDITOR,
context=CommandContext.COMMAND_FORM,
field_name=field_descriptor.name,
user_command=callback_data.user_command,
)
return await field_editor(message=message, **kwargs)
await cmd.handler(callback_context)
if callback_context.register_navigation:
await state.set_data(state_data)
stack, navigation_context = get_navigation_context(state_data=state_data)
back_callback_data = pop_navigation_context(stack=stack)
if back_callback_data:
callback_context.keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_BACK_BTN)),
callback_data=back_callback_data.pack(),
)
)
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(
reply_markup=callback_context.keyboard_builder.as_markup()
)
else:
clear_state(state_data=state_data)
await route_callback(message, back=True, **kwargs)

View File

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

View File

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

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))
}

View File

@@ -1,5 +1,21 @@
"""
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 contextlib import asynccontextmanager
from typing import Callable, Any from inspect import iscoroutinefunction
from typing import Union
from typing import Annotated, Callable, Any, Generic, TypeVar
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer from aiogram.client.telegram import TelegramAPIServer
@@ -7,33 +23,60 @@ from aiogram.client.default import DefaultBotProperties
from aiogram.types import Message, BotCommand as AiogramBotCommand from aiogram.types import Message, BotCommand as AiogramBotCommand
from aiogram.utils.callback_answer import CallbackAnswerMiddleware from aiogram.utils.callback_answer import CallbackAnswerMiddleware
from aiogram.utils.i18n import I18n from aiogram.utils.i18n import I18n
from fastapi import FastAPI from fastapi import Depends, FastAPI, Request, Body, Path, HTTPException
from fastapi.applications import Lifespan, AppType from fastapi.applications import Lifespan, AppType
from secrets import token_hex from fastapi.datastructures import State
from logging import getLogger 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 .config import Config
from .bot.handlers.forms.entity_form import entity_item
from .fsm.db_storage import DbStorage from .fsm.db_storage import DbStorage
from .middleware.telegram import AuthMiddleware, I18nMiddleware from .middleware.telegram import AuthMiddleware, I18nMiddleware
from .model.bot_entity import BotEntity
from .model.user import UserBase from .model.user import UserBase
from .model.entity_metadata import EntityMetadata from .model.bot_metadata import BotMetadata
from .model.descriptors import BotCommand 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 .router import Router
from .api_route.models import list_entity_items, get_me
from .api_route.depends import get_current_user
logger = getLogger(__name__) logger = getLogger(__name__)
UserType = TypeVar("UserType", bound=UserBase, default=UserBase)
ConfigType = TypeVar("ConfigType", bound=Config, default=Config)
@asynccontextmanager @asynccontextmanager
async def default_lifespan(app: "QBotApp"): async def default_lifespan(app: "QuickBot"):
logger.debug("starting qbot app") logger.debug("starting qbot app")
if app.lifespan_bot_init: if app.lifespan_bot_init:
if app.config.USE_NGROK:
app.ngrok_init()
await app.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") logger.info("qbot app started")
if app.lifespan: if app.lifespan:
@@ -42,26 +85,31 @@ async def default_lifespan(app: "QBotApp"):
else: else:
yield 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") logger.info("qbot app stopped")
class QBotApp[UserType: UserBase](FastAPI): class QuickBot(Generic[UserType, ConfigType], FastAPI):
""" """
Main class for the QBot application 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__( def __init__(
self, self,
user_class: UserType = None, config: ConfigType = Config(),
config: Config | None = None, user_class: type[UserType] = None,
bot_start: Callable[ bot_start: Callable[
[ [
Callable[[Message, Any], tuple[UserType, bool]], Callable[[Message, Any], tuple[UserType, bool]],
@@ -72,64 +120,82 @@ class QBotApp[UserType: UserBase](FastAPI):
] = None, ] = None,
lifespan: Lifespan[AppType] | None = None, lifespan: Lifespan[AppType] | None = None,
lifespan_bot_init: bool = True, lifespan_bot_init: bool = True,
lifespan_set_webhook: bool = True,
webhook_handler: Callable[["QuickBot", Request], Any] = None,
allowed_updates: list[str] | None = None, allowed_updates: list[str] | None = None,
*args,
**kwargs, **kwargs,
): ):
if config is None: # --- Initialize default user class if not provided ---
config = Config()
if user_class is None: if user_class is None:
from .model.default_user import DefaultUser from .model.default_user import DefaultUser
user_class = DefaultUser user_class = DefaultUser
self.allowed_updates = allowed_updates or ["message", "callback_query"] self.allowed_updates = list(
(set(allowed_updates or [])).union({"message", "callback_query"})
)
self.user_class = user_class self.user_class = user_class
self.entity_metadata: EntityMetadata = user_class.entity_metadata self.bot_metadata: BotMetadata = user_class.bot_metadata
self.bot_metadata.app = self
self.config = config self.config = config
self.lifespan = lifespan self.lifespan = lifespan
# --- Setup Telegram API server and session ---
api_server = TelegramAPIServer.from_base( api_server = TelegramAPIServer.from_base(
self.config.TELEGRAM_BOT_SERVER, self.config.TELEGRAM_BOT_SERVER,
is_local=self.config.TELEGRAM_BOT_SERVER_IS_LOCAL, is_local=self.config.TELEGRAM_BOT_SERVER_IS_LOCAL,
) )
session = AiohttpSession(api=api_server) session = AiohttpSession(api=api_server)
# --- Initialize Telegram Bot instance ---
self.bot = Bot( self.bot = Bot(
token=self.config.TELEGRAM_BOT_TOKEN, token=self.config.TELEGRAM_BOT_TOKEN,
session=session, session=session,
default=DefaultBotProperties(parse_mode="HTML"), default=DefaultBotProperties(
parse_mode="HTML", link_preview_is_disabled=True
),
) )
# --- Setup Aiogram dispatcher with DB storage for FSM ---
dp = Dispatcher(storage=DbStorage()) dp = Dispatcher(storage=DbStorage())
i18n = I18n(path="locales", default_locale="en", domain="messages") # --- Setup i18n and middleware ---
i18n_middleware = I18nMiddleware(user_class=user_class, i18n=i18n) self.i18n = I18n(path="locales", default_locale="en", domain="messages")
i18n_middleware = I18nMiddleware(user_class=user_class, i18n=self.i18n)
i18n_middleware.setup(dp) i18n_middleware.setup(dp)
dp.callback_query.middleware(CallbackAnswerMiddleware()) dp.callback_query.middleware(CallbackAnswerMiddleware())
# --- Register core routers (start, main menu) ---
from .bot.handlers.start import router as start_router from .bot.handlers.start import router as start_router
dp.include_router(start_router) dp.include_router(start_router)
from .bot.handlers.menu.main import router as main_menu_router from .bot.handlers.menu.main import router as main_menu_router
auth = AuthMiddleware(user_class=user_class) # Register authentication middleware for menu routers
main_menu_router.message.middleware.register(auth) self.auth = AuthMiddleware(user_class=user_class)
main_menu_router.callback_query.middleware.register(auth) main_menu_router.message.middleware.register(self.auth)
main_menu_router.callback_query.middleware.register(self.auth)
dp.include_router(main_menu_router) dp.include_router(main_menu_router)
self.dp = dp self.dp = dp
self.bot_auth_token = token_hex(128) # --- Extension points for custom bot start and webhook handlers ---
self.start_handler = bot_start self.start_handler = bot_start
self.webhook_handler = webhook_handler
self.bot_commands = dict[str, BotCommand]() self.bot_commands = dict[str, BotCommand]()
self.lifespan_bot_init = lifespan_bot_init self.lifespan_bot_init = lifespan_bot_init
self.lifespan_set_webhook = lifespan_set_webhook
super().__init__(lifespan=default_lifespan, *args, **kwargs) # --- 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 from .api_route.telegram import router as telegram_router
self.include_router(telegram_router, prefix="/telegram", tags=["telegram"]) self.include_router(telegram_router, prefix="/telegram", tags=["telegram"])
@@ -137,54 +203,422 @@ class QBotApp[UserType: UserBase](FastAPI):
self.root_router._commands = self.bot_commands self.root_router._commands = self.bot_commands
self.command = self.root_router.command 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): 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 router in routers:
for command_name, command in router._commands.items(): for command_name, command in router._commands.items():
self.bot_commands[command_name] = command 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): 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]]]() commands_captions = dict[str, list[tuple[str, str]]]()
for command_name, command in self.bot_commands.items(): for command_name, command in self.bot_commands.items():
if command.show_in_bot_commands: if command.show_in_bot_commands:
if isinstance(command.caption, str) or command.caption is None: if isinstance(command.caption, str) or command.caption is None:
# Default locale (or no caption provided)
if "default" not in commands_captions: if "default" not in commands_captions:
commands_captions["default"] = [] commands_captions["default"] = []
commands_captions["default"].append( commands_captions["default"].append(
(command_name, command.caption or command_name) (command_name, command.caption or command_name)
) )
else: else:
# Localized captions per locale
for locale, description in command.caption.items(): for locale, description in command.caption.items():
locale = "default" if locale == "en" else locale
if locale not in commands_captions: if locale not in commands_captions:
commands_captions[locale] = [] commands_captions[locale] = []
commands_captions[locale].append((command_name, description)) commands_captions[locale].append((command_name, description))
# Register commands with Telegram for each locale
for locale, commands in commands_captions.items(): for locale, commands in commands_captions.items():
await self.bot.set_my_commands( await self.bot.set_my_commands(
[ [
@@ -194,14 +628,127 @@ class QBotApp[UserType: UserBase](FastAPI):
language_code=None if locale == "default" else locale, 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( await self.bot.set_webhook(
url=f"{self.config.TELEGRAM_WEBHOOK_URL}/telegram/webhook", url=f"{self.config.TELEGRAM_WEBHOOK_URL}/telegram/webhook",
drop_pending_updates=True, drop_pending_updates=True,
allowed_updates=self.allowed_updates, allowed_updates=self.allowed_updates,
secret_token=self.bot_auth_token, secret_token=self.config.TELEGRAM_WEBHOOK_AUTH_KEY,
) )
async def bot_close(self): async def show_form(
await self.bot.delete_webhook() self,
await self.bot.log_out() app_state: State,
await self.bot.close() 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, event: TelegramObject,
data: Dict[str, Any], data: Dict[str, Any],
) -> Any: ) -> Any:
user = await self.user_class.get( if event.business_connection_id:
id=event.from_user.id, session=data["db_session"] 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: if user and user.is_active:
data["user"] = user data["user"] = user

View File

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

View File

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

@@ -1,28 +1,47 @@
"""
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 types import NoneType, UnionType
from typing import ( from typing import (
Any, Any,
ClassVar, ClassVar,
ForwardRef, ForwardRef,
Optional, Optional,
Self,
Union, Union,
get_args, get_args,
get_origin, get_origin,
TYPE_CHECKING, TYPE_CHECKING,
dataclass_transform, dataclass_transform,
Self,
) )
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.fields import _Unset
from pydantic_core import PydanticUndefined from pydantic_core import PydanticUndefined
from sqlmodel import SQLModel, BigInteger, Field, select, func, column, col from sqlmodel import SQLModel, BigInteger, Field, select, func
from sqlmodel.main import FieldInfo from sqlmodel.main import FieldInfo
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import SelectOfScalar
from sqlmodel.main import SQLModelMetaclass, RelationshipInfo from sqlmodel.main import SQLModelMetaclass, RelationshipInfo
from .descriptors import EntityDescriptor, EntityField, FieldDescriptor, Filter from .descriptors import (
from .entity_metadata import EntityMetadata EntityDescriptor,
EntityField,
FieldDescriptor,
Filter,
FilterExpression,
)
from .bot_metadata import BotMetadata
from .crud_service import CrudService
from . import session_dep from . import session_dep
from .utils import (
_static_filter_condition,
_build_filter_condition,
_filter_condition,
_apply_rls_filters,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import UserBase from .user import UserBase
@@ -33,11 +52,34 @@ if TYPE_CHECKING:
field_specifiers=(Field, FieldInfo, EntityField, FieldDescriptor), field_specifiers=(Field, FieldInfo, EntityField, FieldDescriptor),
) )
class BotEntityMetaclass(SQLModelMetaclass): 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 = {} _future_references = {}
def __new__(mcs, name, bases, namespace, **kwargs): 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 = {} bot_fields_descriptors = {}
# --- Inherit field descriptors from parent classes (if any) ---
if bases: if bases:
bot_entity_descriptor = bases[0].__dict__.get("bot_entity_descriptor") bot_entity_descriptor = bases[0].__dict__.get("bot_entity_descriptor")
bot_fields_descriptors = ( bot_fields_descriptors = (
@@ -49,50 +91,67 @@ class BotEntityMetaclass(SQLModelMetaclass):
else {} else {}
) )
# --- Process field annotations to create field descriptors ---
if "__annotations__" in namespace: if "__annotations__" in namespace:
for annotation in namespace["__annotations__"]: for annotation in namespace["__annotations__"]:
if annotation in ["bot_entity_descriptor", "entity_metadata"]: # Skip special attributes
if annotation in ["bot_entity_descriptor", "bot_metadata"]:
continue continue
attribute_value = namespace.get(annotation) attribute_value = namespace.get(annotation, PydanticUndefined)
# Skip relationship fields (handled by SQLModel)
if isinstance(attribute_value, RelationshipInfo): if isinstance(attribute_value, RelationshipInfo):
continue continue
descriptor_kwargs = {} descriptor_kwargs = {}
descriptor_name = annotation descriptor_name = annotation
if attribute_value: # --- Process EntityField attributes to extract SQLModel field descriptors ---
if attribute_value is not PydanticUndefined:
if isinstance(attribute_value, EntityField): if isinstance(attribute_value, EntityField):
descriptor_kwargs = attribute_value.__dict__.copy() descriptor_kwargs = attribute_value.__dict__.copy()
# Extract SQLModel field descriptor if present
sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None) # type: FieldInfo sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None) # type: FieldInfo
if sm_descriptor: if sm_descriptor:
# Transfer default values from EntityField to SQLModel descriptor
if ( if (
attribute_value.default is not None attribute_value.default is not PydanticUndefined
and sm_descriptor.default is PydanticUndefined and sm_descriptor.default is PydanticUndefined
): ):
sm_descriptor.default = attribute_value.default sm_descriptor.default = attribute_value.default
if ( if (
attribute_value.default_factory is not None attribute_value.default_factory is not None
and sm_descriptor.default_factory is PydanticUndefined and sm_descriptor.default_factory is None
): ):
sm_descriptor.default_factory = ( sm_descriptor.default_factory = (
attribute_value.default_factory attribute_value.default_factory
) )
if attribute_value.description is not PydanticUndefined:
sm_descriptor.description = attribute_value.description
else: else:
# Create new SQLModel field descriptor if none exists
if ( if (
attribute_value.default is not None attribute_value.default is not None
or attribute_value.default_factory is not None or attribute_value.default_factory is not None
): ):
sm_descriptor = Field() sm_descriptor = Field()
if attribute_value.default is not None: if attribute_value.default is not PydanticUndefined:
sm_descriptor.default = attribute_value.default sm_descriptor.default = attribute_value.default
if attribute_value.default_factory is not None: if attribute_value.default_factory is not None:
sm_descriptor.default_factory = ( sm_descriptor.default_factory = (
attribute_value.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: if sm_descriptor:
namespace[annotation] = sm_descriptor namespace[annotation] = sm_descriptor
else: else:
@@ -100,10 +159,28 @@ class BotEntityMetaclass(SQLModelMetaclass):
descriptor_name = descriptor_kwargs.pop("name") or 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] type_ = namespace["__annotations__"][annotation]
type_origin = get_origin(type_) # --- Create field descriptor with basic information ---
field_descriptor = FieldDescriptor( field_descriptor = FieldDescriptor(
name=descriptor_name, name=descriptor_name,
field_name=annotation, field_name=annotation,
@@ -112,12 +189,18 @@ class BotEntityMetaclass(SQLModelMetaclass):
**descriptor_kwargs, **descriptor_kwargs,
) )
# --- Process type annotations to determine if field is list or optional ---
type_origin = get_origin(type_)
is_list = False is_list = False
is_optional = False is_optional = False
# Handle list types (e.g., List[str])
if type_origin is list: if type_origin is list:
field_descriptor.is_list = is_list = True field_descriptor.is_list = is_list = True
field_descriptor.type_base = type_ = get_args(type_)[0] field_descriptor.type_base = type_ = get_args(type_)[0]
# Handle Union types for optional fields (e.g., Optional[str])
if type_origin is Union: if type_origin is Union:
args = get_args(type_) args = get_args(type_)
if isinstance(args[0], ForwardRef): if isinstance(args[0], ForwardRef):
@@ -127,16 +210,17 @@ class BotEntityMetaclass(SQLModelMetaclass):
field_descriptor.is_optional = is_optional = True field_descriptor.is_optional = is_optional = True
field_descriptor.type_base = type_ = args[0] 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: if type_origin is UnionType and get_args(type_)[1] is NoneType:
field_descriptor.is_optional = is_optional = True field_descriptor.is_optional = is_optional = True
field_descriptor.type_base = type_ = get_args(type_)[0] field_descriptor.type_base = type_ = get_args(type_)[0]
# --- Handle string type references (forward references to other entities) ---
if isinstance(type_, str): if isinstance(type_, str):
type_not_found = True type_not_found = True
for ( for entity_descriptor in BotMetadata().entity_descriptors.values():
entity_descriptor
) in EntityMetadata().entity_descriptors.values():
if type_ == entity_descriptor.class_name: if type_ == entity_descriptor.class_name:
# Resolve the type to the actual entity class
field_descriptor.type_base = entity_descriptor.type_ field_descriptor.type_base = entity_descriptor.type_
field_descriptor.type_ = ( field_descriptor.type_ = (
list[entity_descriptor.type_] list[entity_descriptor.type_]
@@ -153,6 +237,8 @@ class BotEntityMetaclass(SQLModelMetaclass):
) )
type_not_found = False type_not_found = False
break break
# If type not found, store for future resolution
if type_not_found: if type_not_found:
if type_ in mcs._future_references: if type_ in mcs._future_references:
mcs._future_references[type_].append(field_descriptor) mcs._future_references[type_].append(field_descriptor)
@@ -161,14 +247,17 @@ class BotEntityMetaclass(SQLModelMetaclass):
bot_fields_descriptors[descriptor_name] = field_descriptor bot_fields_descriptors[descriptor_name] = field_descriptor
# --- Process entity descriptor configuration ---
descriptor_name = name descriptor_name = name
if "bot_entity_descriptor" in namespace: if "bot_entity_descriptor" in namespace:
# Extract and process custom entity descriptor
entity_descriptor = namespace.pop("bot_entity_descriptor") entity_descriptor = namespace.pop("bot_entity_descriptor")
descriptor_kwargs: dict = entity_descriptor.__dict__.copy() descriptor_kwargs: dict = entity_descriptor.__dict__.copy()
descriptor_name = descriptor_kwargs.pop("name", None) descriptor_name = descriptor_kwargs.pop("name", None)
descriptor_kwargs.pop("__orig_class__", None)
descriptor_name = descriptor_name or name.lower() descriptor_name = descriptor_name or name.lower()
namespace["bot_entity_descriptor"] = EntityDescriptor( entity_descriptor = namespace["bot_entity_descriptor"] = EntityDescriptor(
name=descriptor_name, name=descriptor_name,
class_name=name, class_name=name,
type_=name, type_=name,
@@ -176,39 +265,41 @@ class BotEntityMetaclass(SQLModelMetaclass):
**descriptor_kwargs, **descriptor_kwargs,
) )
else: else:
# Create default entity descriptor
descriptor_name = name.lower() descriptor_name = name.lower()
namespace["bot_entity_descriptor"] = EntityDescriptor( entity_descriptor = namespace["bot_entity_descriptor"] = EntityDescriptor(
name=descriptor_name, name=descriptor_name,
class_name=name, class_name=name,
type_=name, type_=name,
fields_descriptors=bot_fields_descriptors, fields_descriptors=bot_fields_descriptors,
) )
# --- Link field descriptors to their entity descriptor ---
for field_descriptor in bot_fields_descriptors.values(): for field_descriptor in bot_fields_descriptors.values():
field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"] field_descriptor.entity_descriptor = entity_descriptor
# --- Configure table settings (set to True by default) ---
if "table" not in kwargs: if "table" not in kwargs:
kwargs["table"] = True kwargs["table"] = True
# --- If table is set to True, register entity in global metadata ---
if kwargs["table"]: if kwargs["table"]:
entity_metadata = EntityMetadata() # Register entity in global metadata
entity_metadata.entity_descriptors[descriptor_name] = namespace[ entity_metadata = BotMetadata()
"bot_entity_descriptor" entity_metadata.entity_descriptors[descriptor_name] = entity_descriptor
]
# Add entity_metadata to class annotations
if "__annotations__" in namespace: if "__annotations__" in namespace:
namespace["__annotations__"]["entity_metadata"] = ClassVar[ namespace["__annotations__"]["bot_metadata"] = ClassVar[BotMetadata]
EntityMetadata
]
else: else:
namespace["__annotations__"] = { namespace["__annotations__"] = {"bot_metadata": ClassVar[BotMetadata]}
"entity_metadata": ClassVar[EntityMetadata]
}
namespace["entity_metadata"] = entity_metadata namespace["bot_metadata"] = entity_metadata
# --- Create the class using parent metaclass ---
type_ = super().__new__(mcs, name, bases, namespace, **kwargs) type_ = super().__new__(mcs, name, bases, namespace, **kwargs)
# --- Resolve future references now that the class exists ---
if name in mcs._future_references: if name in mcs._future_references:
for field_descriptor in mcs._future_references[name]: for field_descriptor in mcs._future_references[name]:
type_origin = get_origin(field_descriptor.type_) type_origin = get_origin(field_descriptor.type_)
@@ -227,78 +318,63 @@ class BotEntityMetaclass(SQLModelMetaclass):
) )
) )
setattr(namespace["bot_entity_descriptor"], "type_", 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_ return type_
class BotEntity[CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel]( class BotEntity(SQLModel, metaclass=BotEntityMetaclass, table=False):
SQLModel, metaclass=BotEntityMetaclass, table=False """
): Base class for bot entities that provides CRUD operations, filtering,
bot_entity_descriptor: ClassVar[EntityDescriptor] and Row Level Security (RLS) capabilities.
entity_metadata: ClassVar[EntityMetadata]
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( id: int = EntityField(
sm_descriptor=Field(primary_key=True, sa_type=BigInteger), is_visible=False sm_descriptor=Field(primary_key=True, sa_type=BigInteger),
is_visible=False,
default=None,
) )
@classmethod @classmethod
@session_dep @session_dep
async def get(cls, *, session: AsyncSession | None = None, id: int): async def get(
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, cls,
select_statement: SelectOfScalar[Self], *,
filter: str, session: AsyncSession | None = None,
filter_fields: list[str], id: int,
): user: "UserBase | None" = None,
condition = None ) -> Self:
for field in filter_fields: """
if condition is not None: Retrieve a single entity by ID.
condition = condition | (column(field).ilike(f"%{filter}%"))
else: Args:
condition = column(field).ilike(f"%{filter}%") session: Database session (injected by session_dep)
return select_statement.where(condition) 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 @classmethod
@session_dep @session_dep
@@ -306,28 +382,49 @@ class BotEntity[CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel](
cls, cls,
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
static_filter: list[Filter] | Any = None, user: "UserBase",
static_filter: Filter | FilterExpression | Any = None,
filter: str = None, filter: str = None,
filter_fields: list[str] = None, filter_fields: list[str] = None,
ext_filter: Any = None, ext_filter: Any = None,
user: "UserBase" = None,
) -> int: ) -> 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) select_statement = select(func.count()).select_from(cls)
# --- Apply various filter conditions ---
if static_filter: if static_filter:
if isinstance(static_filter, list): if isinstance(static_filter, list):
select_statement = cls._static_filter_condition( select_statement = _static_filter_condition(
select_statement, static_filter select_statement, static_filter
) )
else: else:
select_statement = select_statement.where(static_filter) # 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: if filter and filter_fields:
select_statement = cls._filter_condition( select_statement = _filter_condition(
select_statement, filter, filter_fields select_statement, filter, filter_fields
) )
if ext_filter: if ext_filter:
select_statement = select_statement.where(ext_filter) select_statement = select_statement.where(ext_filter)
if user:
select_statement = cls._ownership_condition(select_statement, user) select_statement = await _apply_rls_filters(cls, select_statement, user)
return await session.scalar(select_statement) return await session.scalar(select_statement)
@classmethod @classmethod
@@ -336,56 +433,60 @@ class BotEntity[CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel](
cls, cls,
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
user: "UserBase | None" = None,
order_by=None, order_by=None,
static_filter: list[Filter] | Any = None, static_filter: Filter | FilterExpression | Any = None,
filter: str = None, filter: str = None,
filter_fields: list[str] = None, filter_fields: list[str] = None,
ext_filter: Any = None, ext_filter: Any = None,
user: "UserBase" = None,
skip: int = 0, skip: int = 0,
limit: int = None, 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) select_statement = select(cls).offset(skip)
if limit: if limit:
select_statement = select_statement.limit(limit) select_statement = select_statement.limit(limit)
# --- Apply various filter conditions ---
if static_filter is not None: if static_filter is not None:
if isinstance(static_filter, list): if isinstance(static_filter, list):
select_statement = cls._static_filter_condition( select_statement = _static_filter_condition(
select_statement, static_filter cls, select_statement, static_filter
) )
else: else:
select_statement = select_statement.where(static_filter) # 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: if filter and filter_fields:
select_statement = cls._filter_condition( select_statement = _filter_condition(
select_statement, filter, filter_fields cls, select_statement, filter, filter_fields
) )
if ext_filter is not None: if ext_filter is not None:
select_statement = select_statement.where(ext_filter) select_statement = select_statement.where(ext_filter)
if user: if user:
select_statement = cls._ownership_condition(select_statement, user) select_statement = await _apply_rls_filters(cls, select_statement, user)
if order_by is not None: if order_by:
select_statement = select_statement.order_by(order_by) select_statement = select_statement.order_by(order_by)
return (await session.exec(select_statement)).all()
@classmethod return (await session.exec(select_statement)).all()
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 @classmethod
@session_dep @session_dep
@@ -393,9 +494,21 @@ class BotEntity[CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel](
cls, cls,
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
obj_in: CreateSchemaType, obj_in: BaseModel,
commit: bool = False, 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): if isinstance(obj_in, cls):
obj = obj_in obj = obj_in
else: else:
@@ -412,13 +525,26 @@ class BotEntity[CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel](
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
id: int, id: int,
obj_in: UpdateSchemaType, obj_in: BaseModel,
commit: bool = False, 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) obj = await session.get(cls, id)
if obj: if obj:
obj_data = obj.model_dump() obj_data = obj.model_dump()
update_data = obj_in.model_dump(exclude_unset=True) update_data = obj_in.model_dump(exclude_unset=True)
# Only update fields present in the update data
for field in obj_data: for field in obj_data:
if field in update_data: if field in update_data:
setattr(obj, field, update_data[field]) setattr(obj, field, update_data[field])
@@ -432,7 +558,18 @@ class BotEntity[CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel](
@session_dep @session_dep
async def remove( async def remove(
cls, *, session: AsyncSession | None = None, id: int, commit: bool = False 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) obj = await session.get(cls, id)
if obj: if obj:
await session.delete(obj) await session.delete(obj)

View File

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

@@ -5,7 +5,11 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from typing import Any, Callable, TYPE_CHECKING, Literal, Union from typing import Any, Callable, TYPE_CHECKING, Literal, Union
from babel.support import LazyProxy from babel.support import LazyProxy
from dataclasses import dataclass, field 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 sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.orm import InstrumentedAttribute
from .role import RoleBase from .role import RoleBase
from . import EntityPermission from . import EntityPermission
@@ -13,37 +17,41 @@ from ..bot.handlers.context import ContextData
if TYPE_CHECKING: if TYPE_CHECKING:
from .bot_entity import BotEntity from .bot_entity import BotEntity
from ..main import QBotApp from ..main import QuickBot
from .user import UserBase from .user import UserBase
from .crud_service import CrudService
from .bot_process import BotProcess
EntityCaptionCallable = Callable[["EntityDescriptor"], str] # EntityCaptionCallable = Callable[["EntityDescriptor"], str]
EntityItemCaptionCallable = Callable[["EntityDescriptor", Any], str] # EntityItemCaptionCallable = Callable[["EntityDescriptor", Any], str]
EntityFieldCaptionCallable = Callable[["FieldDescriptor", Any, Any], str] # EntityFieldCaptionCallable = Callable[["FieldDescriptor", Any, Any], str]
@dataclass @dataclass
class FieldEditButton: class FieldEditButton[T: "BotEntity"]:
field_name: str field: str | Callable[[type[T]], InstrumentedAttribute]
caption: str | LazyProxy | EntityFieldCaptionCallable | None = None caption: str | LazyProxy | Callable[[T, "BotContext"], str] | None = None
visibility: Callable[[Any], bool] | None = None visibility: Callable[[T, "BotContext"], bool] | None = None
@dataclass @dataclass
class CommandButton: class CommandButton[T: "BotEntity"]:
command: ContextData | Callable[[ContextData, Any], ContextData] | str command: ContextData | Callable[[T, "BotContext"], ContextData] | str
caption: str | LazyProxy | EntityItemCaptionCallable caption: str | LazyProxy | Callable[[T, "BotContext"], str] | None = None
visibility: Callable[[Any], bool] | None = None visibility: Callable[[T, "BotContext"], bool] | None = None
@dataclass @dataclass
class InlineButton: class InlineButton[T: "BotEntity"]:
inline_button: InlineKeyboardButton | Callable[[Any], InlineKeyboardButton] inline_button: (
visibility: Callable[[Any], bool] | None = None InlineKeyboardButton | Callable[[T, "BotContext"], InlineKeyboardButton]
)
visibility: Callable[[T, "BotContext"], bool] | None = None
@dataclass @dataclass
class Filter: class Filter[T: "BotEntity"]:
field_name: str field: str | Callable[[type[T]], InstrumentedAttribute]
operator: Literal[ operator: Literal[
"==", "==",
"!=", "!=",
@@ -63,50 +71,159 @@ class Filter:
value: Any | None = None value: Any | None = None
param_index: int | 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 @dataclass
class EntityList: class EntityList[T: "BotEntity"]:
caption: str | LazyProxy | EntityCaptionCallable | None = None caption: (
item_repr: EntityItemCaptionCallable | None = None str | LazyProxy | Callable[["EntityDescriptor", "BotContext"], str] | None
) = None
item_repr: Callable[[T, "BotContext"], str] | None = None
show_add_new_button: bool = True show_add_new_button: bool = True
item_form: str | None = None item_form: str | None = None
pagination: bool = True pagination: bool = True
static_filters: list[Filter] = None static_filters: Filter[T] | FilterExpression[T] | None = None
filtering: bool = False filtering: bool = False
filtering_fields: list[str] = None filtering_fields: list[str] = None
order_by: str | Any | None = None order_by: str | Any | None = None
@dataclass @dataclass
class EntityForm: class EntityForm[T: "BotEntity"]:
item_repr: EntityItemCaptionCallable | None = None item_repr: Callable[[T, "BotContext"], str] | None = None
edit_field_sequence: list[str] = None edit_field_sequence: list[str] = None
form_buttons: list[list[FieldEditButton | CommandButton | InlineButton]] = None form_buttons: list[list[FieldEditButton | CommandButton | InlineButton]] = None
show_edit_button: bool = True show_edit_button: bool = True
show_delete_button: bool = True show_delete_button: bool = True
before_open: Callable[[T, "BotContext"], None] | None = None
@dataclass(kw_only=True) @dataclass(kw_only=True)
class _BaseFieldDescriptor: class _BaseFieldDescriptor[T: "BotEntity"]:
icon: str = None icon: str = None
caption: str | LazyProxy | EntityFieldCaptionCallable | None = None caption: (
description: str | LazyProxy | EntityFieldCaptionCallable | None = None str | LazyProxy | Callable[["FieldDescriptor", "BotContext"], str] | None
edit_prompt: str | LazyProxy | EntityFieldCaptionCallable | None = None ) = None
caption_value: EntityFieldCaptionCallable | None = None description: str | LazyProxy | None = PydanticUndefined
is_visible: bool | None = None 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 localizable: bool = False
bool_false_value: str | LazyProxy = "no" bool_false_value: str | LazyProxy = "no"
bool_true_value: str | LazyProxy = "yes" bool_true_value: str | LazyProxy = "yes"
ep_form: str | None = None ep_form: str | Callable[["BotContext"], str] | None = None
ep_parent_field: str | None = None ep_parent_field: str | Callable[[type[T]], InstrumentedAttribute] | None = None
ep_child_field: str | None = None ep_child_field: str | Callable[[type[T]], InstrumentedAttribute] | None = None
dt_type: Literal["date", "datetime"] = "date" dt_type: Literal["date", "datetime"] = "date"
default: Any = None 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 default_factory: Callable[[], Any] | None = None
@dataclass(kw_only=True) @dataclass(kw_only=True)
class EntityField(_BaseFieldDescriptor): class EntityField[T: "BotEntity"](_BaseFieldDescriptor[T]):
name: str | None = None name: str | None = None
sm_descriptor: Any = None sm_descriptor: Any = None
@@ -117,7 +234,7 @@ class Setting(_BaseFieldDescriptor):
@dataclass(kw_only=True) @dataclass(kw_only=True)
class FormField(_BaseFieldDescriptor): class FormField[T: "BotEntity"](_BaseFieldDescriptor[T]):
name: str | None = None name: str | None = None
type_: type type_: type
@@ -138,12 +255,19 @@ class FieldDescriptor(_BaseFieldDescriptor):
@dataclass(kw_only=True) @dataclass(kw_only=True)
class _BaseEntityDescriptor: class _BaseEntityDescriptor[T: "BotEntity"]:
icon: str = "📘" icon: str = "📘"
full_name: str | LazyProxy | EntityCaptionCallable | None = None full_name: (
full_name_plural: str | LazyProxy | EntityCaptionCallable | None = None str | LazyProxy | Callable[["EntityDescriptor", "BotContext"], str] | None
description: str | LazyProxy | EntityCaptionCallable | None = None ) = None
item_repr: EntityItemCaptionCallable | 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_list: EntityList = field(default_factory=EntityList)
default_form: EntityForm = field(default_factory=EntityForm) default_form: EntityForm = field(default_factory=EntityForm)
lists: dict[str, EntityList] = field(default_factory=dict[str, EntityList]) lists: dict[str, EntityList] = field(default_factory=dict[str, EntityList])
@@ -152,11 +276,11 @@ class _BaseEntityDescriptor:
ownership_fields: dict[RoleBase, str] = field(default_factory=dict[RoleBase, str]) ownership_fields: dict[RoleBase, str] = field(default_factory=dict[RoleBase, str])
permissions: dict[EntityPermission, list[RoleBase]] = field( permissions: dict[EntityPermission, list[RoleBase]] = field(
default_factory=lambda: { default_factory=lambda: {
EntityPermission.LIST: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], EntityPermission.LIST_RLS: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.READ: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], EntityPermission.READ_RLS: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.CREATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], EntityPermission.CREATE_RLS: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.UPDATE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], EntityPermission.UPDATE_RLS: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.DELETE: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER], EntityPermission.DELETE_RLS: [RoleBase.DEFAULT_USER, RoleBase.SUPER_USER],
EntityPermission.LIST_ALL: [RoleBase.SUPER_USER], EntityPermission.LIST_ALL: [RoleBase.SUPER_USER],
EntityPermission.READ_ALL: [RoleBase.SUPER_USER], EntityPermission.READ_ALL: [RoleBase.SUPER_USER],
EntityPermission.CREATE_ALL: [RoleBase.SUPER_USER], EntityPermission.CREATE_ALL: [RoleBase.SUPER_USER],
@@ -164,13 +288,23 @@ class _BaseEntityDescriptor:
EntityPermission.DELETE_ALL: [RoleBase.SUPER_USER], EntityPermission.DELETE_ALL: [RoleBase.SUPER_USER],
} }
) )
on_created: Callable[["BotEntity", "EntityEventContext"], None] | None = None rls_filters: Filter[T] | FilterExpression[T] | None = None
on_deleted: Callable[["BotEntity", "EntityEventContext"], None] | None = None rls_filters_params: Callable[["UserBase"], list[Any]] = lambda user: [user.id]
on_updated: Callable[["BotEntity", "EntityEventContext"], None] | None = None 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) @dataclass(kw_only=True)
class Entity(_BaseEntityDescriptor): class Entity[T: "BotEntity"](_BaseEntityDescriptor[T]):
name: str | None = None name: str | None = None
@@ -183,17 +317,19 @@ class EntityDescriptor(_BaseEntityDescriptor):
@dataclass(kw_only=True) @dataclass(kw_only=True)
class CommandCallbackContext[UT: UserBase]: class CommandCallbackContext:
keyboard_builder: InlineKeyboardBuilder = field( keyboard_builder: InlineKeyboardBuilder = field(
default_factory=InlineKeyboardBuilder default_factory=InlineKeyboardBuilder
) )
message_text: str | None = None message_text: str | None = None
register_navigation: bool = True register_navigation: bool = True
clear_navigation: bool = False
message: Message | CallbackQuery message: Message | CallbackQuery
callback_data: ContextData callback_data: ContextData
db_session: AsyncSession db_session: AsyncSession
user: UT user: "UserBase"
app: "QBotApp" app: "QuickBot"
app_state: State
state_data: dict[str, Any] state_data: dict[str, Any]
state: FSMContext state: FSMContext
form_data: dict[str, Any] form_data: dict[str, Any]
@@ -202,10 +338,13 @@ class CommandCallbackContext[UT: UserBase]:
@dataclass(kw_only=True) @dataclass(kw_only=True)
class EntityEventContext: class BotContext:
db_session: AsyncSession db_session: AsyncSession
app: "QBotApp" app: "QuickBot"
app_state: State
user: "UserBase"
message: Message | CallbackQuery | None = None message: Message | CallbackQuery | None = None
default_handler: Callable[["BotEntity", "BotContext"], None] | None = None
@dataclass(kw_only=True) @dataclass(kw_only=True)
@@ -221,3 +360,31 @@ class BotCommand:
show_cancel_in_param_form: bool = True show_cancel_in_param_form: bool = True
show_back_in_param_form: bool = True show_back_in_param_form: bool = True
handler: Callable[[CommandCallbackContext], None] 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

@@ -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

@@ -2,4 +2,4 @@ from .bot_enum import BotEnum, EnumMember
class LanguageBase(BotEnum): 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

@@ -2,5 +2,5 @@ from .bot_enum import BotEnum, EnumMember
class RoleBase(BotEnum): class RoleBase(BotEnum):
SUPER_USER = EnumMember("super_user") SUPER_USER = EnumMember("super_user", {"default": "admin"})
DEFAULT_USER = EnumMember("default_user") 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 aiogram.utils.i18n.context import get_i18n
from datetime import datetime from datetime import datetime
from sqlmodel import SQLModel, Field, select from sqlmodel import SQLModel, Field, select
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any, get_args, get_origin from typing import Any, get_args, get_origin
from ..db import async_session from ..db import async_session
@@ -193,11 +194,23 @@ class Settings(metaclass=SettingsMetaclass):
) )
@classmethod @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 name = param.field_name
if name not in cls._cache.keys(): 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] ret_val = cls._cache[name]
@@ -213,8 +226,7 @@ class Settings(metaclass=SettingsMetaclass):
return ret_val return ret_val
@classmethod @classmethod
async def load_param(cls, param: FieldDescriptor) -> Any: async def load_param(cls, session: AsyncSession, param: FieldDescriptor) -> Any:
async with async_session() as session:
db_setting = ( db_setting = (
await session.exec( await session.exec(
select(DbSettings).where(DbSettings.name == param.field_name) select(DbSettings).where(DbSettings.name == param.field_name)
@@ -240,20 +252,20 @@ class Settings(metaclass=SettingsMetaclass):
) )
) )
@classmethod # @classmethod
async def load_params(cls): # async def load_params(cls):
async with async_session() as session: # async with async_session() as session:
db_settings = (await session.exec(select(DbSettings))).all() # db_settings = (await session.exec(select(DbSettings))).all()
for db_setting in db_settings: # for db_setting in db_settings:
if db_setting.name in cls.__dict__: # if db_setting.name in cls.__dict__:
setting = cls.__dict__[db_setting.name] # type: FieldDescriptor # setting = cls.__dict__[db_setting.name] # type: FieldDescriptor
cls._cache[db_setting.name] = await deserialize( # cls._cache[db_setting.name] = await deserialize(
session=session, # session=session,
type_=setting.type_, # type_=setting.type_,
value=db_setting.value, # value=db_setting.value,
) # )
cls._loaded = True # cls._loaded = True
@classmethod @classmethod
async def set_param(cls, param: str | FieldDescriptor, value) -> None: async def set_param(cls, param: str | FieldDescriptor, value) -> None:

View File

@@ -1,3 +1,4 @@
from sqlalchemy import BigInteger
from sqlmodel import Field, ARRAY from sqlmodel import Field, ARRAY
from .bot_entity import BotEntity from .bot_entity import BotEntity
@@ -5,6 +6,7 @@ from .bot_enum import EnumType
from .language import LanguageBase from .language import LanguageBase
from .role import RoleBase from .role import RoleBase
from .descriptors import EntityField
from .settings import DbSettings as DbSettings from .settings import DbSettings as DbSettings
from .fsm_storage import FSMStorage as FSMStorage from .fsm_storage import FSMStorage as FSMStorage
from .view_setting import ViewSetting as ViewSetting from .view_setting import ViewSetting as ViewSetting
@@ -13,11 +15,24 @@ from .view_setting import ViewSetting as ViewSetting
class UserBase(BotEntity, table=False): class UserBase(BotEntity, table=False):
__tablename__ = "user" __tablename__ = "user"
lang: LanguageBase = Field(sa_type=EnumType(LanguageBase), default=LanguageBase.EN) id: int = EntityField(
is_active: bool = True description="User Telegram ID",
sm_descriptor=Field(primary_key=True, sa_type=BigInteger),
is_visible=False,
)
name: str 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( roles: list[RoleBase] = Field(
sa_type=ARRAY(EnumType(RoleBase)), default=[RoleBase.DEFAULT_USER] 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: ...

View File

@@ -2,39 +2,36 @@ from babel.support import LazyProxy
from inspect import iscoroutinefunction, signature from inspect import iscoroutinefunction, signature
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from aiogram.utils.i18n import I18n from aiogram.utils.i18n import I18n
from typing import Any, TYPE_CHECKING from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any, TYPE_CHECKING, Callable
import ujson as json import ujson as json
from ..model.bot_entity import BotEntity from quickbot.utils.serialization import deserialize
from ..model.bot_enum import BotEnum from quickbot.model.permissions import (
_extract_rls_filter_fields,
get_user_permissions,
_extract_filter_fields,
)
from ..model.settings import Settings from ..model.settings import Settings
from ..model.descriptors import ( from ..model.descriptors import (
BotContext,
EntityList,
FieldDescriptor, FieldDescriptor,
EntityDescriptor, EntityDescriptor,
EntityItemCaptionCallable,
EntityFieldCaptionCallable,
EntityPermission, EntityPermission,
EntityCaptionCallable, _BaseFieldDescriptor,
_BaseEntityDescriptor,
Filter,
) )
from ..bot.handlers.context import ContextData, CommandContext from ..bot.handlers.context import CallbackCommand, ContextData, CommandContext
if TYPE_CHECKING: if TYPE_CHECKING:
from ..model.bot_entity import BotEntity
from ..model.user import UserBase from ..model.user import UserBase
from ..main import QBotApp from ..main import QuickBot
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: def get_local_text(text: str, locale: str = None) -> str:
@@ -52,39 +49,6 @@ def get_local_text(text: str, locale: str = None) -> str:
return obj.get(locale, obj[list(obj.keys())[0]]) 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): def get_send_message(message: Message | CallbackQuery):
if isinstance(message, Message): if isinstance(message, Message):
return message.answer return message.answer
@@ -106,45 +70,64 @@ def clear_state(state_data: dict, clear_nav: bool = False):
async def get_entity_item_repr( async def get_entity_item_repr(
entity: BotEntity, item_repr: EntityItemCaptionCallable | None = None entity: "BotEntity",
context: BotContext,
item_repr: Callable[["BotEntity", BotContext], str] | None = None,
) -> str: ) -> str:
descr = entity.bot_entity_descriptor descr = entity.bot_entity_descriptor
if not item_repr:
item_repr = descr.item_repr
if item_repr: if item_repr:
return item_repr(descr, entity) if iscoroutinefunction(item_repr):
return ( return await item_repr(entity, context)
descr.item_repr(descr, entity) else:
if descr.item_repr return item_repr(entity, context)
else f"{
await get_callable_str(descr.full_name, descr, entity) return f"{
await get_callable_str(
callable_str=descr.full_name,
context=context,
descriptor=descr,
entity=entity,
)
if descr.full_name if descr.full_name
else descr.name else descr.name
}: {str(entity.id)}" }: {str(entity.id)}"
)
async def get_value_repr( async def get_value_repr(
value: Any, field_descriptor: FieldDescriptor, locale: str | None = None value: Any,
field_descriptor: FieldDescriptor,
context: BotContext,
locale: str | None = None,
) -> str: ) -> str:
if value is None: if value is None:
return "" return ""
type_ = field_descriptor.type_base type_ = field_descriptor.type_base
if isinstance(value, bool): if isinstance(value, bool):
return "【✔︎】" if value else "【 】" return "[✓]" if value else "[ ]"
elif field_descriptor.is_list: elif field_descriptor.is_list:
if issubclass(type_, BotEntity): if hasattr(type_, "bot_entity_descriptor"):
return ( return f"[{
f"[{', '.join([await get_entity_item_repr(item) for item in value])}]" ', '.join(
[
await get_entity_item_repr(entity=item, context=context)
for item in value
]
) )
elif issubclass(type_, BotEnum): }]"
elif hasattr(type_, "all_members"):
return f"[{', '.join(item.localized(locale) for item in value)}]" return f"[{', '.join(item.localized(locale) for item in value)}]"
elif type_ is str: elif type_ is str:
return f"[{', '.join([f'"{item}"' for item in value])}]" return f"[{', '.join([f'"{item}"' for item in value])}]"
else: else:
return f"[{', '.join([str(item) for item in value])}]" return f"[{', '.join([str(item) for item in value])}]"
elif issubclass(type_, BotEntity): elif hasattr(type_, "bot_entity_descriptor"):
return await get_entity_item_repr(value) return await get_entity_item_repr(entity=value, context=context)
elif issubclass(type_, BotEnum): elif hasattr(type_, "all_members"):
return value.localized(locale) return value.localized(locale)
elif isinstance(value, str): elif isinstance(value, str):
if field_descriptor and field_descriptor.localizable: if field_descriptor and field_descriptor.localizable:
@@ -162,13 +145,13 @@ async def get_callable_str(
callable_str: ( callable_str: (
str str
| LazyProxy | LazyProxy
| EntityCaptionCallable | Callable[[EntityDescriptor, BotContext], str]
| EntityItemCaptionCallable | Callable[["BotEntity", BotContext], str]
| EntityFieldCaptionCallable | Callable[[FieldDescriptor, "BotEntity", BotContext], str]
), ),
descriptor: FieldDescriptor | EntityDescriptor, context: BotContext,
entity: Any = None, descriptor: FieldDescriptor | EntityDescriptor | None = None,
value: Any = None, entity: "BotEntity | Any" = None,
) -> str: ) -> str:
if isinstance(callable_str, str): if isinstance(callable_str, str):
return callable_str return callable_str
@@ -177,31 +160,34 @@ async def get_callable_str(
elif callable(callable_str): elif callable(callable_str):
args = signature(callable_str).parameters args = signature(callable_str).parameters
if iscoroutinefunction(callable_str): if iscoroutinefunction(callable_str):
if len(args) == 1: if len(args) == 3:
return await callable_str(descriptor) return await callable_str(descriptor, entity, context)
elif len(args) == 2:
return await callable_str(descriptor, entity)
elif len(args) == 3:
return await callable_str(descriptor, entity, value)
else: else:
if len(args) == 1: param = args[next(iter(args))]
return callable_str(descriptor) if not isinstance(param.annotation, str) and (
elif len(args) == 2: issubclass(param.annotation, _BaseFieldDescriptor)
return callable_str(descriptor, entity) or issubclass(param.annotation, _BaseEntityDescriptor)
elif len(args) == 3: ):
return callable_str(descriptor, entity, value) 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( def get_entity_descriptor(
app: "QBotApp", callback_data: ContextData app: "QuickBot", callback_data: ContextData
) -> EntityDescriptor: ) -> EntityDescriptor:
if callback_data.entity_name: if callback_data.entity_name:
return app.entity_metadata.entity_descriptors[callback_data.entity_name] return app.bot_metadata.entity_descriptors[callback_data.entity_name]
return None return None
def get_field_descriptor( def get_field_descriptor(
app: "QBotApp", callback_data: ContextData app: "QuickBot", callback_data: ContextData
) -> FieldDescriptor | None: ) -> FieldDescriptor | None:
if callback_data.context == CommandContext.SETTING_EDIT: if callback_data.context == CommandContext.SETTING_EDIT:
return Settings.list_params()[callback_data.field_name] return Settings.list_params()[callback_data.field_name]
@@ -224,25 +210,62 @@ def get_field_descriptor(
return None return None
def build_field_sequence( async def build_field_sequence(
entity_descriptor: EntityDescriptor, user: "UserBase", callback_data: ContextData 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]() field_sequence = list[str]()
# exclude ownership fields from edit if user has no CREATE_ALL/UPDATE_ALL permission # exclude RLS fields from edit if user has no CREATE_ALL/UPDATE_ALL permission
user_permissions = get_user_permissions(user, entity_descriptor) user_permissions = get_user_permissions(user, entity_descriptor)
for fd in entity_descriptor.fields_descriptors.values(): for fd in entity_descriptor.fields_descriptors.values():
if not ( 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 fd.is_optional
or fd.field_name == "id" or fd.field_name == "id"
or fd.field_name[:-3] == "_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 is not None
or fd.default_factory is not None
): ):
skip = False skip = True
for own_field in entity_descriptor.ownership_fields.items(): # Check RLS filters for field visibility
if ( if entity_descriptor.rls_filters:
own_field[1].rstrip("_id") == fd.field_name.rstrip("_id") # Get RLS filter fields that should be auto-filled and hidden from user
and own_field[0] in user.roles rls_filter_fields = _extract_rls_filter_fields(entity_descriptor)
and ( if fd.field_name in rls_filter_fields and (
( (
EntityPermission.CREATE_ALL not in user_permissions EntityPermission.CREATE_ALL not in user_permissions
and callback_data.context == CommandContext.ENTITY_CREATE and callback_data.context == CommandContext.ENTITY_CREATE
@@ -251,11 +274,50 @@ def build_field_sequence(
EntityPermission.UPDATE_ALL not in user_permissions EntityPermission.UPDATE_ALL not in user_permissions
and callback_data.context == CommandContext.ENTITY_EDIT and callback_data.context == CommandContext.ENTITY_EDIT
) )
)
): ):
skip = True skip = True
break
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: if not skip:
field_sequence.append(fd.field_name) field_sequence.append(fd.field_name)
return field_sequence 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 from types import UnionType, NoneType
import ujson as json import ujson as json
from ..model.bot_entity import BotEntity from quickbot.model.descriptors import FieldDescriptor
from ..model.bot_enum import BotEnum
from ..model.descriptors import FieldDescriptor
async def deserialize[T](session: AsyncSession, type_: type[T], value: str = None) -> T: 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] arg_type = args[0]
values = json.loads(value) if value else [] values = json.loads(value) if value else []
if arg_type: if arg_type:
if issubclass(arg_type, BotEntity): if hasattr(arg_type, "bot_entity_descriptor"):
ret = list[arg_type]() ret = list[arg_type]()
items = ( items = (
await session.exec(select(arg_type).where(column("id").in_(values))) 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: for item in items:
ret.append(item) ret.append(item)
return ret return ret
elif issubclass(arg_type, BotEnum): elif hasattr(arg_type, "all_members"):
return [arg_type(value) for value in values] return [arg_type(value) for value in values]
else: else:
return [arg_type(value) for value in values] return [arg_type(value) for value in values]
else: else:
return values return values
elif issubclass(type_, BotEntity): elif hasattr(type_, "bot_entity_descriptor"):
if is_optional and not value: if is_optional and not value:
return None return None
return await session.get(type_, int(value)) return await session.get(type_, int(value))
elif issubclass(type_, BotEnum): elif hasattr(type_, "all_members"):
if is_optional and not value: if is_optional and not value:
return None return None
return type_(value) return type_(value)
@@ -79,12 +77,12 @@ def serialize(value: Any, field_descriptor: FieldDescriptor) -> str:
type_ = field_descriptor.type_base type_ = field_descriptor.type_base
if field_descriptor.is_list: 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) 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) return json.dumps([item.value for item in value], ensure_ascii=False)
else: else:
return json.dumps(value, ensure_ascii=False) 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.id) if value else ""
return str(value) return str(value)

View File

@@ -1,5 +1,5 @@
from quickbot import ( from quickbot import (
QBotApp, QuickBot,
BotEntity, BotEntity,
Entity, Entity,
EntityForm, EntityForm,
@@ -116,7 +116,7 @@ class Entity(BotEntity):
) )
app = QBotApp( app = QuickBot(
user_class=User, user_class=User,
) )

1366
uv.lock generated

File diff suppressed because it is too large Load Diff