Compare commits

..

29 Commits

Author SHA1 Message Date
Alexander Kalinovsky
fe0380f9f3 minor fixes and updates
All checks were successful
Build Docs / changes (push) Successful in 5s
Build Docs / build-docs (push) Has been skipped
Build Docs / deploy-docs (push) Has been skipped
2025-03-19 14:46:23 +07:00
Alexander Kalinovsky
f0db2b2830 upd defult editing field sequences generation when ownership fields defined 2025-03-13 16:52:03 +07:00
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 2306 additions and 99 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

8
.gitignore vendored
View File

@@ -1,6 +1,10 @@
__pycache__
/backend/.venv
.env
.pytest_cache
/backend/logs
.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

65
docs/img/qbot.svg Normal file
View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="1500"
height="500"
viewBox="0 0 1500 500"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
sodipodi:docname="qbot.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="704.4466"
inkscape:cy="297.41182"
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.81143"
y="115.09507"
width="901.16989"
height="411.40365"
id="rect3" /><rect
x="90.606755"
y="102.85091"
width="416.30131"
height="306.1039"
id="rect2" /></defs><g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1"><g
id="g18"
transform="translate(118.93657)"><path
style="font-size:266.667px;font-family:'Arial Rounded MT Bold';-inkscape-font-specification:'Arial Rounded MT Bold, Normal';fill:#404040"
d="m 685.86904,314.58341 q 8.46355,5.72918 18.4896,11.19793 10.02606,5.33854 13.28127,8.59377 3.25521,3.125 3.25521,8.98437 0,4.16668 -3.90625,8.33335 -3.77605,4.16668 -9.24481,4.16668 -4.42708,0 -10.8073,-2.8646 -6.25001,-2.86459 -14.84377,-8.33334 -8.46355,-5.46876 -18.61981,-12.76044 -18.88023,9.63543 -46.35423,9.63543 -22.26565,0 -39.974,-7.03126 -17.57815,-7.16146 -29.55733,-20.44273 -11.97918,-13.41147 -18.09898,-31.77087 -5.98959,-18.3594 -5.98959,-39.97401 0,-22.00523 6.25001,-40.36463 6.38021,-18.3594 18.35939,-31.25004 11.97918,-12.89064 29.16671,-19.66148 17.18752,-6.90105 39.06254,-6.90105 29.68754,0 50.91152,12.10939 21.3542,11.97918 32.29171,34.24483 10.93751,22.13545 10.93751,52.0834 0,45.44276 -24.6094,72.0053 z m -30.33858,-21.09377 q 8.07293,-9.2448 11.84897,-21.87503 3.90626,-12.63022 3.90626,-29.29691 0,-20.96358 -6.77084,-36.32817 -6.77085,-15.3646 -19.40107,-23.17711 -12.50002,-7.94273 -28.77608,-7.94273 -11.58855,0 -21.4844,4.42709 -9.76563,4.29688 -16.9271,12.63022 -7.03126,8.33335 -11.19793,21.3542 -4.03647,12.89064 -4.03647,29.0365 0,32.94274 15.36461,50.78131 15.3646,17.70836 38.80213,17.70836 9.63543,0 19.79169,-4.03647 -6.1198,-4.55729 -15.3646,-9.11459 -9.1146,-4.5573 -12.50002,-7.03126 -3.38542,-2.47396 -3.38542,-7.03126 0,-3.90625 3.25521,-6.90105 3.25521,-2.99479 7.16147,-2.99479 11.84897,0 39.71359,19.79169 z m 169.53146,44.79172 h -58.3334 q -12.63023,0 -18.09898,-5.59898 -5.33855,-5.72917 -5.33855,-18.09897 V 171.09366 q 0,-12.63023 5.46876,-18.09898 5.59896,-5.59897 17.96877,-5.59897 h 61.84903 q 13.67189,0 23.69795,1.69271 10.02605,1.69271 17.96877,6.51043 6.77084,4.03646 11.97918,10.28647 5.20834,6.1198 7.94272,13.67189 2.73438,7.42188 2.73438,15.75523 0,28.64586 -28.64587,41.92713 37.63025,11.97918 37.63025,46.61464 0,16.01564 -8.20313,28.90629 -8.20314,12.76043 -22.13545,18.88023 -8.72396,3.64583 -20.0521,5.20834 -11.32814,1.43229 -26.43233,1.43229 z m -2.86458,-84.76574 h -40.23443 v 55.72924 h 41.53651 q 39.19276,0 39.19276,-28.25524 0,-14.45315 -10.15627,-20.96357 -10.15626,-6.51043 -30.33857,-6.51043 z m -40.23443,-77.08343 v 49.34902 h 35.41671 q 14.45314,0 22.26565,-2.73438 7.94272,-2.73437 12.10939,-10.41667 3.25522,-5.46876 3.25522,-12.2396 0,-14.45315 -10.28647,-19.14065 -10.28648,-4.81772 -31.38025,-4.81772 z m 283.98469,92.83866 q 0,15.88544 -4.9479,29.29691 -4.9479,13.41148 -14.3229,23.0469 -9.375,9.63543 -22.3959,14.84377 -13.0208,5.07813 -29.29688,5.07813 -16.14586,0 -29.0365,-5.20834 -12.89064,-5.20834 -22.39586,-14.84377 -9.37501,-9.76563 -14.32293,-22.91669 -4.81772,-13.28127 -4.81772,-29.29691 0,-16.14585 4.94793,-29.55733 4.94792,-13.41147 14.19272,-22.91669 9.2448,-9.50522 22.39586,-14.58335 13.15106,-5.20834 29.0365,-5.20834 16.14588,0 29.29688,5.20834 13.1511,5.20834 22.5261,14.84376 9.375,9.63543 14.1927,22.9167 4.9479,13.28126 4.9479,29.29691 z m -35.6771,0 q 0,-21.74482 -9.6354,-33.85421 -9.5052,-12.10939 -25.65108,-12.10939 -10.41668,0 -18.3594,5.46876 -7.94272,5.33855 -12.2396,15.88544 -4.29688,10.54689 -4.29688,24.60941 0,13.93231 4.16667,24.34899 4.29688,10.41668 12.10939,16.01564 7.94272,5.46876 18.61982,5.46876 16.14588,0 25.65108,-12.10939 9.6354,-12.2396 9.6354,-33.72401 z m 65.6251,-69.14071 h 3.9062 v -21.35419 q 0,-8.59376 0.3906,-13.41148 0.5209,-4.94792 2.6042,-8.46355 2.0833,-3.64584 5.9896,-5.85938 3.9063,-2.34375 8.724,-2.34375 6.7708,0 12.2396,5.07813 3.6458,3.38542 4.5573,8.33334 1.0416,4.81771 1.0416,13.8021 v 24.21878 h 13.0209 q 7.5521,0 11.4583,3.64584 4.0365,3.51563 4.0365,9.11459 0,7.16147 -5.7292,10.02606 -5.5989,2.86458 -16.1458,2.86458 h -6.6407 v 65.36467 q 0,8.33334 0.5209,12.89064 0.651,4.42709 3.125,7.29167 2.6041,2.73438 8.3333,2.73438 3.125,0 8.4636,-1.04167 5.3385,-1.17187 8.3333,-1.17187 4.2969,0 7.6823,3.51563 3.5156,3.38542 3.5156,8.46355 0,8.59376 -9.375,13.15106 -9.375,4.55729 -26.9531,4.55729 -16.6667,0 -25.2605,-5.59896 -8.5937,-5.59897 -11.3281,-15.49481 -2.6042,-9.89585 -2.6042,-26.43233 v -68.22924 h -4.6875 q -7.6823,0 -11.7188,-3.64584 -4.0364,-3.64583 -4.0364,-9.2448 0,-5.59896 4.1667,-9.11459 4.2968,-3.64584 12.3698,-3.64585 z"
id="text17"
aria-label="QBot" /><g
id="g17"
transform="matrix(1.195598,0,0,1.195598,-52.1047,-71.699942)"><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></g></svg>

After

Width:  |  Height:  |  Size: 9.4 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

101
docs/index.md Normal file
View File

@@ -0,0 +1,101 @@
<style>
h1 { display: none; }
</style>
<p align="center">
<a href="https://qbot.botforge.biz"><img src="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>
forced_change_

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

View File

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
)
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
@@ -250,10 +260,14 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
# What if user has several roles and each role has its own ownership field? Should we allow creation even
# if user has no CREATE_ALL permission
user_permissions = get_user_permissions(user, entity_descriptor)
# for role in user.roles:
# if role in entity_descriptor.ownership_fields and not EntityPermission.CREATE_ALL in user_permissions:
# entity_data[entity_descriptor.ownership_fields[role]] = user.id
for role in user.roles:
if (
role in entity_descriptor.ownership_fields
and EntityPermission.CREATE_ALL not in user_permissions
):
entity_data[entity_descriptor.ownership_fields[role]] = user.id
deser_entity_data = {
key: await deserialize(
@@ -281,6 +295,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 +355,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
)
if form.edit_field_sequence:
field_sequence = form.edit_field_sequence
else:
field_sequence = build_field_sequence(
entity_descriptor=field_descriptor.entity_descriptor,
user=user,
callback_data=callback_data,
)
field_index = (
field_sequence.index(field_descriptor.name)
if callback_data.context

View File

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

View File

@@ -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 qbot.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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -222,3 +222,40 @@ def get_field_descriptor(
if entity_descriptor:
return entity_descriptor.fields_descriptors.get(callback_data.field_name)
return None
def build_field_sequence(
entity_descriptor: EntityDescriptor, user: "UserBase", callback_data: ContextData
):
field_sequence = list[str]()
# exclude ownership fields from edit if user has no CREATE_ALL/UPDATE_ALL permission
user_permissions = get_user_permissions(user, entity_descriptor)
for fd in entity_descriptor.fields_descriptors.values():
if not (
fd.is_optional
or fd.field_name == "id"
or fd.field_name[:-3] == "_id"
or fd.default is not None
):
skip = False
for own_field in entity_descriptor.ownership_fields.items():
if (
own_field[1].rstrip("_id") == fd.field_name.rstrip("_id")
and own_field[0] in user.roles
and (
(
EntityPermission.CREATE_ALL not in user_permissions
and callback_data.context == CommandContext.ENTITY_CREATE
)
or (
EntityPermission.UPDATE_ALL not in user_permissions
and callback_data.context == CommandContext.ENTITY_EDIT
)
)
):
skip = True
break
if not skip:
field_sequence.append(fd.field_name)
return field_sequence

139
tests/bot.py Normal file
View File

@@ -0,0 +1,139 @@
from qbot import (
QBotApp,
BotEntity,
Entity,
EntityForm,
EntityList,
FieldEditButton,
EntityField,
CommandCallbackContext,
ContextData,
CallbackCommand,
)
from qbot.model.user import UserBase
from qbot.model.descriptors import Filter
from qbot.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