Compare commits
29 Commits
main_old
...
fe0380f9f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0380f9f3 | ||
|
|
f0db2b2830 | ||
|
|
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 |
86
.gitea/workflows/build-deploy-docs.yml
Normal file
86
.gitea/workflows/build-deploy-docs.yml
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
name: Build Docs
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Set job outputs to values from filter step
|
||||||
|
outputs:
|
||||||
|
docs: ${{ steps.filter.outputs.docs }}
|
||||||
|
steps:
|
||||||
|
# - name: Base requirements
|
||||||
|
# run: |
|
||||||
|
# apk update && apk add --no-cache nodejs
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# For pull requests it's not necessary to checkout the code but for the main branch it is
|
||||||
|
- uses: dorny/paths-filter@v3
|
||||||
|
id: filter
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
docs:
|
||||||
|
- README.md
|
||||||
|
- docs/**
|
||||||
|
- requirements-docs.txt
|
||||||
|
- pyproject.toml
|
||||||
|
- mkdocs.yml
|
||||||
|
- Dockerfile.docs-site
|
||||||
|
- docker-compose.docs-site.yml
|
||||||
|
- .gitea/workflows/build-docs.yml
|
||||||
|
- .gitea/workflows/deploy-docs.yml
|
||||||
|
|
||||||
|
build-docs:
|
||||||
|
needs:
|
||||||
|
- changes
|
||||||
|
if: ${{ needs.changes.outputs.docs == 'true' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-22.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13.2"
|
||||||
|
- name: Setup uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.6.3"
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: |
|
||||||
|
requirements**.txt
|
||||||
|
pyproject.toml
|
||||||
|
- name: Install docs extras
|
||||||
|
run: |
|
||||||
|
uv venv
|
||||||
|
uv pip install mkdocs-material
|
||||||
|
- name: Build Docs
|
||||||
|
run: .venv/bin/mkdocs build
|
||||||
|
- name: Build Docker Image
|
||||||
|
run: |
|
||||||
|
docker build -f Dockerfile.docs-site -t ${{ vars.STACK_NAME }} .
|
||||||
|
docker tag ${{ vars.STACK_NAME }}:latest registry.botforge.biz/${{ vars.STACK_NAME }}
|
||||||
|
docker login registry.botforge.biz -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
docker push registry.botforge.biz/${{ vars.STACK_NAME }}
|
||||||
|
|
||||||
|
deploy-docs:
|
||||||
|
needs:
|
||||||
|
- build-docs
|
||||||
|
runs-on: staging
|
||||||
|
env:
|
||||||
|
STACK_NAME: ${{ vars.STACK_NAME }}
|
||||||
|
DOMAIN: ${{ vars.DOMAIN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Copy to working directory
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/${{ vars.STACK_NAME }}
|
||||||
|
cp docker-compose.docs-site.yml ~/${{ vars.STACK_NAME }}/docker-compose.yml
|
||||||
|
- name: Start Docs Site
|
||||||
|
run: |
|
||||||
|
cd ~/${{ vars.STACK_NAME }}
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
10
Dockerfile.docs-site
Normal file
10
Dockerfile.docs-site
Normal 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
21
LICENSE
Normal 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
99
README.md
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
20
docker-compose.docs-site.yml
Normal file
20
docker-compose.docs-site.yml
Normal 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
65
docs/img/qbot.svg
Normal 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
59
docs/img/qbot_1_1.svg
Normal 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
101
docs/index.md
Normal 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
14
mkdocs.yml
Normal 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
29
pyproject.toml
Normal 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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
@@ -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)
|
||||||
0
src/qbot/bot/handlers/__init__.py
Normal file
0
src/qbot/bot/handlers/__init__.py
Normal 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,
|
||||||
)
|
)
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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 = (
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
field_sequence = form.edit_field_sequence
|
if form.edit_field_sequence:
|
||||||
|
field_sequence = form.edit_field_sequence
|
||||||
|
else:
|
||||||
|
field_sequence = build_field_sequence(
|
||||||
|
entity_descriptor=entity_descriptor,
|
||||||
|
user=user,
|
||||||
|
callback_data=callback_data,
|
||||||
|
)
|
||||||
|
|
||||||
current_index = (
|
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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
field_sequence = form.edit_field_sequence
|
if form.edit_field_sequence:
|
||||||
|
field_sequence = form.edit_field_sequence
|
||||||
|
else:
|
||||||
|
field_sequence = build_field_sequence(
|
||||||
|
entity_descriptor=field_descriptor.entity_descriptor,
|
||||||
|
user=user,
|
||||||
|
callback_data=callback_data,
|
||||||
|
)
|
||||||
field_index = (
|
field_index = (
|
||||||
field_sequence.index(field_descriptor.name)
|
field_sequence.index(field_descriptor.name)
|
||||||
if callback_data.context
|
if callback_data.context
|
||||||
@@ -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
|
||||||
field_descriptor.caption,
|
else '✏️'
|
||||||
field_descriptor,
|
} {
|
||||||
entity_item,
|
await get_callable_str(
|
||||||
field_value,
|
field_descriptor.caption,
|
||||||
)
|
field_descriptor,
|
||||||
}"
|
entity_item,
|
||||||
|
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,25 +263,47 @@ async def entity_item(
|
|||||||
|
|
||||||
item_text = f"<b><u><i>{entity_caption or entity_descriptor.name}:</i></u></b> <b>{entity_item_repr}</b>"
|
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
|
||||||
field_descriptor.caption, field_descriptor, entity_item
|
and not field_descriptor.is_visible
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
skip = False
|
||||||
|
|
||||||
|
for own_field in entity_descriptor.ownership_fields.items():
|
||||||
|
if (
|
||||||
|
own_field[1].rstrip("_id")
|
||||||
|
== field_descriptor.field_name.rstrip("_id")
|
||||||
|
and own_field[0] in user.roles
|
||||||
|
and EntityPermission.READ_ALL not in user_permissions
|
||||||
|
):
|
||||||
|
skip = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
continue
|
||||||
|
|
||||||
|
field_caption = await get_callable_str(
|
||||||
|
field_descriptor.caption, field_descriptor, entity_item
|
||||||
|
)
|
||||||
|
if field_descriptor.caption_value:
|
||||||
|
value = await get_callable_str(
|
||||||
|
field_descriptor.caption_value,
|
||||||
|
field_descriptor,
|
||||||
|
entity_item,
|
||||||
|
getattr(entity_item, field_descriptor.field_name),
|
||||||
)
|
)
|
||||||
if field_descriptor.caption_value:
|
else:
|
||||||
value = get_callable_str(
|
value = await get_value_repr(
|
||||||
field_descriptor.caption_value,
|
value=getattr(entity_item, field_descriptor.field_name),
|
||||||
field_descriptor,
|
field_descriptor=field_descriptor,
|
||||||
entity_item,
|
locale=user.lang,
|
||||||
getattr(entity_item, field_descriptor.field_name),
|
)
|
||||||
)
|
item_text += f"\n{field_caption or field_descriptor.name}:{f' <b>{value}</b>' if value else ''}"
|
||||||
else:
|
|
||||||
value = get_value_repr(
|
|
||||||
value=getattr(entity_item, field_descriptor.field_name),
|
|
||||||
field_descriptor=field_descriptor,
|
|
||||||
locale=user.lang,
|
|
||||||
)
|
|
||||||
item_text += f"\n{field_caption or field_descriptor.name}:{f' <b>{value}</b>' if value else ''}"
|
|
||||||
|
|
||||||
context = pop_navigation_context(navigation_stack)
|
context = pop_navigation_context(navigation_stack)
|
||||||
if context:
|
if context:
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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(
|
||||||
@@ -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"]
|
||||||
@@ -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(
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -45,44 +46,30 @@ async def default_lifespan(app: "QBotApp"):
|
|||||||
|
|
||||||
if app.lifespan_bot_init:
|
if app.lifespan_bot_init:
|
||||||
await app.bot_close()
|
await app.bot_close()
|
||||||
|
|
||||||
if app.config.USE_NGROK:
|
if app.config.USE_NGROK:
|
||||||
app.ngrok_stop()
|
app.ngrok_stop()
|
||||||
|
|
||||||
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[
|
Callable[[Message, Any], tuple[UserType, bool]],
|
||||||
[
|
Message,
|
||||||
Annotated[
|
Any,
|
||||||
Callable[[Message, Any], None],
|
],
|
||||||
Doc("Default handler for the start command"),
|
None,
|
||||||
],
|
] = None,
|
||||||
Message,
|
|
||||||
Any,
|
|
||||||
],
|
|
||||||
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
|
||||||
@@ -165,7 +156,6 @@ class QBotApp(FastAPI):
|
|||||||
pyngrok_config=PyngrokConfig(auth_token=self.config.NGROK_AUTH_TOKEN),
|
pyngrok_config=PyngrokConfig(auth_token=self.config.NGROK_AUTH_TOKEN),
|
||||||
)
|
)
|
||||||
self.config.NGROK_URL = tunnel.public_url
|
self.config.NGROK_URL = tunnel.public_url
|
||||||
|
|
||||||
|
|
||||||
def ngrok_stop(self):
|
def ngrok_stop(self):
|
||||||
try:
|
try:
|
||||||
@@ -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()
|
||||||
@@ -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"]
|
||||||
|
|
||||||
@@ -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__()
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,12 +176,20 @@ 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 len(args) == 1:
|
if iscoroutinefunction(callable_str):
|
||||||
return callable_str(descriptor)
|
if len(args) == 1:
|
||||||
elif len(args) == 2:
|
return await callable_str(descriptor)
|
||||||
return callable_str(descriptor, entity)
|
elif len(args) == 2:
|
||||||
elif len(args) == 3:
|
return await callable_str(descriptor, entity)
|
||||||
return callable_str(descriptor, entity, value)
|
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(
|
def get_entity_descriptor(
|
||||||
@@ -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
139
tests/bot.py
Normal 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
0
tests/test_bot.py
Normal file
Reference in New Issue
Block a user