Compare commits
36 Commits
4364c2c175
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
400c6137fd | ||
|
|
09e844c73b | ||
|
|
40a28638bb | ||
|
|
fe7ca7f51b | ||
|
|
4df67c93d4 | ||
|
|
a078cdfd86 | ||
|
|
4ac80e0105 | ||
|
|
923b0e6cc9 | ||
|
|
b1f7ccf1b4 | ||
|
|
469b160fb8 | ||
|
|
ae036023e5 | ||
|
|
44240b8b04 | ||
|
|
2af0fceb25 | ||
|
|
5cadd66ce8 | ||
|
|
33abe15562 | ||
|
|
eb57a4ff78 | ||
|
|
dcacd31bbc | ||
|
|
094c36f61b | ||
|
|
90652b9f3f | ||
|
|
a4999159b9 | ||
|
|
5343e5b2cf | ||
|
|
e17629c607 | ||
|
|
3e51fd4476 | ||
|
|
f6c5eb875b | ||
|
|
a134194852 | ||
|
|
a3357a2924 | ||
|
|
b995abfc1e | ||
|
|
0e6225e82f | ||
|
|
43288698e9 | ||
|
|
8421d05826 | ||
|
|
c81fb57c1a | ||
|
|
783ecac91a | ||
|
|
dc006c70fd | ||
|
|
e10d7ff7bf | ||
|
|
6a7355996c | ||
|
|
f417c7741c |
18
README.md
18
README.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
37
src/quickbot/api_route/depends.py
Normal file
37
src/quickbot/api_route/depends.py
Normal 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")
|
||||||
49
src/quickbot/api_route/models.py
Normal file
49
src/quickbot/api_route/models.py
Normal 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
|
||||||
@@ -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
20
src/quickbot/auth/jwt.py
Normal 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])
|
||||||
15
src/quickbot/auth/telegram.py
Normal file
15
src/quickbot/auth/telegram.py
Normal 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_
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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.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.settings as menu_settings
|
||||||
import quickbot.bot.handlers.menu.parameters as menu_parameters
|
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.menu.entities as menu_entities
|
||||||
import quickbot.bot.handlers.forms.entity_list as form_list
|
import quickbot.bot.handlers.forms.entity_list as form_list
|
||||||
import quickbot.bot.handlers.forms.entity_form as form_item
|
import quickbot.bot.handlers.forms.entity_form as form_item
|
||||||
import quickbot.bot.handlers.editors.main as editor
|
import quickbot.bot.handlers.editors.main as editor
|
||||||
|
import quickbot.bot.handlers.user_handlers.main as user_handler
|
||||||
|
|
||||||
|
|
||||||
async def route_callback(message: Message | CallbackQuery, back: bool = True, **kwargs):
|
|
||||||
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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
123
src/quickbot/bot/handlers/user_handlers/command_handler.py
Normal file
123
src/quickbot/bot/handlers/user_handlers/command_handler.py
Normal 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
|
||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
40
src/quickbot/i18n/__init__.py
Normal file
40
src/quickbot/i18n/__init__.py
Normal 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))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
46
src/quickbot/model/annotated_schema.py
Normal file
46
src/quickbot/model/annotated_schema.py
Normal 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.
|
||||||
|
"""
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
16
src/quickbot/model/bot_metadata.py
Normal file
16
src/quickbot/model/bot_metadata.py
Normal 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
|
||||||
77
src/quickbot/model/bot_process.py
Normal file
77
src/quickbot/model/bot_process.py
Normal 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): ...
|
||||||
9
src/quickbot/model/crud_command.py
Normal file
9
src/quickbot/model/crud_command.py
Normal 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"
|
||||||
374
src/quickbot/model/crud_service.py
Normal file
374
src/quickbot/model/crud_service.py
Normal 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
|
||||||
@@ -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): ...
|
||||||
|
|||||||
@@ -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] = {}
|
|
||||||
@@ -2,4 +2,4 @@ from .bot_enum import BotEnum, EnumMember
|
|||||||
|
|
||||||
|
|
||||||
class LanguageBase(BotEnum):
|
class LanguageBase(BotEnum):
|
||||||
EN = EnumMember("en", {"en": "🇬🇧 english"})
|
DEFAULT = EnumMember("default")
|
||||||
|
|||||||
6
src/quickbot/model/list_schema.py
Normal file
6
src/quickbot/model/list_schema.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ListSchema(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
192
src/quickbot/model/permissions.py
Normal file
192
src/quickbot/model/permissions.py
Normal 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
|
||||||
53
src/quickbot/model/pydantic_json.py
Normal file
53
src/quickbot/model/pydantic_json.py
Normal 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)}")
|
||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
364
src/quickbot/model/utils.py
Normal 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
9
src/quickbot/plugin.py
Normal 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: ...
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user