Compare commits
27
Commits
main_old
...
02aec23b84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02aec23b84 | ||
|
|
dd56d3f312 | ||
|
|
9e9979bf31 | ||
|
|
921c6e39f5 | ||
|
|
518d7a4856 | ||
|
|
5690a4ba12 | ||
|
|
06c9467c6e | ||
|
|
6deb49c6d7 | ||
|
|
89f7e3bee2 | ||
|
|
1b0d6d4125 | ||
|
|
22ab18f648 | ||
|
|
0054a7e154 | ||
|
|
2327c49e1c | ||
|
|
748264f6c8 | ||
|
|
55e12c0475 | ||
|
|
f3fe54513c | ||
|
|
50a434dae8 | ||
|
|
76966301f6 | ||
|
|
fc49b2ee47 | ||
|
|
d59b28ad29 | ||
|
|
e413cd3ae5 | ||
|
|
0f479f7914 | ||
|
|
426ca3fad0 | ||
|
|
b7abd3f30f | ||
|
|
71ba2c052b | ||
|
|
1d162677a8 | ||
|
|
baa55d28d6 |
No files matched your search
@@ -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
|
||||
|
||||
+7
-3
@@ -1,6 +1,10 @@
|
||||
__pycache__
|
||||
/backend/.venv
|
||||
.env
|
||||
.pytest_cache
|
||||
/backend/logs
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
.venv
|
||||
.python-version
|
||||
uv.lock
|
||||
dist
|
||||
*.egg-info
|
||||
site
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM nginx
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
RUN rm -rf ./*
|
||||
COPY ./site/ ./
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
File renamed without changes.
@@ -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 |
@@ -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
@@ -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
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
@@ -10,7 +10,6 @@ from logging import getLogger
|
||||
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.setLevel("DEBUG")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
Whitespace-only changes.
@@ -6,7 +6,7 @@ from ....utils.main import get_callable_str
|
||||
from ..context import ContextData, CallbackCommand
|
||||
|
||||
|
||||
def add_filter_controls(
|
||||
async def add_filter_controls(
|
||||
keyboard_builder: InlineKeyboardBuilder,
|
||||
entity_descriptor: EntityDescriptor,
|
||||
filter: str = None,
|
||||
@@ -15,7 +15,7 @@ def add_filter_controls(
|
||||
):
|
||||
caption = ", ".join(
|
||||
[
|
||||
get_callable_str(
|
||||
await get_callable_str(
|
||||
entity_descriptor.fields_descriptors[field_name].caption,
|
||||
entity_descriptor,
|
||||
)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -17,7 +17,6 @@ class CallbackCommand(StrEnum):
|
||||
DATE_PICKER_MONTH = "dm"
|
||||
DATE_PICKER_YEAR = "dy"
|
||||
TIME_PICKER = "tp"
|
||||
# STRING_EDITOR_LOCALE = "sl"
|
||||
ENTITY_PICKER_PAGE = "ep"
|
||||
ENTITY_PICKER_TOGGLE_ITEM = "et"
|
||||
VIEW_FILTER_EDIT = "vf"
|
||||
File renamed without changes.
@@ -25,13 +25,23 @@ async def show_editor(message: Message | CallbackQuery, **kwargs):
|
||||
value_type = field_descriptor.type_base
|
||||
|
||||
if field_descriptor.edit_prompt:
|
||||
edit_prompt = get_callable_str(
|
||||
field_descriptor.edit_prompt, field_descriptor, None, current_value
|
||||
edit_prompt = await get_callable_str(
|
||||
field_descriptor.edit_prompt,
|
||||
field_descriptor,
|
||||
callback_data
|
||||
if callback_data.context == CommandContext.COMMAND_FORM
|
||||
else None,
|
||||
current_value,
|
||||
)
|
||||
else:
|
||||
if field_descriptor.caption:
|
||||
caption_str = get_callable_str(
|
||||
field_descriptor.caption, field_descriptor, None, current_value
|
||||
caption_str = await get_callable_str(
|
||||
field_descriptor.caption,
|
||||
field_descriptor,
|
||||
callback_data
|
||||
if callback_data.context == CommandContext.COMMAND_FORM
|
||||
else None,
|
||||
current_value,
|
||||
)
|
||||
else:
|
||||
caption_str = field_descriptor.name
|
||||
@@ -42,7 +52,7 @@ async def show_editor(message: Message | CallbackQuery, **kwargs):
|
||||
)
|
||||
).format(
|
||||
name=caption_str,
|
||||
value=get_value_repr(current_value, field_descriptor, user.lang),
|
||||
value=await get_value_repr(current_value, field_descriptor, user.lang),
|
||||
)
|
||||
else:
|
||||
edit_prompt = (
|
||||
@@ -56,12 +56,20 @@ async def time_picker(
|
||||
|
||||
if not current_value:
|
||||
current_value = time(0, 0)
|
||||
is_datetime = False
|
||||
else:
|
||||
is_datetime = isinstance(current_value, datetime)
|
||||
if not is_datetime:
|
||||
current_time = datetime.combine(datetime.now(), current_value)
|
||||
remainder = current_value.minute % 5
|
||||
if remainder >= 3:
|
||||
current_value += timedelta(minutes=(5 - remainder))
|
||||
current_time += timedelta(minutes=(5 - remainder))
|
||||
else:
|
||||
current_value -= timedelta(minutes=remainder)
|
||||
current_time -= timedelta(minutes=remainder)
|
||||
if is_datetime:
|
||||
current_value = datetime.combine(current_value.date(), current_time.time())
|
||||
else:
|
||||
current_value = current_time.time()
|
||||
|
||||
for i in range(12):
|
||||
keyboard_builder.row(
|
||||
@@ -209,7 +209,7 @@ async def render_entity_picker(
|
||||
type_.bot_entity_descriptor, item
|
||||
)
|
||||
if type_.bot_entity_descriptor.item_repr
|
||||
else get_callable_str(
|
||||
else await get_callable_str(
|
||||
type_.bot_entity_descriptor.full_name,
|
||||
type_.bot_entity_descriptor,
|
||||
item,
|
||||
@@ -259,7 +259,7 @@ async def render_entity_picker(
|
||||
and form_list.filtering
|
||||
and form_list.filtering_fields
|
||||
):
|
||||
add_filter_controls(
|
||||
await add_filter_controls(
|
||||
keyboard_builder=keyboard_builder,
|
||||
entity_descriptor=type_.bot_entity_descriptor,
|
||||
filter=entity_filter,
|
||||
File renamed without changes.
+15
-1
@@ -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 (
|
||||
@@ -281,6 +282,12 @@ 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))
|
||||
else:
|
||||
entity_descriptor.on_created(new_entity, EntityEventContext(db_session=db_session, app=app))
|
||||
|
||||
form_name = (
|
||||
callback_data.form_params.split("&")[0]
|
||||
if callback_data.form_params
|
||||
@@ -325,6 +332,13 @@ 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))
|
||||
else:
|
||||
entity_descriptor.on_updated(entity, EntityEventContext(db_session=db_session, app=app))
|
||||
|
||||
elif callback_data.context == CommandContext.COMMAND_FORM:
|
||||
clear_state(state_data=state_data)
|
||||
File renamed without changes.
File renamed without changes.
@@ -62,6 +62,10 @@ async def entity_item(
|
||||
|
||||
entity_item = await entity_type.get(session=db_session, id=callback_data.entity_id)
|
||||
|
||||
state: FSMContext = kwargs["state"]
|
||||
state_data = kwargs["state_data"]
|
||||
await state.set_data(state_data)
|
||||
|
||||
if not entity_item:
|
||||
return await query.answer(
|
||||
text=(await Settings.get(Settings.APP_STRINGS_NOT_FOUND))
|
||||
@@ -100,13 +104,13 @@ async def entity_item(
|
||||
]
|
||||
field_value = getattr(entity_item, field_descriptor.field_name)
|
||||
if btn_caption:
|
||||
btn_text = get_callable_str(
|
||||
btn_text = await get_callable_str(
|
||||
btn_caption, field_descriptor, entity_item, field_value
|
||||
)
|
||||
else:
|
||||
if field_descriptor.type_base is bool:
|
||||
btn_text = f"{'【✔︎】 ' if field_value else '【 】 '}{
|
||||
get_callable_str(
|
||||
await get_callable_str(
|
||||
field_descriptor.caption,
|
||||
field_descriptor,
|
||||
entity_item,
|
||||
@@ -116,18 +120,20 @@ async def entity_item(
|
||||
else field_name
|
||||
}"
|
||||
else:
|
||||
btn_text = (
|
||||
f"✏️ {
|
||||
get_callable_str(
|
||||
field_descriptor.caption,
|
||||
field_descriptor,
|
||||
entity_item,
|
||||
field_value,
|
||||
)
|
||||
}"
|
||||
btn_text = f"{
|
||||
field_descriptor.icon
|
||||
if field_descriptor.icon
|
||||
else '✏️'
|
||||
} {
|
||||
await get_callable_str(
|
||||
field_descriptor.caption,
|
||||
field_descriptor,
|
||||
entity_item,
|
||||
field_value,
|
||||
)
|
||||
if field_descriptor.caption
|
||||
else f"✏️ {field_name}"
|
||||
)
|
||||
else field_name
|
||||
}"
|
||||
btn_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=btn_text,
|
||||
@@ -144,7 +150,7 @@ async def entity_item(
|
||||
elif isinstance(button, CommandButton):
|
||||
btn_caption = button.caption
|
||||
|
||||
btn_text = get_callable_str(
|
||||
btn_text = await get_callable_str(
|
||||
btn_caption, entity_descriptor, entity_item
|
||||
)
|
||||
|
||||
@@ -215,7 +221,7 @@ async def entity_item(
|
||||
item_text = form.item_repr(entity_descriptor, entity_item)
|
||||
else:
|
||||
entity_caption = (
|
||||
get_callable_str(
|
||||
await get_callable_str(
|
||||
entity_descriptor.full_name, entity_descriptor, entity_item
|
||||
)
|
||||
if entity_descriptor.full_name
|
||||
@@ -223,7 +229,7 @@ async def entity_item(
|
||||
)
|
||||
|
||||
entity_item_repr = (
|
||||
get_callable_str(
|
||||
await get_callable_str(
|
||||
entity_descriptor.item_repr, entity_descriptor, entity_item
|
||||
)
|
||||
if entity_descriptor.item_repr
|
||||
@@ -234,18 +240,18 @@ async def entity_item(
|
||||
|
||||
for field_descriptor in entity_descriptor.fields_descriptors.values():
|
||||
if field_descriptor.is_visible:
|
||||
field_caption = get_callable_str(
|
||||
field_caption = await get_callable_str(
|
||||
field_descriptor.caption, field_descriptor, entity_item
|
||||
)
|
||||
if field_descriptor.caption_value:
|
||||
value = get_callable_str(
|
||||
value = await get_callable_str(
|
||||
field_descriptor.caption_value,
|
||||
field_descriptor,
|
||||
entity_item,
|
||||
getattr(entity_item, field_descriptor.field_name),
|
||||
)
|
||||
else:
|
||||
value = get_value_repr(
|
||||
value = await get_value_repr(
|
||||
value=getattr(entity_item, field_descriptor.field_name),
|
||||
field_descriptor=field_descriptor,
|
||||
locale=user.lang,
|
||||
@@ -261,9 +267,9 @@ async def entity_item(
|
||||
)
|
||||
)
|
||||
|
||||
state: FSMContext = kwargs["state"]
|
||||
state_data = kwargs["state_data"]
|
||||
await state.set_data(state_data)
|
||||
# state: FSMContext = kwargs["state"]
|
||||
# state_data = kwargs["state_data"]
|
||||
# await state.set_data(state_data)
|
||||
|
||||
send_message = get_send_message(query)
|
||||
|
||||
+13
-2
@@ -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,18 @@ 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))
|
||||
else:
|
||||
entity_descriptor.on_deleted(entity, EntityEventContext(db_session=db_session, app=app))
|
||||
|
||||
|
||||
await route_callback(message=query, **kwargs)
|
||||
|
||||
elif not callback_data.data:
|
||||
@@ -61,7 +72,7 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
|
||||
return await query.message.edit_text(
|
||||
text=(
|
||||
await Settings.get(Settings.APP_STRINGS_CONFIRM_DELETE_P_NAME)
|
||||
).format(name=get_entity_item_repr(entity=entity)),
|
||||
).format(name=await get_entity_item_repr(entity=entity)),
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.row(
|
||||
InlineKeyboardButton(
|
||||
@@ -197,7 +197,7 @@ async def entity_list(
|
||||
caption = entity_descriptor.item_repr(entity_descriptor, item)
|
||||
elif entity_descriptor.full_name:
|
||||
caption = f"{
|
||||
get_callable_str(
|
||||
await get_callable_str(
|
||||
callable_str=entity_descriptor.full_name,
|
||||
descriptor=entity_descriptor,
|
||||
entity=item,
|
||||
@@ -228,7 +228,7 @@ async def entity_list(
|
||||
)
|
||||
|
||||
if form_list.filtering and form_list.filtering_fields:
|
||||
add_filter_controls(
|
||||
await add_filter_controls(
|
||||
keyboard_builder=keyboard_builder,
|
||||
entity_descriptor=entity_descriptor,
|
||||
filter=entity_filter,
|
||||
@@ -245,17 +245,17 @@ async def entity_list(
|
||||
)
|
||||
|
||||
if form_list.caption:
|
||||
entity_text = get_callable_str(form_list.caption, entity_descriptor)
|
||||
entity_text = await get_callable_str(form_list.caption, entity_descriptor)
|
||||
else:
|
||||
if entity_descriptor.full_name_plural:
|
||||
entity_text = get_callable_str(
|
||||
entity_text = await get_callable_str(
|
||||
entity_descriptor.full_name_plural, entity_descriptor
|
||||
)
|
||||
else:
|
||||
entity_text = entity_descriptor.name
|
||||
|
||||
if entity_descriptor.description:
|
||||
entity_text = f"{entity_text} {get_callable_str(entity_descriptor.description, entity_descriptor)}"
|
||||
entity_text = f"{entity_text} {await get_callable_str(entity_descriptor.description, entity_descriptor)}"
|
||||
|
||||
state: FSMContext = kwargs["state"]
|
||||
state_data = kwargs["state_data"]
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -54,12 +54,12 @@ async def parameters_menu(
|
||||
continue
|
||||
|
||||
if key.caption_value:
|
||||
caption = get_callable_str(
|
||||
caption = await get_callable_str(
|
||||
callable_str=key.caption_value, descriptor=key, entity=None, value=value
|
||||
)
|
||||
else:
|
||||
if key.caption:
|
||||
caption = get_callable_str(
|
||||
caption = await get_callable_str(
|
||||
callable_str=key.caption, descriptor=key, entity=None, value=value
|
||||
)
|
||||
else:
|
||||
@@ -68,7 +68,7 @@ async def parameters_menu(
|
||||
if key.type_ is bool:
|
||||
caption = f"{'【✔︎】' if value else '【 】'} {caption}"
|
||||
else:
|
||||
caption = f"{caption}: {get_value_repr(value=value, field_descriptor=key, locale=user.lang)}"
|
||||
caption = f"{caption}: {await get_value_repr(value=value, field_descriptor=key, locale=user.lang)}"
|
||||
|
||||
keyboard_builder.row(
|
||||
InlineKeyboardButton(
|
||||
File renamed without changes.
@@ -8,6 +8,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from ...main import QBotApp
|
||||
from ...model.settings import Settings
|
||||
from ...model.language import LanguageBase
|
||||
from ...model.user import UserBase
|
||||
from ...utils.main import clear_state
|
||||
|
||||
|
||||
@@ -16,9 +17,24 @@ router = Router()
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def start(
|
||||
message: Message, db_session: AsyncSession, app: QBotApp, state: FSMContext
|
||||
):
|
||||
async def start(message: Message, **kwargs):
|
||||
app: QBotApp = kwargs["app"]
|
||||
|
||||
if app.start_handler:
|
||||
await app.start_handler(
|
||||
default_start_handler, message, **kwargs
|
||||
)
|
||||
else:
|
||||
await default_start_handler(message, **kwargs)
|
||||
|
||||
|
||||
async def default_start_handler[UserType: UserBase](
|
||||
message: Message,
|
||||
db_session: AsyncSession,
|
||||
app: QBotApp,
|
||||
state: FSMContext,
|
||||
**kwargs,
|
||||
) -> tuple[UserType, bool]:
|
||||
state_data = await state.get_data()
|
||||
clear_state(state_data=state_data, clear_nav=True)
|
||||
|
||||
@@ -27,6 +43,7 @@ async def start(
|
||||
user = await User.get(session=db_session, id=message.from_user.id)
|
||||
|
||||
if not user:
|
||||
is_new = True
|
||||
msg_text = (await Settings.get(Settings.APP_STRINGS_WELCOME_P_NAME)).format(
|
||||
name=message.from_user.full_name
|
||||
)
|
||||
@@ -61,6 +78,7 @@ async def start(
|
||||
return
|
||||
|
||||
else:
|
||||
is_new = False
|
||||
if user.is_active:
|
||||
msg_text = (
|
||||
await Settings.get(Settings.APP_STRINGS_GREETING_P_NAME)
|
||||
@@ -71,3 +89,5 @@ async def start(
|
||||
).format(name=user.name)
|
||||
|
||||
await message.answer(msg_text)
|
||||
|
||||
return user, is_new
|
||||
File renamed without changes.
@@ -31,7 +31,7 @@ class Config(BaseSettings):
|
||||
def API_DOMAIN(self) -> str:
|
||||
if self.ENVIRONMENT == "local":
|
||||
return self.DOMAIN
|
||||
return f"api.{self.DOMAIN}"
|
||||
return f"{self.DOMAIN}"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
@@ -39,7 +39,7 @@ class Config(BaseSettings):
|
||||
if self.USE_NGROK:
|
||||
return self.NGROK_URL
|
||||
return (
|
||||
f"{'http' if self.ENVIRONMENT == 'local' else 'https'}://{self.API_DOMAIN}"
|
||||
f"https://{self.API_DOMAIN}"
|
||||
)
|
||||
|
||||
API_PORT: int = 8000
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+12
-27
@@ -1,6 +1,5 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated, Callable, Any
|
||||
from typing_extensions import Doc
|
||||
from typing import Callable, Any
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.types import Message, BotCommand as AiogramBotCommand
|
||||
@@ -52,37 +51,23 @@ async def default_lifespan(app: "QBotApp"):
|
||||
logger.info("qbot app stopped")
|
||||
|
||||
|
||||
class QBotApp(FastAPI):
|
||||
class QBotApp[UserType: UserBase](FastAPI):
|
||||
"""
|
||||
Main class for the QBot application
|
||||
"""
|
||||
|
||||
def __init__[UserType: UserBase](
|
||||
def __init__(
|
||||
self,
|
||||
user_class: (
|
||||
Annotated[
|
||||
type[UserType], Doc("User class that will be used in the application")
|
||||
]
|
||||
| None
|
||||
) = None,
|
||||
user_class: UserType = None,
|
||||
config: Config | None = None,
|
||||
bot_start: (
|
||||
Annotated[
|
||||
Callable[
|
||||
[
|
||||
Annotated[
|
||||
Callable[[Message, Any], None],
|
||||
Doc("Default handler for the start command"),
|
||||
],
|
||||
Message,
|
||||
Any,
|
||||
],
|
||||
None,
|
||||
],
|
||||
Doc("Handler for the start command"),
|
||||
]
|
||||
| None
|
||||
) = None,
|
||||
bot_start: Callable[
|
||||
[
|
||||
Callable[[Message, Any], tuple[UserType, bool]],
|
||||
Message,
|
||||
Any,
|
||||
],
|
||||
None,
|
||||
] = None,
|
||||
lifespan: Lifespan[AppType] | None = None,
|
||||
lifespan_bot_init: bool = True,
|
||||
allowed_updates: list[str] | None = None,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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__()
|
||||
|
||||
File renamed without changes.
@@ -163,6 +163,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 +200,12 @@ class CommandCallbackContext[UT: UserBase]:
|
||||
kwargs: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class EntityEventContext:
|
||||
db_session: AsyncSession
|
||||
app: "QBotApp"
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BotCommand:
|
||||
name: str
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,5 @@
|
||||
from babel.support import LazyProxy
|
||||
from inspect import signature
|
||||
from inspect import iscoroutinefunction, signature
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.utils.i18n import I18n
|
||||
from typing import Any, TYPE_CHECKING
|
||||
@@ -105,7 +105,7 @@ def clear_state(state_data: dict, clear_nav: bool = False):
|
||||
state_data["navigation_context"] = context
|
||||
|
||||
|
||||
def get_entity_item_repr(
|
||||
async def get_entity_item_repr(
|
||||
entity: BotEntity, item_repr: EntityItemCaptionCallable | None = None
|
||||
) -> str:
|
||||
descr = entity.bot_entity_descriptor
|
||||
@@ -115,14 +115,14 @@ def get_entity_item_repr(
|
||||
descr.item_repr(descr, entity)
|
||||
if descr.item_repr
|
||||
else f"{
|
||||
get_callable_str(descr.full_name, descr, entity)
|
||||
await get_callable_str(descr.full_name, descr, entity)
|
||||
if descr.full_name
|
||||
else descr.name
|
||||
}: {str(entity.id)}"
|
||||
)
|
||||
|
||||
|
||||
def get_value_repr(
|
||||
async def get_value_repr(
|
||||
value: Any, field_descriptor: FieldDescriptor, locale: str | None = None
|
||||
) -> str:
|
||||
if value is None:
|
||||
@@ -133,7 +133,9 @@ def get_value_repr(
|
||||
return "【✔︎】" if value else "【 】"
|
||||
elif field_descriptor.is_list:
|
||||
if issubclass(type_, BotEntity):
|
||||
return f"[{', '.join([get_entity_item_repr(item) for item in value])}]"
|
||||
return (
|
||||
f"[{', '.join([await get_entity_item_repr(item) for item in value])}]"
|
||||
)
|
||||
elif issubclass(type_, BotEnum):
|
||||
return f"[{', '.join(item.localized(locale) for item in value)}]"
|
||||
elif type_ is str:
|
||||
@@ -141,7 +143,7 @@ def get_value_repr(
|
||||
else:
|
||||
return f"[{', '.join([str(item) for item in value])}]"
|
||||
elif issubclass(type_, BotEntity):
|
||||
return get_entity_item_repr(value)
|
||||
return await get_entity_item_repr(value)
|
||||
elif issubclass(type_, BotEnum):
|
||||
return value.localized(locale)
|
||||
elif isinstance(value, str):
|
||||
@@ -156,7 +158,7 @@ def get_value_repr(
|
||||
return str(value)
|
||||
|
||||
|
||||
def get_callable_str(
|
||||
async def get_callable_str(
|
||||
callable_str: (
|
||||
str
|
||||
| LazyProxy
|
||||
@@ -174,12 +176,20 @@ def get_callable_str(
|
||||
return callable_str.value
|
||||
elif callable(callable_str):
|
||||
args = signature(callable_str).parameters
|
||||
if len(args) == 1:
|
||||
return callable_str(descriptor)
|
||||
elif len(args) == 2:
|
||||
return callable_str(descriptor, entity)
|
||||
elif len(args) == 3:
|
||||
return callable_str(descriptor, entity, value)
|
||||
if iscoroutinefunction(callable_str):
|
||||
if len(args) == 1:
|
||||
return await callable_str(descriptor)
|
||||
elif len(args) == 2:
|
||||
return await callable_str(descriptor, entity)
|
||||
elif len(args) == 3:
|
||||
return await callable_str(descriptor, entity, value)
|
||||
else:
|
||||
if len(args) == 1:
|
||||
return callable_str(descriptor)
|
||||
elif len(args) == 2:
|
||||
return callable_str(descriptor, entity)
|
||||
elif len(args) == 3:
|
||||
return callable_str(descriptor, entity, value)
|
||||
|
||||
|
||||
def get_entity_descriptor(
|
||||
File renamed without changes.
File renamed without changes.
+141
@@ -0,0 +1,141 @@
|
||||
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(),
|
||||
)
|
||||
)
|
||||
Whitespace-only changes.
Reference in new issue
Block a user