Compare commits

...

38 Commits

Author SHA1 Message Date
Alexander Kalinovsky
4364c2c175 rename project
All checks were successful
Build Docs / changes (push) Successful in 6s
Build Docs / build-docs (push) Successful in 1m5s
Build Docs / deploy-docs (push) Successful in 5s
2025-03-27 15:48:55 +07:00
Alexander Kalinovsky
5db09d0fd1 rename project 2025-03-27 15:48:00 +07:00
Alexander Kalinovsky
732c86b6fb fix permission checks for command forms
All checks were successful
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-21 18:46:22 +07:00
Alexander Kalinovsky
66a6de8b20 minor updates
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-20 18:30:18 +07:00
Alexander Kalinovsky
8d99e6f5d5 undo last
All checks were successful
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-20 02:15:11 +07:00
Alexander Kalinovsky
d7671c8d26 retry register webhook
All checks were successful
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-20 02:00:54 +07:00
Alexander Kalinovsky
ffe2c95056 minor fixes
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-20 01:53:04 +07:00
Alexander Kalinovsky
6965c03fec minor fix
All checks were successful
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-20 00:00:04 +07:00
Alexander Kalinovsky
5e2b8e51f6 app state fix
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-19 15:09:12 +07:00
Alexander Kalinovsky
fe0380f9f3 minor fixes and updates
All checks were successful
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-19 14:46:23 +07:00
Alexander Kalinovsky
f0db2b2830 upd defult editing field sequences generation when ownership fields defined 2025-03-13 16:52:03 +07:00
Alexander Kalinovsky
02aec23b84 feat: entity events
All checks were successful
Build Docs / changes (push) Successful in 20s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-10 22:10:09 +07:00
Alexander Kalinovsky
dd56d3f312 feat: awaitable entity representations
All checks were successful
Build Docs / changes (push) Successful in 28s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-08 14:57:09 +07:00
Alexander Kalinovsky
9e9979bf31 fix docs deploy
All checks were successful
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Successful in 43s
Build Docs / deploy-docs (push) Successful in 4s
2025-03-01 15:59:16 +07:00
Alexander Kalinovsky
921c6e39f5 fix docs deploy
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Successful in 36s
Build Docs / deploy-docs (push) Successful in 3s
2025-03-01 15:42:57 +07:00
Alexander Kalinovsky
518d7a4856 fix docs deploy
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Successful in 35s
Build Docs / deploy-docs (push) Successful in 3s
2025-03-01 15:28:49 +07:00
Alexander Kalinovsky
5690a4ba12 fix docs deploy
All checks were successful
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Successful in 35s
Build Docs / deploy-docs (push) Successful in 3s
2025-03-01 15:23:31 +07:00
Alexander Kalinovsky
06c9467c6e fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Successful in 36s
Build Docs / deploy-docs (push) Failing after 2s
2025-03-01 15:21:06 +07:00
Alexander Kalinovsky
6deb49c6d7 fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Successful in 39s
Build Docs / deploy-docs (push) Failing after 3s
2025-03-01 15:17:46 +07:00
Alexander Kalinovsky
89f7e3bee2 fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Successful in 48s
Build Docs / deploy-docs (push) Failing after 3s
2025-03-01 14:54:20 +07:00
Alexander Kalinovsky
1b0d6d4125 fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 4s
Build Docs / build-docs (push) Failing after 25s
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 14:16:18 +07:00
Alexander Kalinovsky
22ab18f648 fix docs deploy
Some checks failed
Build Docs / changes (push) Failing after 6s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 14:14:17 +07:00
Alexander Kalinovsky
0054a7e154 fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 6s
Build Docs / build-docs (push) Failing after 1m14s
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 02:37:00 +07:00
Alexander Kalinovsky
2327c49e1c fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 6s
Build Docs / build-docs (push) Failing after 28s
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 02:31:42 +07:00
Alexander Kalinovsky
748264f6c8 fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Failing after 17s
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 02:26:26 +07:00
Alexander Kalinovsky
55e12c0475 fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Failing after 24s
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 02:23:44 +07:00
Alexander Kalinovsky
f3fe54513c fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Failing after 18s
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 02:21:44 +07:00
Alexander Kalinovsky
50a434dae8 fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 10s
Build Docs / build-docs (push) Failing after 28s
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 02:17:18 +07:00
Alexander Kalinovsky
76966301f6 fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Failing after 1m24s
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 01:50:13 +07:00
Alexander Kalinovsky
fc49b2ee47 fix docs deploy
Some checks failed
Build Docs / changes (push) Successful in 6s
Build Docs / build-docs (push) Failing after 1m25s
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 01:45:11 +07:00
Alexander Kalinovsky
d59b28ad29 fix python version
Some checks failed
Build Docs / changes (push) Successful in 6s
Build Docs / build-docs (push) Failing after 8s
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 01:31:52 +07:00
Alexander Kalinovsky
e413cd3ae5 test docs deploy
Some checks failed
Build Docs / changes (push) Successful in 6s
Build Docs / build-docs (push) Failing after 51s
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 01:27:33 +07:00
Alexander Kalinovsky
0f479f7914 fix docs deploy
All checks were successful
Build Docs / changes (push) Successful in 8s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 01:24:41 +07:00
Alexander Kalinovsky
426ca3fad0 fix doc deploy
Some checks failed
Build Docs / changes (push) Failing after 1s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 01:22:28 +07:00
Alexander Kalinovsky
b7abd3f30f fix workflow name
Some checks failed
Build Docs / changes (push) Failing after 27s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-01 01:03:38 +07:00
Alexander Kalinovsky
71ba2c052b add mkdocs 2025-03-01 00:57:31 +07:00
Alexander Kalinovsky
1d162677a8 upd project structure 2025-02-18 20:57:52 +01:00
baa55d28d6 Merge pull request 'upd bot init' (#1) from main into prepack
Reviewed-on: BotForge/qbot#1
2025-02-10 19:36:54 +00:00
70 changed files with 2481 additions and 203 deletions

View File

@@ -0,0 +1,86 @@
name: Build Docs
on:
push:
branches:
- main
jobs:
changes:
runs-on: ubuntu-latest
# Set job outputs to values from filter step
outputs:
docs: ${{ steps.filter.outputs.docs }}
steps:
# - name: Base requirements
# run: |
# apk update && apk add --no-cache nodejs
- uses: actions/checkout@v4
# For pull requests it's not necessary to checkout the code but for the main branch it is
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
docs:
- README.md
- docs/**
- requirements-docs.txt
- pyproject.toml
- mkdocs.yml
- Dockerfile.docs-site
- docker-compose.docs-site.yml
- .gitea/workflows/build-docs.yml
- .gitea/workflows/deploy-docs.yml
build-docs:
needs:
- changes
if: ${{ needs.changes.outputs.docs == 'true' }}
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13.2"
- name: Setup uv
uses: astral-sh/setup-uv@v5
with:
version: "0.6.3"
enable-cache: true
cache-dependency-glob: |
requirements**.txt
pyproject.toml
- name: Install docs extras
run: |
uv venv
uv pip install mkdocs-material
- name: Build Docs
run: .venv/bin/mkdocs build
- name: Build Docker Image
run: |
docker build -f Dockerfile.docs-site -t ${{ vars.STACK_NAME }} .
docker tag ${{ vars.STACK_NAME }}:latest registry.botforge.biz/${{ vars.STACK_NAME }}
docker login registry.botforge.biz -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_PASSWORD }}
docker push registry.botforge.biz/${{ vars.STACK_NAME }}
deploy-docs:
needs:
- build-docs
runs-on: staging
env:
STACK_NAME: ${{ vars.STACK_NAME }}
DOMAIN: ${{ vars.DOMAIN }}
steps:
- uses: actions/checkout@v4
- name: Copy to working directory
run: |
mkdir -p ~/${{ vars.STACK_NAME }}
cp docker-compose.docs-site.yml ~/${{ vars.STACK_NAME }}/docker-compose.yml
- name: Start Docs Site
run: |
cd ~/${{ vars.STACK_NAME }}
docker compose up -d

10
.gitignore vendored
View File

@@ -1,6 +1,10 @@
__pycache__
/backend/.venv
.env
.pytest_cache
/backend/logs
.DS_Store
.DS_Store
.venv
.python-version
uv.lock
dist
*.egg-info
site

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

10
Dockerfile.docs-site Normal file
View File

@@ -0,0 +1,10 @@
FROM nginx
WORKDIR /usr/share/nginx/html
RUN rm -rf ./*
COPY ./site/ ./
CMD ["nginx", "-g", "daemon off;"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Alexander Kalinovsky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
<p align="center">
<a href="https://qbot.botforge.biz"><img src="https://qbot.botforge.biz/img/qbot.svg" alt="QBot"></a>
</p>
<p align="center">
<em>Telegram Bots Rapid Application Development (RAD) Framework.</em>
</p>
**QBot** is a library for fast development of Telegram bots and mini-apps following the **RAD (Rapid Application Development)** principle in a **declarative style**.
## Key Features
- **Automatic CRUD Interface Generation** Manage objects effortlessly without manual UI coding.
- **Built-in Field Editors** Includes text inputs, date/time pickers, boolean switches, enums, and entity selectors.
- **Advanced Pagination & Filtering** Easily organize and navigate large datasets.
- **Authentication & Authorization** Role-based access control for secure interactions.
- **Context Preservation** Store navigation stacks and user interaction states in the database.
- **Internationalization Support** Localizable UI and string fields for multilingual bots.
**QBot** powered by **[FastAPI](https://fastapi.tiangolo.com)**, **[SQLModel](https://sqlmodel.tiangolo.com)** & **[aiogram](https://aiogram.dev)** Leverage the full capabilities of these frameworks for high performance and flexibility.
## Benefits
- **Faster Development** Automate repetitive tasks and build bots in record time.
- **Highly Modular** Easily extend and customize functionality.
- **Less Code Duplication** Focus on core features while QBot handles the rest.
- **Enterprise-Grade Structure** Scalable, maintainable, and optimized for real-world usage.
## Example
```python
class AppEntity(BotEntity):
"""
BotEntity - business entity. Based on SQLModel, which provides ORM functionality,
basic CRUD functions and additional metadata, describing entities view and behaviour
"""
bot_entity_descriptor = Entity( # metadata attribute
name = "bot_entity", # Entity - descriptor for entity metadata
...
)
name: str # entity field with default sqlmodel's FieldInfo descriptor
# and default qbot's field descriptor
description: str | None = Field( # field with sqlmodel's descriptor
sa_type = String, index = True) # and default qbot's descriptor
age: int = EntityField( # field with qbot's descriptor
caption = "Age",
)
user_id: int | None = EntityField( # using both descriptors
sm_descriptor=Field(
sa_type=BigInteger,
foreign_key="user.id",
ondelete="RESTRICT",
nullable=True,
),
is_visible=False,
)
user: Optional[User] = EntityField( # using Relationship as sqlmodel descriptor
sm_descriptor=Relationship(
back_populates="entities",
sa_relationship_kwargs={
"lazy": "selectin",
"foreign_keys": "Entity.user_id",
}
),
caption="User",
)
app = QBotApp() # bot application based on FastAPI application
# providing Telegram API webhook handler
@app.command( # decorator for bot commands definition
name="menu",
caption="Main menu",
show_in_bot_commands=True, # shows command in bot's main menu
clear_navigation=True, # resets navigation stack between bot dialogues
)
async def menu(context: CommandCallbackContext):
context.message_text = "Main menu" # message text
context.keyboard_builder.row( # and buttons will be shown to user
InlineKeyboardButton(
text="Entities",
callback_data=ContextData( # callback query dataclass representing
command=CallbackCommand.ENTITY_LIST, # navigation context and its parameters
entity_name="bot_entity"
).pack(),
)
)
```
## 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>
Here you can see the result - [YouTube Video with Bot](https://www.youtube.com/shorts/ptTnoppkYfM)

View File

@@ -0,0 +1,20 @@
services:
qbot-docs-site:
image: 'registry.botforge.biz/${STACK_NAME?Variable not set}:${TAG-latest}'
restart: always
networks:
- traefik-public
labels:
- traefik.enable=true
- traefik.docker.network=traefik-public
- traefik.constraint-label=traefik-public
- traefik.http.services.${STACK_NAME?Variable not set}.loadbalancer.server.port=80
- traefik.http.routers.${STACK_NAME?Variable not set}.rule=Host(`${DOMAIN?Variable not set}`)
- traefik.http.routers.${STACK_NAME?Variable not set}.entrypoints=websecure
- traefik.http.routers.${STACK_NAME?Variable not set}.tls=true
- traefik.http.routers.${STACK_NAME?Variable not set}.tls.certresolver=le
- traefik.http.routers.${STACK_NAME?Variable not set}.service=${STACK_NAME?Variable not set}
networks:
traefik-public:
external: true

73
docs/img/qbot.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

59
docs/img/qbot_1_1.svg Normal file
View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="500"
height="500"
viewBox="0 0 500 500"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
sodipodi:docname="qbot_1_1.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="0.52452521"
inkscape:cx="346.02722"
inkscape:cy="171.58374"
inkscape:window-width="1608"
inkscape:window-height="1007"
inkscape:window-x="8"
inkscape:window-y="52"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs
id="defs1"><rect
x="80.811432"
y="115.09507"
width="901.16986"
height="411.40366"
id="rect3" /><rect
x="90.606758"
y="102.85091"
width="416.3013"
height="306.10391"
id="rect2" /></defs><g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1"><g
id="g17"
transform="matrix(1.3786092,0,0,1.3786092,-125.59292,-120.94283)"><path
id="path17"
style="-inkscape-font-specification:'Arial Rounded MT Bold, ';opacity:1;fill:#a587c9;fill-opacity:1"
d="m 272.44727,142.82422 c -6.05914,0.0153 -11.03743,2.03697 -14.95508,6.0293 -3.98025,4.51096 -6.09057,11.66026 -6.47657,21.24218 v 3.72461 c -0.0471,-0.0706 -0.0973,-0.13474 -0.14453,-0.20508 v 52.01758 18.20703 57.11524 c 0.0477,-0.0476 0.0968,-0.0871 0.14453,-0.13477 v 64.16602 c 2e-5,8.38844 1.13695,15.11813 3.29102,20.36523 0.73739,1.28016 1.58937,2.4395 2.56445,3.46875 3.62458,3.92663 8.21734,6.0553 13.75782,6.42383 0.5993,0.0286 1.18235,0.0711 1.8125,0.0723 6.05823,-0.0157 11.0359,-2.03742 14.95312,-6.0293 3.98025,-4.51096 6.09057,-11.66026 6.47656,-21.24219 v -3.72461 c 0.0471,0.0706 0.0974,0.13474 0.14453,0.20508 v -52.01953 -18.20508 -57.11523 c -0.0477,0.0476 -0.0968,0.0871 -0.14453,0.13476 V 173.1543 c 0,-8.38703 -1.13767,-15.11654 -3.29101,-20.36328 -0.73758,-1.28076 -1.58899,-2.44105 -2.56446,-3.47071 -3.6877,-3.99502 -8.37983,-6.1256 -14.05078,-6.4375 -0.50595,-0.0202 -0.99001,-0.0576 -1.51757,-0.0586 z" /><path
d="m 250.87109,300.95508 v -57.11524 c -0.79426,8.72199 -2.70097,16.33161 -5.73632,22.8125 -4.1111,8.77777 -9.55427,15.33464 -16.33204,19.66797 -6.77777,4.22221 -14.22287,6.33203 -22.33398,6.33203 -7.44443,0 -14.38954,-2.10982 -20.83398,-6.33203 -6.44444,-4.33333 -11.66602,-10.8902 -15.66602,-19.66797 -3.88888,-8.88888 -5.83398,-19.4438 -5.83398,-31.66601 0,-18.55553 4.05686,-32.88889 12.16796,-43 8.1111,-10.1111 18.27779,-15.16602 30.5,-15.16602 6,0 11.66668,1.22158 17,3.66602 5.33333,2.33333 10.05492,5.94509 14.16602,10.83398 4.11111,4.77777 7.33269,10.83269 9.66602,18.16602 1.65444,4.96334 2.70164,10.36877 3.23632,16.14648 v -52.01758 c -6.86066,-10.22654 -15.08687,-17.82908 -24.6875,-22.79492 -9.66665,-5.11111 -20.61177,-7.66601 -32.83398,-7.66601 -14.44443,0 -27.61113,3.60982 -39.5,10.83203 -11.77777,7.22221 -20.99936,17.77909 -27.66602,31.66797 -6.66666,13.77776 -10,30.33269 -10,49.66601 0,18.88887 3.27844,35.33204 9.83399,49.33203 6.66666,13.88887 15.77648,24.55556 27.33203,32 11.66665,7.44444 24.55687,11.16797 38.66797,11.16797 11.55555,0 22.05361,-2.27843 31.49804,-6.83398 9.50365,-4.64131 18.62118,-11.32337 27.35547,-20.03125 z"
style="-inkscape-font-specification:'Arial Rounded MT Bold, ';opacity:1;fill:#fabf9c;fill-opacity:1"
id="path13" /><path
d="m 294.01562,237.18555 v 57.11523 c 0.79416,-8.72281 2.70075,-16.33307 5.73633,-22.81445 4.1111,-8.77777 9.55426,-15.33269 16.33203,-19.66602 6.77777,-4.22221 14.22288,-6.33398 22.33399,-6.33398 7.44443,0 14.38954,2.11177 20.83398,6.33398 6.44444,4.33333 11.66602,10.88825 15.66602,19.66602 3.88888,8.88888 5.83398,19.4438 5.83398,31.66601 0,18.55553 -4.05687,32.88889 -12.16797,43 -8.1111,10.1111 -18.27779,15.16797 -30.5,15.16797 -6,0 -11.66668,-1.22353 -17,-3.66797 -5.33333,-2.33333 -10.05491,-5.94314 -14.16601,-10.83203 -4.11111,-4.77777 -7.33269,-10.83464 -9.66602,-18.16797 -1.6545,-4.96351 -2.70166,-10.36855 -3.23633,-16.14648 v 52.01953 c 6.86067,10.22655 15.08688,17.82909 24.6875,22.79492 9.66665,5.11111 20.61178,7.66602 32.83399,7.66602 14.44443,0 27.61113,-3.61178 39.5,-10.83399 11.77777,-7.22221 20.99936,-17.77713 27.66601,-31.66601 6.66667,-13.77776 10,-30.3327 10,-49.66602 10e-6,-18.88887 -3.27648,-35.33399 -9.83203,-49.33398 -6.66666,-13.88887 -15.77843,-24.55556 -27.33398,-32 -11.66665,-7.44444 -24.55492,-11.16602 -38.66602,-11.16602 -11.55555,0 -22.05557,2.27844 -31.5,6.83399 -9.50364,4.64131 -18.62118,11.32337 -27.35547,20.03125 z"
style="-inkscape-font-specification:'Arial Rounded MT Bold, ';opacity:1;fill:#a7a7fa;fill-opacity:1"
id="path15" /></g></g></svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

99
docs/index.md Normal file
View File

@@ -0,0 +1,99 @@
<style>
h1 { display: none; }
</style>
<p align="center">
<a href="https://quickbot.botforge.biz"><img src="img/qbot.svg" alt="QuickBot"></a>
</p>
<p align="center">
<em>Telegram Bots Rapid Application Development (RAD) Framework.</em>
</p>
**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
- **Automatic CRUD Interface Generation** Manage objects effortlessly without manual UI coding.
- **Built-in Field Editors** Includes text inputs, date/time pickers, boolean switches, enums, and entity selectors.
- **Advanced Pagination & Filtering** Easily organize and navigate large datasets.
- **Authentication & Authorization** Role-based access control for secure interactions.
- **Context Preservation** Store navigation stacks and user interaction states in the database.
- **Internationalization Support** Localizable UI and string fields for multilingual bots.
**QuickBot** powered by **[FastAPI](https://fastapi.tiangolo.com)**, **[SQLModel](https://sqlmodel.tiangolo.com)** & **[aiogram](https://aiogram.dev)** Leverage the full capabilities of these frameworks for high performance and flexibility.
## Benefits
- **Faster Development** Automate repetitive tasks and build bots in record time.
- **Highly Modular** Easily extend and customize functionality.
- **Less Code Duplication** Focus on core features while QuickBot handles the rest.
- **Enterprise-Grade Structure** Scalable, maintainable, and optimized for real-world usage.
## Example
```python
class AppEntity(BotEntity):
"""
BotEntity - business entity. Based on SQLModel, which provides ORM functionality,
basic CRUD functions and additional metadata, describing entities view and behaviour
"""
bot_entity_descriptor = Entity( # metadata attribute
name = "bot_entity", # Entity - descriptor for entity metadata
...
)
name: str # entity field with default sqlmodel's FieldInfo descriptor
# and default quickbot's field descriptor
description: str | None = Field( # field with sqlmodel's descriptor
sa_type = String, index = True) # and default quickbot's descriptor
age: int = EntityField( # field with quickbot's descriptor
caption = "Age",
)
user_id: int | None = EntityField( # using both descriptors
sm_descriptor=Field(
sa_type=BigInteger,
foreign_key="user.id",
ondelete="RESTRICT",
nullable=True,
),
is_visible=False,
)
user: Optional[User] = EntityField( # using Relationship as sqlmodel descriptor
sm_descriptor=Relationship(
back_populates="entities",
sa_relationship_kwargs={
"lazy": "selectin",
"foreign_keys": "Entity.user_id",
}
),
caption="User",
)
app = QBotApp() # bot application based on FastAPI application
# providing Telegram API webhook handler
@app.command( # decorator for bot commands definition
name="menu",
caption="Main menu",
show_in_bot_commands=True, # shows command in bot's main menu
clear_navigation=True, # resets navigation stack between bot dialogues
)
async def menu(context: CommandCallbackContext):
context.message_text = "Main menu" # message text
context.keyboard_builder.row( # and buttons will be shown to user
InlineKeyboardButton(
text="Entities",
callback_data=ContextData( # callback query dataclass representing
command=CallbackCommand.ENTITY_LIST, # navigation context and its parameters
entity_name="bot_entity"
).pack(),
)
)
```
## Result
<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>

14
mkdocs.yml Normal file
View File

@@ -0,0 +1,14 @@
site_name: QBot Framework
site_url: https://qbot.botforge.biz
theme:
name: material
palette:
primary: #fabf9c
accent: #a7a7fa
font:
text: 'Roboto'
code: 'Roboto Mono'
logo: 'img/qbot_1_1.svg'
favicon: 'img/qbot_1_1.svg'
repo_name: botforge/qbot
repo_url: https://git.botforge.biz/botforge/qbot

29
pyproject.toml Normal file
View File

@@ -0,0 +1,29 @@
[project]
name = "quickbot"
version = "0.1.0"
description = "QBot - Rapid Application Development Framework for Telegram Bots"
readme = "README.md"
requires-python = ">=3.12"
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
"Development Status :: 3 - Alpha",
]
authors = [
{ name = "Alexander Kalinovsky", email = "ak@botforge.biz" },
]
license = { file = "LICENSE" }
dependencies = [
"aiogram>=3.17.0",
"babel>=2.17.0",
"fastapi[standard]>=0.115.8",
"greenlet>=3.1.1",
"mkdocs-material>=9.6.5",
"pydantic-settings>=2.7.1",
"pyngrok>=7.2.3",
"pytest>=8.3.4",
"ruff>=0.9.6",
"sqlmodel>=0.0.22",
"ujson>=5.10.0",
]

View File

@@ -12,8 +12,10 @@ from .model.descriptors import (
EntityField as EntityField,
EntityForm as EntityForm,
EntityList as EntityList,
Filter as Filter,
EntityPermission as EntityPermission,
CommandCallbackContext as CommandCallbackContext,
EntityEventContext as EntityEventContext,
CommandButton as CommandButton,
FieldEditButton as FieldEditButton,
InlineButton as InlineButton,

View File

@@ -10,7 +10,6 @@ from logging import getLogger
logger = getLogger(__name__)
logger.setLevel("DEBUG")
router = APIRouter()
@@ -31,8 +30,14 @@ async def telegram_webhook(
logger.error("Invalid request", exc_info=True)
return Response(status_code=400)
try:
state_kw = request.state._state # TODO: avoid accessing private attribute
await app.dp.feed_webhook_update(
app.bot, update, db_session=db_session, app=app
app.bot,
update,
db_session=db_session,
app=app,
**(state_kw or {}),
)
except Exception:
logger.error("Error processing update", exc_info=True)

View File

View File

@@ -6,7 +6,7 @@ from ....utils.main import get_callable_str
from ..context import ContextData, CallbackCommand
def add_filter_controls(
async def add_filter_controls(
keyboard_builder: InlineKeyboardBuilder,
entity_descriptor: EntityDescriptor,
filter: str = None,
@@ -15,7 +15,7 @@ def add_filter_controls(
):
caption = ", ".join(
[
get_callable_str(
await get_callable_str(
entity_descriptor.fields_descriptors[field_name].caption,
entity_descriptor,
)

View File

@@ -2,7 +2,7 @@ from aiogram.types import Message, CallbackQuery
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from qbot.main import QBotApp
from quickbot.main import QBotApp
from ..context import CallbackCommand
@@ -12,14 +12,14 @@ from ....utils.navigation import (
pop_navigation_context,
)
import qbot.bot.handlers.menu.main as menu_main
import qbot.bot.handlers.menu.settings as menu_settings
import qbot.bot.handlers.menu.parameters as menu_parameters
import qbot.bot.handlers.menu.language as menu_language
import qbot.bot.handlers.menu.entities as menu_entities
import qbot.bot.handlers.forms.entity_list as form_list
import qbot.bot.handlers.forms.entity_form as form_item
import qbot.bot.handlers.editors.main as editor
import quickbot.bot.handlers.menu.main as menu_main
import quickbot.bot.handlers.menu.settings as menu_settings
import quickbot.bot.handlers.menu.parameters as menu_parameters
import quickbot.bot.handlers.menu.language as menu_language
import quickbot.bot.handlers.menu.entities as menu_entities
import quickbot.bot.handlers.forms.entity_list as form_list
import quickbot.bot.handlers.forms.entity_form as form_item
import quickbot.bot.handlers.editors.main as editor
async def route_callback(message: Message | CallbackQuery, back: bool = True, **kwargs):
@@ -47,7 +47,7 @@ async def route_callback(message: Message | CallbackQuery, back: bool = True, **
elif context.command == CallbackCommand.FIELD_EDITOR:
await editor.field_editor(message, **kwargs)
elif context.command == CallbackCommand.USER_COMMAND:
import qbot.bot.handlers.user_handlers.main as user_handler
import quickbot.bot.handlers.user_handlers.main as user_handler
app: "QBotApp" = kwargs["app"]
cmd = app.bot_commands.get(context.user_command.split("&")[0])

View File

@@ -17,7 +17,6 @@ class CallbackCommand(StrEnum):
DATE_PICKER_MONTH = "dm"
DATE_PICKER_YEAR = "dy"
TIME_PICKER = "tp"
# STRING_EDITOR_LOCALE = "sl"
ENTITY_PICKER_PAGE = "ep"
ENTITY_PICKER_TOGGLE_ITEM = "et"
VIEW_FILTER_EDIT = "vf"

View File

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

View File

@@ -25,13 +25,23 @@ async def show_editor(message: Message | CallbackQuery, **kwargs):
value_type = field_descriptor.type_base
if field_descriptor.edit_prompt:
edit_prompt = get_callable_str(
field_descriptor.edit_prompt, field_descriptor, None, current_value
edit_prompt = await get_callable_str(
field_descriptor.edit_prompt,
field_descriptor,
callback_data
if callback_data.context == CommandContext.COMMAND_FORM
else None,
current_value,
)
else:
if field_descriptor.caption:
caption_str = get_callable_str(
field_descriptor.caption, field_descriptor, None, current_value
caption_str = await get_callable_str(
field_descriptor.caption,
field_descriptor,
callback_data
if callback_data.context == CommandContext.COMMAND_FORM
else None,
current_value,
)
else:
caption_str = field_descriptor.name
@@ -42,7 +52,7 @@ async def show_editor(message: Message | CallbackQuery, **kwargs):
)
).format(
name=caption_str,
value=get_value_repr(current_value, field_descriptor, user.lang),
value=await get_value_repr(current_value, field_descriptor, user.lang),
)
else:
edit_prompt = (

View File

@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from ....model.descriptors import FieldDescriptor
from ....model.settings import Settings
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand
from ....utils.main import get_send_message, get_field_descriptor
from .wrapper import wrap_editor
@@ -49,6 +50,7 @@ async def time_picker(
callback_data: ContextData,
current_value: datetime | time,
state: FSMContext,
user: UserBase,
edit_prompt: str | None = None,
**kwargs,
):
@@ -56,12 +58,20 @@ async def time_picker(
if not current_value:
current_value = time(0, 0)
is_datetime = False
else:
is_datetime = isinstance(current_value, datetime)
if not is_datetime:
current_time = datetime.combine(datetime.now(), current_value)
remainder = current_value.minute % 5
if remainder >= 3:
current_value += timedelta(minutes=(5 - remainder))
current_time += timedelta(minutes=(5 - remainder))
else:
current_value -= timedelta(minutes=remainder)
current_time -= timedelta(minutes=remainder)
if is_datetime:
current_value = datetime.combine(current_value.date(), current_time.time())
else:
current_value = current_time.time()
for i in range(12):
keyboard_builder.row(
@@ -154,6 +164,7 @@ async def time_picker(
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
)
await state.set_data(state_data)
@@ -171,6 +182,7 @@ async def date_picker(
callback_data: ContextData,
current_value: datetime,
state: FSMContext,
user: UserBase,
edit_prompt: str | None = None,
**kwargs,
):
@@ -265,6 +277,7 @@ async def date_picker(
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
)
await state.set_data(state_data)
@@ -284,6 +297,7 @@ async def date_picker_year(
callback_data: ContextData,
app: "QBotApp",
state: FSMContext,
user: UserBase,
**kwargs,
):
start_date = datetime.strptime(callback_data.data, "%Y-%m-%d %H-%M")
@@ -357,6 +371,7 @@ async def date_picker_year(
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
)
await query.message.edit_reply_markup(reply_markup=keyboard_builder.as_markup())

View File

@@ -209,7 +209,7 @@ async def render_entity_picker(
type_.bot_entity_descriptor, item
)
if type_.bot_entity_descriptor.item_repr
else get_callable_str(
else await get_callable_str(
type_.bot_entity_descriptor.full_name,
type_.bot_entity_descriptor,
item,
@@ -259,7 +259,7 @@ async def render_entity_picker(
and form_list.filtering
and form_list.filtering_fields
):
add_filter_controls(
await add_filter_controls(
keyboard_builder=keyboard_builder,
entity_descriptor=type_.bot_entity_descriptor,
filter=entity_filter,
@@ -289,6 +289,7 @@ async def render_entity_picker(
field_descriptor=field_descriptor,
callback_data=callback_data,
state_data=state_data,
user=user,
)
await state.set_data(state_data)

View File

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

View File

@@ -1,3 +1,4 @@
from inspect import iscoroutinefunction
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.fsm.context import FSMContext
@@ -12,7 +13,7 @@ from ..user_handlers.main import cammand_handler
from ....model import EntityPermission
from ....model.user import UserBase
from ....model.settings import Settings
from ....model.descriptors import FieldDescriptor
from ....model.descriptors import EntityEventContext, FieldDescriptor
from ....model.language import LanguageBase
from ....auth import authorize_command
from ....utils.main import (
@@ -21,6 +22,7 @@ from ....utils.main import (
clear_state,
get_entity_descriptor,
get_field_descriptor,
build_field_sequence,
)
from ....utils.serialization import deserialize
from ..common.routing import route_callback
@@ -167,7 +169,15 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
form_name, entity_descriptor.default_form
)
field_sequence = form.edit_field_sequence
if form.edit_field_sequence:
field_sequence = form.edit_field_sequence
else:
field_sequence = build_field_sequence(
entity_descriptor=entity_descriptor,
user=user,
callback_data=callback_data,
)
current_index = (
field_sequence.index(callback_data.field_name)
if callback_data.context
@@ -251,9 +261,18 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
# What if user has several roles and each role has its own ownership field? Should we allow creation even
# if user has no CREATE_ALL permission
# for role in user.roles:
# if role in entity_descriptor.ownership_fields and not EntityPermission.CREATE_ALL in user_permissions:
# entity_data[entity_descriptor.ownership_fields[role]] = user.id
if callback_data.context in [
CommandContext.ENTITY_CREATE,
CommandContext.ENTITY_EDIT,
]:
user_permissions = get_user_permissions(user, entity_descriptor)
for role in user.roles:
if (
role in entity_descriptor.ownership_fields
and EntityPermission.CREATE_ALL not in user_permissions
):
entity_data[entity_descriptor.ownership_fields[role]] = user.id
deser_entity_data = {
key: await deserialize(
@@ -281,6 +300,22 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
commit=True,
)
if entity_descriptor.on_created:
if iscoroutinefunction(entity_descriptor.on_created):
await entity_descriptor.on_created(
new_entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
)
else:
entity_descriptor.on_created(
new_entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
)
form_name = (
callback_data.form_params.split("&")[0]
if callback_data.form_params
@@ -325,6 +360,23 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
setattr(entity, key, value)
await db_session.commit()
await db_session.refresh(entity)
if entity_descriptor.on_updated:
if iscoroutinefunction(entity_descriptor.on_updated):
await entity_descriptor.on_updated(
entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
)
else:
entity_descriptor.on_updated(
entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
)
elif callback_data.context == CommandContext.COMMAND_FORM:
clear_state(state_data=state_data)

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
from inspect import iscoroutinefunction
from typing import TYPE_CHECKING
from aiogram import Router, F
from aiogram.fsm.context import FSMContext
@@ -6,7 +7,12 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from logging import getLogger
from sqlmodel.ext.asyncio.session import AsyncSession
from ....model.descriptors import FieldEditButton, CommandButton, InlineButton
from ....model.descriptors import (
FieldEditButton,
CommandButton,
InlineButton,
EntityEventContext,
)
from ....model.settings import Settings
from ....model.user import UserBase
from ....model import EntityPermission
@@ -17,6 +23,8 @@ from ....utils.main import (
get_value_repr,
get_callable_str,
get_entity_descriptor,
build_field_sequence,
get_user_permissions,
)
from ..context import ContextData, CallbackCommand, CommandContext
from ....utils.navigation import (
@@ -62,6 +70,10 @@ async def entity_item(
entity_item = await entity_type.get(session=db_session, id=callback_data.entity_id)
state: FSMContext = kwargs["state"]
state_data = kwargs["state_data"]
await state.set_data(state_data)
if not entity_item:
return await query.answer(
text=(await Settings.get(Settings.APP_STRINGS_NOT_FOUND))
@@ -85,10 +97,11 @@ async def entity_item(
)
if form.form_buttons:
context = EntityEventContext(db_session=db_session, app=app, message=query)
for edit_buttons_row in form.form_buttons:
btn_row = []
for button in edit_buttons_row:
if button.visibility and not button.visibility(entity_item):
if button.visibility and not button.visibility(entity_item, context):
continue
if isinstance(button, FieldEditButton) and can_edit:
@@ -100,13 +113,13 @@ async def entity_item(
]
field_value = getattr(entity_item, field_descriptor.field_name)
if btn_caption:
btn_text = get_callable_str(
btn_text = await get_callable_str(
btn_caption, field_descriptor, entity_item, field_value
)
else:
if field_descriptor.type_base is bool:
btn_text = f"{'【✔︎】 ' if field_value else '【 】 '}{
get_callable_str(
await get_callable_str(
field_descriptor.caption,
field_descriptor,
entity_item,
@@ -116,18 +129,20 @@ async def entity_item(
else field_name
}"
else:
btn_text = (
f"✏️ {
get_callable_str(
field_descriptor.caption,
field_descriptor,
entity_item,
field_value,
)
}"
btn_text = f"{
field_descriptor.icon
if field_descriptor.icon
else '✏️'
} {
await get_callable_str(
field_descriptor.caption,
field_descriptor,
entity_item,
field_value,
)
if field_descriptor.caption
else f"✏️ {field_name}"
)
else field_name
}"
btn_row.append(
InlineKeyboardButton(
text=btn_text,
@@ -144,14 +159,17 @@ async def entity_item(
elif isinstance(button, CommandButton):
btn_caption = button.caption
btn_text = get_callable_str(
btn_text = await get_callable_str(
btn_caption, entity_descriptor, entity_item
)
if isinstance(button.command, ContextData):
btn_cdata = button.command
elif callable(button.command):
btn_cdata = button.command(callback_data, entity_item)
if iscoroutinefunction(button.command):
btn_cdata = await button.command(entity_item, context)
else:
btn_cdata = button.command(entity_item, context)
elif isinstance(button.command, str):
btn_cdata = ContextData(
command=CallbackCommand.USER_COMMAND,
@@ -169,13 +187,26 @@ async def entity_item(
if isinstance(button.inline_button, InlineKeyboardButton):
btn_row.append(button.inline_button)
elif callable(button.inline_button):
btn_row.append(button.inline_button(entity_item))
if iscoroutinefunction(button.inline_button):
btn_row.append(
await button.inline_button(entity_item, context)
)
else:
btn_row.append(button.inline_button(entity_item, context))
if btn_row:
keyboard_builder.row(*btn_row)
edit_delete_row = []
if can_edit and form.show_edit_button:
if form.edit_field_sequence:
field_sequence = form.edit_field_sequence
else:
field_sequence = build_field_sequence(
entity_descriptor=entity_descriptor,
user=user,
callback_data=callback_data,
)
edit_delete_row.append(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_EDIT_BTN)),
@@ -185,7 +216,7 @@ async def entity_item(
entity_name=entity_descriptor.name,
entity_id=str(entity_item.id),
form_params=callback_data.form_params,
field_name=form.edit_field_sequence[0],
field_name=field_sequence[0],
).pack(),
)
)
@@ -215,7 +246,7 @@ async def entity_item(
item_text = form.item_repr(entity_descriptor, entity_item)
else:
entity_caption = (
get_callable_str(
await get_callable_str(
entity_descriptor.full_name, entity_descriptor, entity_item
)
if entity_descriptor.full_name
@@ -223,7 +254,7 @@ async def entity_item(
)
entity_item_repr = (
get_callable_str(
await get_callable_str(
entity_descriptor.item_repr, entity_descriptor, entity_item
)
if entity_descriptor.item_repr
@@ -232,25 +263,47 @@ async def entity_item(
item_text = f"<b><u><i>{entity_caption or entity_descriptor.name}:</i></u></b> <b>{entity_item_repr}</b>"
user_permissions = get_user_permissions(user, entity_descriptor)
for field_descriptor in entity_descriptor.fields_descriptors.values():
if field_descriptor.is_visible:
field_caption = get_callable_str(
field_descriptor.caption, field_descriptor, entity_item
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),
)
if field_descriptor.caption_value:
value = get_callable_str(
field_descriptor.caption_value,
field_descriptor,
entity_item,
getattr(entity_item, field_descriptor.field_name),
)
else:
value = get_value_repr(
value=getattr(entity_item, field_descriptor.field_name),
field_descriptor=field_descriptor,
locale=user.lang,
)
item_text += f"\n{field_caption or field_descriptor.name}:{f' <b>{value}</b>' if value else ''}"
else:
value = await get_value_repr(
value=getattr(entity_item, field_descriptor.field_name),
field_descriptor=field_descriptor,
locale=user.lang,
)
item_text += f"\n{field_caption or field_descriptor.name}:{f' <b>{value}</b>' if value else ''}"
context = pop_navigation_context(navigation_stack)
if context:
@@ -261,9 +314,9 @@ async def entity_item(
)
)
state: FSMContext = kwargs["state"]
state_data = kwargs["state_data"]
await state.set_data(state_data)
# state: FSMContext = kwargs["state"]
# state_data = kwargs["state_data"]
# await state.set_data(state_data)
send_message = get_send_message(query)

View File

@@ -1,3 +1,4 @@
from inspect import iscoroutinefunction
from aiogram import Router, F
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton
@@ -5,6 +6,8 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import TYPE_CHECKING
from quickbot.model.descriptors import EntityEventContext
from ..context import ContextData, CallbackCommand
from ....model.user import UserBase
from ....model.settings import Settings
@@ -47,10 +50,22 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
)
if callback_data.data == "yes":
await entity_descriptor.type_.remove(
entity = await entity_descriptor.type_.remove(
session=db_session, id=int(callback_data.entity_id), commit=True
)
if entity_descriptor.on_deleted:
if iscoroutinefunction(entity_descriptor.on_created):
await entity_descriptor.on_deleted(
entity,
EntityEventContext(db_session=db_session, app=app, message=query),
)
else:
entity_descriptor.on_deleted(
entity,
EntityEventContext(db_session=db_session, app=app, message=query),
)
await route_callback(message=query, **kwargs)
elif not callback_data.data:
@@ -61,7 +76,7 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
return await query.message.edit_text(
text=(
await Settings.get(Settings.APP_STRINGS_CONFIRM_DELETE_P_NAME)
).format(name=get_entity_item_repr(entity=entity)),
).format(name=await get_entity_item_repr(entity=entity)),
reply_markup=InlineKeyboardBuilder()
.row(
InlineKeyboardButton(

View File

@@ -18,6 +18,7 @@ from ....utils.main import (
clear_state,
get_entity_descriptor,
get_callable_str,
build_field_sequence,
)
from ....utils.serialization import deserialize
from ..context import ContextData, CallbackCommand, CommandContext
@@ -116,6 +117,14 @@ async def entity_list(
EntityPermission.CREATE in user_permissions
or EntityPermission.CREATE_ALL in user_permissions
) and form_list.show_add_new_button:
if form_item.edit_field_sequence:
field_sequence = form_item.edit_field_sequence
else:
field_sequence = build_field_sequence(
entity_descriptor=entity_descriptor,
user=user,
callback_data=callback_data,
)
keyboard_builder.row(
InlineKeyboardButton(
text=(await Settings.get(Settings.APP_STRINGS_ADD_BTN)),
@@ -123,7 +132,7 @@ async def entity_list(
command=CallbackCommand.FIELD_EDITOR,
context=CommandContext.ENTITY_CREATE,
entity_name=entity_descriptor.name,
field_name=form_item.edit_field_sequence[0],
field_name=field_sequence[0],
form_params=form_list.item_form,
).pack(),
)
@@ -197,7 +206,7 @@ async def entity_list(
caption = entity_descriptor.item_repr(entity_descriptor, item)
elif entity_descriptor.full_name:
caption = f"{
get_callable_str(
await get_callable_str(
callable_str=entity_descriptor.full_name,
descriptor=entity_descriptor,
entity=item,
@@ -228,7 +237,7 @@ async def entity_list(
)
if form_list.filtering and form_list.filtering_fields:
add_filter_controls(
await add_filter_controls(
keyboard_builder=keyboard_builder,
entity_descriptor=entity_descriptor,
filter=entity_filter,
@@ -245,17 +254,17 @@ async def entity_list(
)
if form_list.caption:
entity_text = get_callable_str(form_list.caption, entity_descriptor)
entity_text = await get_callable_str(form_list.caption, entity_descriptor)
else:
if entity_descriptor.full_name_plural:
entity_text = get_callable_str(
entity_text = await get_callable_str(
entity_descriptor.full_name_plural, entity_descriptor
)
else:
entity_text = entity_descriptor.name
if entity_descriptor.description:
entity_text = f"{entity_text} {get_callable_str(entity_descriptor.description, entity_descriptor)}"
entity_text = f"{entity_text} {await get_callable_str(entity_descriptor.description, entity_descriptor)}"
state: FSMContext = kwargs["state"]
state_data = kwargs["state_data"]

View File

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

View File

@@ -54,12 +54,12 @@ async def parameters_menu(
continue
if key.caption_value:
caption = get_callable_str(
caption = await get_callable_str(
callable_str=key.caption_value, descriptor=key, entity=None, value=value
)
else:
if key.caption:
caption = get_callable_str(
caption = await get_callable_str(
callable_str=key.caption, descriptor=key, entity=None, value=value
)
else:
@@ -68,7 +68,7 @@ async def parameters_menu(
if key.type_ is bool:
caption = f"{'【✔︎】' if value else '【 】'} {caption}"
else:
caption = f"{caption}: {get_value_repr(value=value, field_descriptor=key, locale=user.lang)}"
caption = f"{caption}: {await get_value_repr(value=value, field_descriptor=key, locale=user.lang)}"
keyboard_builder.row(
InlineKeyboardButton(

View File

@@ -8,6 +8,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ...main import QBotApp
from ...model.settings import Settings
from ...model.language import LanguageBase
from ...model.user import UserBase
from ...utils.main import clear_state
@@ -16,9 +17,22 @@ router = Router()
@router.message(CommandStart())
async def start(
message: Message, db_session: AsyncSession, app: QBotApp, state: FSMContext
):
async def start(message: Message, **kwargs):
app: QBotApp = kwargs["app"]
if app.start_handler:
await app.start_handler(default_start_handler, message, **kwargs)
else:
await default_start_handler(message, **kwargs)
async def default_start_handler[UserType: UserBase](
message: Message,
db_session: AsyncSession,
app: QBotApp,
state: FSMContext,
**kwargs,
) -> tuple[UserType, bool]:
state_data = await state.get_data()
clear_state(state_data=state_data, clear_nav=True)
@@ -27,6 +41,7 @@ async def start(
user = await User.get(session=db_session, id=message.from_user.id)
if not user:
is_new = True
msg_text = (await Settings.get(Settings.APP_STRINGS_WELCOME_P_NAME)).format(
name=message.from_user.full_name
)
@@ -61,6 +76,7 @@ async def start(
return
else:
is_new = False
if user.is_active:
msg_text = (
await Settings.get(Settings.APP_STRINGS_GREETING_P_NAME)
@@ -71,3 +87,5 @@ async def start(
).format(name=user.name)
await message.answer(msg_text)
return user, is_new

View File

@@ -4,21 +4,21 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from inspect import iscoroutinefunction
from typing import TYPE_CHECKING
from qbot.utils.main import clear_state
from qbot.utils.navigation import (
from quickbot.utils.main import clear_state
from quickbot.utils.navigation import (
save_navigation_context,
get_navigation_context,
pop_navigation_context,
)
from qbot.bot.handlers.editors.main import field_editor
from qbot.bot.handlers.common.routing import route_callback
from qbot.utils.serialization import deserialize
from qbot.utils.main import get_send_message
from qbot.model.descriptors import BotCommand, CommandCallbackContext
from qbot.model.settings import Settings
from quickbot.bot.handlers.editors.main import field_editor
from quickbot.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:
from qbot.main import QBotApp
from quickbot.main import QBotApp
from ..context import ContextData, CallbackCommand, CommandContext

View File

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

View File

@@ -1,7 +1,8 @@
from contextlib import asynccontextmanager
from typing import Annotated, Callable, Any
from typing_extensions import Doc
from typing import Callable, Any
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.client.default import DefaultBotProperties
from aiogram.types import Message, BotCommand as AiogramBotCommand
from aiogram.utils.callback_answer import CallbackAnswerMiddleware
@@ -22,12 +23,12 @@ from .router import Router
logger = getLogger(__name__)
@asynccontextmanager
async def default_lifespan(app: "QBotApp"):
logger.debug("starting qbot app")
if app.lifespan_bot_init:
if app.config.USE_NGROK:
app.ngrok_init()
@@ -36,8 +37,8 @@ async def default_lifespan(app: "QBotApp"):
logger.info("qbot app started")
if app.lifespan:
async with app.lifespan(app):
yield
async with app.lifespan(app) as state:
yield state
else:
yield
@@ -45,44 +46,30 @@ async def default_lifespan(app: "QBotApp"):
if app.lifespan_bot_init:
await app.bot_close()
if app.config.USE_NGROK:
app.ngrok_stop()
logger.info("qbot app stopped")
class QBotApp(FastAPI):
class QBotApp[UserType: UserBase](FastAPI):
"""
Main class for the QBot application
"""
def __init__[UserType: UserBase](
def __init__(
self,
user_class: (
Annotated[
type[UserType], Doc("User class that will be used in the application")
]
| None
) = None,
user_class: UserType = None,
config: Config | None = None,
bot_start: (
Annotated[
Callable[
[
Annotated[
Callable[[Message, Any], None],
Doc("Default handler for the start command"),
],
Message,
Any,
],
None,
],
Doc("Handler for the start command"),
]
| None
) = None,
bot_start: Callable[
[
Callable[[Message, Any], tuple[UserType, bool]],
Message,
Any,
],
None,
] = None,
lifespan: Lifespan[AppType] | None = None,
lifespan_bot_init: bool = True,
allowed_updates: list[str] | None = None,
@@ -103,8 +90,14 @@ class QBotApp(FastAPI):
self.entity_metadata: EntityMetadata = user_class.entity_metadata
self.config = config
self.lifespan = lifespan
api_server = TelegramAPIServer.from_base(
self.config.TELEGRAM_BOT_SERVER,
is_local=self.config.TELEGRAM_BOT_SERVER_IS_LOCAL,
)
session = AiohttpSession(api=api_server)
self.bot = Bot(
token=self.config.TELEGRAM_BOT_TOKEN,
session=session,
default=DefaultBotProperties(parse_mode="HTML"),
)
@@ -139,18 +132,16 @@ class QBotApp(FastAPI):
from .api_route.telegram import router as telegram_router
self.include_router(telegram_router, prefix="/api/telegram", tags=["telegram"])
self.include_router(telegram_router, prefix="/telegram", tags=["telegram"])
self.root_router = Router()
self.root_router._commands = self.bot_commands
self.command = self.root_router.command
def register_routers(self, *routers: Router):
for router in routers:
for command_name, command in router._commands.items():
self.bot_commands[command_name] = command
def ngrok_init(self):
try:
from pyngrok import ngrok
@@ -165,7 +156,6 @@ class QBotApp(FastAPI):
pyngrok_config=PyngrokConfig(auth_token=self.config.NGROK_AUTH_TOKEN),
)
self.config.NGROK_URL = tunnel.public_url
def ngrok_stop(self):
try:
@@ -178,10 +168,7 @@ class QBotApp(FastAPI):
ngrok.disconnect(self.config.NGROK_URL)
ngrok.kill()
async def bot_init(self):
commands_captions = dict[str, list[tuple[str, str]]]()
for command_name, command in self.bot_commands.items():
@@ -208,13 +195,13 @@ class QBotApp(FastAPI):
)
await self.bot.set_webhook(
url=f"{self.config.API_URL}/api/telegram/webhook",
url=f"{self.config.TELEGRAM_WEBHOOK_URL}/telegram/webhook",
drop_pending_updates=True,
allowed_updates=self.allowed_updates,
secret_token=self.bot_auth_token,
)
async def bot_close(self):
await self.bot.delete_webhook()
await self.bot.log_out()
await self.bot.close()

View File

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

View File

@@ -160,7 +160,7 @@ class EnumType(TypeDecorator):
impl = AutoString
cache_ok = True
def __init__(self, enum_type: BotEnum):
def __init__(self, enum_type):
self._enum_type = enum_type
super().__init__()

View File

@@ -93,7 +93,7 @@ class _BaseFieldDescriptor:
description: str | LazyProxy | EntityFieldCaptionCallable | None = None
edit_prompt: str | LazyProxy | EntityFieldCaptionCallable | None = None
caption_value: EntityFieldCaptionCallable | None = None
is_visible: bool = True
is_visible: bool | None = None
localizable: bool = False
bool_false_value: str | LazyProxy = "no"
bool_true_value: str | LazyProxy = "yes"
@@ -102,6 +102,7 @@ class _BaseFieldDescriptor:
ep_child_field: str | None = None
dt_type: Literal["date", "datetime"] = "date"
default: Any = None
default_factory: Callable[[], Any] | None = None
@dataclass(kw_only=True)
@@ -163,6 +164,9 @@ class _BaseEntityDescriptor:
EntityPermission.DELETE_ALL: [RoleBase.SUPER_USER],
}
)
on_created: Callable[["BotEntity", "EntityEventContext"], None] | None = None
on_deleted: Callable[["BotEntity", "EntityEventContext"], None] | None = None
on_updated: Callable[["BotEntity", "EntityEventContext"], None] | None = None
@dataclass(kw_only=True)
@@ -197,6 +201,13 @@ class CommandCallbackContext[UT: UserBase]:
kwargs: dict[str, Any] = field(default_factory=dict)
@dataclass(kw_only=True)
class EntityEventContext:
db_session: AsyncSession
app: "QBotApp"
message: Message | CallbackQuery | None = None
@dataclass(kw_only=True)
class BotCommand:
name: str

View File

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

View File

@@ -1,5 +1,5 @@
from babel.support import LazyProxy
from inspect import signature
from inspect import iscoroutinefunction, signature
from aiogram.types import Message, CallbackQuery
from aiogram.utils.i18n import I18n
from typing import Any, TYPE_CHECKING
@@ -105,7 +105,7 @@ def clear_state(state_data: dict, clear_nav: bool = False):
state_data["navigation_context"] = context
def get_entity_item_repr(
async def get_entity_item_repr(
entity: BotEntity, item_repr: EntityItemCaptionCallable | None = None
) -> str:
descr = entity.bot_entity_descriptor
@@ -115,14 +115,14 @@ def get_entity_item_repr(
descr.item_repr(descr, entity)
if descr.item_repr
else f"{
get_callable_str(descr.full_name, descr, entity)
await get_callable_str(descr.full_name, descr, entity)
if descr.full_name
else descr.name
}: {str(entity.id)}"
)
def get_value_repr(
async def get_value_repr(
value: Any, field_descriptor: FieldDescriptor, locale: str | None = None
) -> str:
if value is None:
@@ -133,7 +133,9 @@ def get_value_repr(
return "【✔︎】" if value else "【 】"
elif field_descriptor.is_list:
if issubclass(type_, BotEntity):
return f"[{', '.join([get_entity_item_repr(item) for item in value])}]"
return (
f"[{', '.join([await get_entity_item_repr(item) for item in value])}]"
)
elif issubclass(type_, BotEnum):
return f"[{', '.join(item.localized(locale) for item in value)}]"
elif type_ is str:
@@ -141,7 +143,7 @@ def get_value_repr(
else:
return f"[{', '.join([str(item) for item in value])}]"
elif issubclass(type_, BotEntity):
return get_entity_item_repr(value)
return await get_entity_item_repr(value)
elif issubclass(type_, BotEnum):
return value.localized(locale)
elif isinstance(value, str):
@@ -156,7 +158,7 @@ def get_value_repr(
return str(value)
def get_callable_str(
async def get_callable_str(
callable_str: (
str
| LazyProxy
@@ -174,12 +176,20 @@ def get_callable_str(
return callable_str.value
elif callable(callable_str):
args = signature(callable_str).parameters
if len(args) == 1:
return callable_str(descriptor)
elif len(args) == 2:
return callable_str(descriptor, entity)
elif len(args) == 3:
return callable_str(descriptor, entity, value)
if iscoroutinefunction(callable_str):
if len(args) == 1:
return await callable_str(descriptor)
elif len(args) == 2:
return await callable_str(descriptor, entity)
elif len(args) == 3:
return await callable_str(descriptor, entity, value)
else:
if len(args) == 1:
return callable_str(descriptor)
elif len(args) == 2:
return callable_str(descriptor, entity)
elif len(args) == 3:
return callable_str(descriptor, entity, value)
def get_entity_descriptor(
@@ -212,3 +222,40 @@ def get_field_descriptor(
if entity_descriptor:
return entity_descriptor.fields_descriptors.get(callback_data.field_name)
return None
def build_field_sequence(
entity_descriptor: EntityDescriptor, user: "UserBase", callback_data: ContextData
):
field_sequence = list[str]()
# exclude ownership fields from edit if user has no CREATE_ALL/UPDATE_ALL permission
user_permissions = get_user_permissions(user, entity_descriptor)
for fd in entity_descriptor.fields_descriptors.values():
if not (
fd.is_optional
or fd.field_name == "id"
or fd.field_name[:-3] == "_id"
or fd.default is not None
):
skip = False
for own_field in entity_descriptor.ownership_fields.items():
if (
own_field[1].rstrip("_id") == fd.field_name.rstrip("_id")
and own_field[0] in user.roles
and (
(
EntityPermission.CREATE_ALL not in user_permissions
and callback_data.context == CommandContext.ENTITY_CREATE
)
or (
EntityPermission.UPDATE_ALL not in user_permissions
and callback_data.context == CommandContext.ENTITY_EDIT
)
)
):
skip = True
break
if not skip:
field_sequence.append(fd.field_name)
return field_sequence

139
tests/bot.py Normal file
View File

@@ -0,0 +1,139 @@
from quickbot import (
QBotApp,
BotEntity,
Entity,
EntityForm,
EntityList,
FieldEditButton,
EntityField,
CommandCallbackContext,
ContextData,
CallbackCommand,
)
from quickbot.model.user import UserBase
from quickbot.model.descriptors import Filter
from quickbot.model.role import RoleBase
from aiogram.types import InlineKeyboardButton
from datetime import datetime
from sqlmodel import BigInteger, Field, Relationship
from typing import Optional
class User(UserBase):
bot_entity_descriptor = Entity(
icon="👤",
full_name="User",
full_name_plural="Users",
item_repr=lambda d, e: e.name,
default_list=EntityList(
show_add_new_button=False,
static_filters=[Filter("roles", "contains", value=RoleBase.SUPER_USER)],
filtering=True,
filtering_fields=["name"],
),
default_form=EntityForm(
show_delete_button=False,
show_edit_button=False,
form_buttons=[
[
FieldEditButton("name"),
FieldEditButton("is_active"),
],
[
FieldEditButton("lang"),
FieldEditButton("roles"),
],
],
item_repr=lambda d, e: f"{e.name}\n{
'is active' if e.is_active else 'is not active'
}\nlang: {e.lang.localized()}\nroles: [{
', '.join([r.localized() for r in e.roles]) if e.roles else 'none'
}]",
),
)
class Entity(BotEntity):
bot_entity_descriptor = Entity(
icon="📦",
full_name="Entity",
full_name_plural="Entities",
item_repr=lambda d, e: e.name,
default_list=EntityList(
filtering=True,
filtering_fields=["name"],
order_by="name",
),
default_form=EntityForm(
form_buttons=[
[
FieldEditButton("name"),
FieldEditButton("user"),
],
[
FieldEditButton("creation_dt"),
],
],
),
)
id: int = EntityField(
caption="ID",
sm_descriptor=Field(
primary_key=True,
),
is_visible=False,
)
name: str = EntityField(
caption="Name",
)
creation_dt: datetime = EntityField(
caption="Creation date",
dt_type="datetime",
)
user_id: int | None = EntityField(
sm_descriptor=Field(
sa_type=BigInteger,
foreign_key="user.id",
ondelete="RESTRICT",
nullable=True,
),
is_visible=False,
)
user: Optional[User] = EntityField(
sm_descriptor=Relationship(
sa_relationship_kwargs={
"lazy": "selectin",
"foreign_keys": "Entity.user_id",
}
),
caption="User",
)
app = QBotApp(
user_class=User,
)
@app.command(
name="menu",
caption="Main menu",
show_in_bot_commands=True,
clear_navigation=True,
)
async def menu(context: CommandCallbackContext):
context.message_text = "Main menu"
context.keyboard_builder.row(
InlineKeyboardButton(
text="Entities",
callback_data=ContextData(
command=CallbackCommand.MENU_ENTRY_ENTITIES,
).pack(),
)
)

0
tests/test_bot.py Normal file
View File

1366
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff