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 2424 additions and 179 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__ __pycache__
/backend/.venv
.env .env
.pytest_cache .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

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, EntityField as EntityField,
EntityForm as EntityForm, EntityForm as EntityForm,
EntityList as EntityList, EntityList as EntityList,
Filter as Filter,
EntityPermission as EntityPermission, EntityPermission as EntityPermission,
CommandCallbackContext as CommandCallbackContext, CommandCallbackContext as CommandCallbackContext,
EntityEventContext as EntityEventContext,
CommandButton as CommandButton, CommandButton as CommandButton,
FieldEditButton as FieldEditButton, FieldEditButton as FieldEditButton,
InlineButton as InlineButton, InlineButton as InlineButton,

View File

@@ -10,7 +10,6 @@ from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
logger.setLevel("DEBUG")
router = APIRouter() router = APIRouter()
@@ -32,7 +31,11 @@ async def telegram_webhook(
return Response(status_code=400) return Response(status_code=400)
try: try:
await app.dp.feed_webhook_update( 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: except Exception:
logger.error("Error processing update", exc_info=True) 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 from ..context import ContextData, CallbackCommand
def add_filter_controls( async def add_filter_controls(
keyboard_builder: InlineKeyboardBuilder, keyboard_builder: InlineKeyboardBuilder,
entity_descriptor: EntityDescriptor, entity_descriptor: EntityDescriptor,
filter: str = None, filter: str = None,
@@ -15,7 +15,7 @@ def add_filter_controls(
): ):
caption = ", ".join( caption = ", ".join(
[ [
get_callable_str( await get_callable_str(
entity_descriptor.fields_descriptors[field_name].caption, entity_descriptor.fields_descriptors[field_name].caption,
entity_descriptor, entity_descriptor,
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ from typing import Any
from ....model.descriptors import FieldDescriptor from ....model.descriptors import FieldDescriptor
from ....model.language import LanguageBase from ....model.language import LanguageBase
from ....model.settings import Settings from ....model.settings import Settings
from ....model.user import UserBase
from ....utils.main import get_send_message, get_local_text from ....utils.main import get_send_message, get_local_text
from ....utils.serialization import serialize from ....utils.serialization import serialize
from ..context import ContextData, CallbackCommand from ..context import ContextData, CallbackCommand
@@ -25,6 +26,7 @@ async def string_editor(
current_value: Any, current_value: Any,
edit_prompt: str, edit_prompt: str,
state: FSMContext, state: FSMContext,
user: UserBase,
locale_index: int = 0, locale_index: int = 0,
**kwargs, **kwargs,
): ):
@@ -90,6 +92,7 @@ async def string_editor(
field_descriptor=field_descriptor, field_descriptor=field_descriptor,
callback_data=callback_data, callback_data=callback_data,
state_data=state_data, state_data=state_data,
user=user,
) )
await state.set_data(state_data) 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.settings import Settings
from ....model.descriptors import FieldDescriptor from ....model.descriptors import FieldDescriptor
from ....model.user import UserBase
from ..context import ContextData, CallbackCommand, CommandContext from ..context import ContextData, CallbackCommand, CommandContext
from ....utils.navigation import get_navigation_context, pop_navigation_context from ....utils.navigation import get_navigation_context, pop_navigation_context
from ....utils.main import build_field_sequence
async def wrap_editor( async def wrap_editor(
@@ -12,6 +14,7 @@ async def wrap_editor(
field_descriptor: FieldDescriptor, field_descriptor: FieldDescriptor,
callback_data: ContextData, callback_data: ContextData,
state_data: dict, state_data: dict,
user: UserBase,
): ):
if callback_data.context in [ if callback_data.context in [
CommandContext.ENTITY_CREATE, CommandContext.ENTITY_CREATE,
@@ -36,7 +39,14 @@ async def wrap_editor(
form = field_descriptor.entity_descriptor.forms.get( form = field_descriptor.entity_descriptor.forms.get(
form_name, field_descriptor.entity_descriptor.default_form form_name, field_descriptor.entity_descriptor.default_form
) )
if form.edit_field_sequence:
field_sequence = 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_index = (
field_sequence.index(field_descriptor.name) field_sequence.index(field_descriptor.name)
if callback_data.context if callback_data.context

View File

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

View File

@@ -1,3 +1,4 @@
from inspect import iscoroutinefunction
from aiogram import Router, F from aiogram import Router, F
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton from aiogram.types import CallbackQuery, InlineKeyboardButton
@@ -5,6 +6,8 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from qbot.model.descriptors import EntityEventContext
from ..context import ContextData, CallbackCommand from ..context import ContextData, CallbackCommand
from ....model.user import UserBase from ....model.user import UserBase
from ....model.settings import Settings from ....model.settings import Settings
@@ -47,10 +50,22 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
) )
if callback_data.data == "yes": 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 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) await route_callback(message=query, **kwargs)
elif not callback_data.data: elif not callback_data.data:
@@ -61,7 +76,7 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
return await query.message.edit_text( return await query.message.edit_text(
text=( text=(
await Settings.get(Settings.APP_STRINGS_CONFIRM_DELETE_P_NAME) await Settings.get(Settings.APP_STRINGS_CONFIRM_DELETE_P_NAME)
).format(name=get_entity_item_repr(entity=entity)), ).format(name=await get_entity_item_repr(entity=entity)),
reply_markup=InlineKeyboardBuilder() reply_markup=InlineKeyboardBuilder()
.row( .row(
InlineKeyboardButton( InlineKeyboardButton(

View File

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

View File

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

View File

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

View File

@@ -24,26 +24,11 @@ class Config(BaseSettings):
def DATABASE_URI(self) -> str: def DATABASE_URI(self) -> str:
return f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" 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 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" TELEGRAM_BOT_TOKEN: str = "changethis"
ADMIN_TELEGRAM_ID: int ADMIN_TELEGRAM_ID: int

View File

@@ -1,7 +1,8 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Annotated, Callable, Any from typing import Callable, Any
from typing_extensions import Doc
from aiogram import Bot, Dispatcher 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.client.default import DefaultBotProperties
from aiogram.types import Message, BotCommand as AiogramBotCommand from aiogram.types import Message, BotCommand as AiogramBotCommand
from aiogram.utils.callback_answer import CallbackAnswerMiddleware from aiogram.utils.callback_answer import CallbackAnswerMiddleware
@@ -22,12 +23,12 @@ from .router import Router
logger = getLogger(__name__) logger = getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def default_lifespan(app: "QBotApp"): async def default_lifespan(app: "QBotApp"):
logger.debug("starting qbot app") logger.debug("starting qbot app")
if app.lifespan_bot_init: if app.lifespan_bot_init:
if app.config.USE_NGROK: if app.config.USE_NGROK:
app.ngrok_init() app.ngrok_init()
@@ -36,8 +37,8 @@ async def default_lifespan(app: "QBotApp"):
logger.info("qbot app started") logger.info("qbot app started")
if app.lifespan: if app.lifespan:
async with app.lifespan(app): async with app.lifespan(app) as state:
yield yield state
else: else:
yield yield
@@ -52,37 +53,23 @@ async def default_lifespan(app: "QBotApp"):
logger.info("qbot app stopped") logger.info("qbot app stopped")
class QBotApp(FastAPI): class QBotApp[UserType: UserBase](FastAPI):
""" """
Main class for the QBot application Main class for the QBot application
""" """
def __init__[UserType: UserBase]( def __init__(
self, self,
user_class: ( user_class: UserType = None,
Annotated[
type[UserType], Doc("User class that will be used in the application")
]
| None
) = None,
config: Config | None = None, config: Config | None = None,
bot_start: ( bot_start: Callable[
Annotated[
Callable[
[ [
Annotated[ Callable[[Message, Any], tuple[UserType, bool]],
Callable[[Message, Any], None],
Doc("Default handler for the start command"),
],
Message, Message,
Any, Any,
], ],
None, None,
], ] = None,
Doc("Handler for the start command"),
]
| None
) = None,
lifespan: Lifespan[AppType] | None = None, lifespan: Lifespan[AppType] | None = None,
lifespan_bot_init: bool = True, lifespan_bot_init: bool = True,
allowed_updates: list[str] | None = None, allowed_updates: list[str] | None = None,
@@ -103,8 +90,14 @@ class QBotApp(FastAPI):
self.entity_metadata: EntityMetadata = user_class.entity_metadata self.entity_metadata: EntityMetadata = user_class.entity_metadata
self.config = config self.config = config
self.lifespan = lifespan 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( self.bot = Bot(
token=self.config.TELEGRAM_BOT_TOKEN, token=self.config.TELEGRAM_BOT_TOKEN,
session=session,
default=DefaultBotProperties(parse_mode="HTML"), default=DefaultBotProperties(parse_mode="HTML"),
) )
@@ -139,18 +132,16 @@ class QBotApp(FastAPI):
from .api_route.telegram import router as telegram_router 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 = Router()
self.root_router._commands = self.bot_commands self.root_router._commands = self.bot_commands
self.command = self.root_router.command self.command = self.root_router.command
def register_routers(self, *routers: Router): def register_routers(self, *routers: Router):
for router in routers: for router in routers:
for command_name, command in router._commands.items(): for command_name, command in router._commands.items():
self.bot_commands[command_name] = command self.bot_commands[command_name] = command
def ngrok_init(self): def ngrok_init(self):
try: try:
from pyngrok import ngrok from pyngrok import ngrok
@@ -166,7 +157,6 @@ class QBotApp(FastAPI):
) )
self.config.NGROK_URL = tunnel.public_url self.config.NGROK_URL = tunnel.public_url
def ngrok_stop(self): def ngrok_stop(self):
try: try:
from pyngrok import ngrok from pyngrok import ngrok
@@ -178,10 +168,7 @@ class QBotApp(FastAPI):
ngrok.disconnect(self.config.NGROK_URL) ngrok.disconnect(self.config.NGROK_URL)
ngrok.kill() ngrok.kill()
async def bot_init(self): async def bot_init(self):
commands_captions = dict[str, list[tuple[str, str]]]() commands_captions = dict[str, list[tuple[str, str]]]()
for command_name, command in self.bot_commands.items(): for command_name, command in self.bot_commands.items():
@@ -198,6 +185,13 @@ class QBotApp(FastAPI):
commands_captions[locale] = [] commands_captions[locale] = []
commands_captions[locale].append((command_name, description)) 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(): for locale, commands in commands_captions.items():
await self.bot.set_my_commands( await self.bot.set_my_commands(
[ [
@@ -207,14 +201,7 @@ class QBotApp(FastAPI):
language_code=None if locale == "default" else locale, 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): async def bot_close(self):
await self.bot.delete_webhook() 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, dataclass_transform,
) )
from pydantic import BaseModel from pydantic import BaseModel
from pydantic_core import PydanticUndefined
from sqlmodel import SQLModel, BigInteger, Field, select, func, column, col from sqlmodel import SQLModel, BigInteger, Field, select, func, column, col
from sqlmodel.main import FieldInfo from sqlmodel.main import FieldInfo
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -64,7 +65,33 @@ class BotEntityMetaclass(SQLModelMetaclass):
if attribute_value: if attribute_value:
if isinstance(attribute_value, EntityField): if isinstance(attribute_value, EntityField):
descriptor_kwargs = attribute_value.__dict__.copy() 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: if sm_descriptor:
namespace[annotation] = sm_descriptor namespace[annotation] = sm_descriptor
@@ -157,23 +184,6 @@ class BotEntityMetaclass(SQLModelMetaclass):
fields_descriptors=bot_fields_descriptors, 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(): for field_descriptor in bot_fields_descriptors.values():
field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"] field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"]

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
from babel.support import LazyProxy from babel.support import LazyProxy
from inspect import signature from inspect import iscoroutinefunction, signature
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from aiogram.utils.i18n import I18n from aiogram.utils.i18n import I18n
from typing import Any, TYPE_CHECKING from typing import Any, TYPE_CHECKING
@@ -105,7 +105,7 @@ def clear_state(state_data: dict, clear_nav: bool = False):
state_data["navigation_context"] = context state_data["navigation_context"] = context
def get_entity_item_repr( async def get_entity_item_repr(
entity: BotEntity, item_repr: EntityItemCaptionCallable | None = None entity: BotEntity, item_repr: EntityItemCaptionCallable | None = None
) -> str: ) -> str:
descr = entity.bot_entity_descriptor descr = entity.bot_entity_descriptor
@@ -115,14 +115,14 @@ def get_entity_item_repr(
descr.item_repr(descr, entity) descr.item_repr(descr, entity)
if descr.item_repr if descr.item_repr
else f"{ else f"{
get_callable_str(descr.full_name, descr, entity) await get_callable_str(descr.full_name, descr, entity)
if descr.full_name if descr.full_name
else descr.name else descr.name
}: {str(entity.id)}" }: {str(entity.id)}"
) )
def get_value_repr( async def get_value_repr(
value: Any, field_descriptor: FieldDescriptor, locale: str | None = None value: Any, field_descriptor: FieldDescriptor, locale: str | None = None
) -> str: ) -> str:
if value is None: if value is None:
@@ -133,7 +133,9 @@ def get_value_repr(
return "【✔︎】" if value else "【 】" return "【✔︎】" if value else "【 】"
elif field_descriptor.is_list: elif field_descriptor.is_list:
if issubclass(type_, BotEntity): 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): elif issubclass(type_, BotEnum):
return f"[{', '.join(item.localized(locale) for item in value)}]" return f"[{', '.join(item.localized(locale) for item in value)}]"
elif type_ is str: elif type_ is str:
@@ -141,7 +143,7 @@ def get_value_repr(
else: else:
return f"[{', '.join([str(item) for item in value])}]" return f"[{', '.join([str(item) for item in value])}]"
elif issubclass(type_, BotEntity): elif issubclass(type_, BotEntity):
return get_entity_item_repr(value) return await get_entity_item_repr(value)
elif issubclass(type_, BotEnum): elif issubclass(type_, BotEnum):
return value.localized(locale) return value.localized(locale)
elif isinstance(value, str): elif isinstance(value, str):
@@ -156,7 +158,7 @@ def get_value_repr(
return str(value) return str(value)
def get_callable_str( async def get_callable_str(
callable_str: ( callable_str: (
str str
| LazyProxy | LazyProxy
@@ -174,6 +176,14 @@ def get_callable_str(
return callable_str.value return callable_str.value
elif callable(callable_str): elif callable(callable_str):
args = signature(callable_str).parameters args = signature(callable_str).parameters
if iscoroutinefunction(callable_str):
if len(args) == 1:
return await callable_str(descriptor)
elif len(args) == 2:
return await callable_str(descriptor, entity)
elif len(args) == 3:
return await callable_str(descriptor, entity, value)
else:
if len(args) == 1: if len(args) == 1:
return callable_str(descriptor) return callable_str(descriptor)
elif len(args) == 2: elif len(args) == 2:
@@ -212,3 +222,40 @@ def get_field_descriptor(
if entity_descriptor: if entity_descriptor:
return entity_descriptor.fields_descriptors.get(callback_data.field_name) return entity_descriptor.fields_descriptors.get(callback_data.field_name)
return None 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