Compare commits

..

3 Commits

Author SHA1 Message Date
Alexander Kalinovsky
3208721d9e fix save state when entity not found 2025-02-13 02:37:49 +01:00
Alexander Kalinovsky
b7211368cc merge from remote 2025-02-13 02:13:22 +01:00
Alexander Kalinovsky
ca374cdea0 upd get_callable_str async 2025-02-13 02:00:20 +01:00
70 changed files with 123 additions and 2363 deletions

View File

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

10
.gitignore vendored
View File

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

View File

@@ -1 +0,0 @@
3.13

View File

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

21
LICENSE
View File

@@ -1,21 +0,0 @@
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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ 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
@@ -21,7 +20,6 @@ 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()
@@ -72,7 +70,6 @@ async def bool_editor(
field_descriptor=field_descriptor, field_descriptor=field_descriptor,
callback_data=callback_data, callback_data=callback_data,
state_data=state_data, state_data=state_data,
user=user,
) )
state: FSMContext = kwargs["state"] state: FSMContext = kwargs["state"]

View File

@@ -8,7 +8,6 @@ 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
@@ -50,7 +49,6 @@ 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,
): ):
@@ -164,7 +162,6 @@ 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)
@@ -182,7 +179,6 @@ 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,
): ):
@@ -277,7 +273,6 @@ 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)
@@ -297,7 +292,6 @@ 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")
@@ -371,7 +365,6 @@ async def date_picker_year(
field_descriptor=field_descriptor, field_descriptor=field_descriptor,
callback_data=callback_data, callback_data=callback_data,
state_data=state_data, state_data=state_data,
user=user,
) )
await query.message.edit_reply_markup(reply_markup=keyboard_builder.as_markup()) await query.message.edit_reply_markup(reply_markup=keyboard_builder.as_markup())

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
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
@@ -13,7 +12,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 EntityEventContext, FieldDescriptor from ....model.descriptors import 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 (
@@ -22,7 +21,6 @@ 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
@@ -169,15 +167,7 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
form_name, entity_descriptor.default_form form_name, entity_descriptor.default_form
) )
if form.edit_field_sequence: field_sequence = form.edit_field_sequence
field_sequence = form.edit_field_sequence
else:
field_sequence = build_field_sequence(
entity_descriptor=entity_descriptor,
user=user,
callback_data=callback_data,
)
current_index = ( current_index = (
field_sequence.index(callback_data.field_name) field_sequence.index(callback_data.field_name)
if callback_data.context if callback_data.context
@@ -261,18 +251,9 @@ 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
if callback_data.context in [ # for role in user.roles:
CommandContext.ENTITY_CREATE, # if role in entity_descriptor.ownership_fields and not EntityPermission.CREATE_ALL in user_permissions:
CommandContext.ENTITY_EDIT, # entity_data[entity_descriptor.ownership_fields[role]] = user.id
]:
user_permissions = get_user_permissions(user, entity_descriptor)
for role in user.roles:
if (
role in entity_descriptor.ownership_fields
and EntityPermission.CREATE_ALL not in user_permissions
):
entity_data[entity_descriptor.ownership_fields[role]] = user.id
deser_entity_data = { deser_entity_data = {
key: await deserialize( key: await deserialize(
@@ -300,22 +281,6 @@ 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
@@ -360,23 +325,6 @@ async def process_field_edit_callback(message: Message | CallbackQuery, **kwargs
setattr(entity, key, value) setattr(entity, key, value)
await db_session.commit() await db_session.commit()
await db_session.refresh(entity)
if entity_descriptor.on_updated:
if iscoroutinefunction(entity_descriptor.on_updated):
await entity_descriptor.on_updated(
entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
)
else:
entity_descriptor.on_updated(
entity,
EntityEventContext(
db_session=db_session, app=app, message=message
),
)
elif callback_data.context == CommandContext.COMMAND_FORM: elif callback_data.context == CommandContext.COMMAND_FORM:
clear_state(state_data=state_data) clear_state(state_data=state_data)

View File

@@ -8,7 +8,6 @@ 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
@@ -26,7 +25,6 @@ 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,
): ):
@@ -92,7 +90,6 @@ async def string_editor(
field_descriptor=field_descriptor, field_descriptor=field_descriptor,
callback_data=callback_data, callback_data=callback_data,
state_data=state_data, state_data=state_data,
user=user,
) )
await state.set_data(state_data) await state.set_data(state_data)

View File

@@ -3,10 +3,8 @@ 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(
@@ -14,7 +12,6 @@ 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,
@@ -39,14 +36,7 @@ async def wrap_editor(
form = field_descriptor.entity_descriptor.forms.get( form = field_descriptor.entity_descriptor.forms.get(
form_name, field_descriptor.entity_descriptor.default_form form_name, field_descriptor.entity_descriptor.default_form
) )
if form.edit_field_sequence: field_sequence = form.edit_field_sequence
field_sequence = form.edit_field_sequence
else:
field_sequence = build_field_sequence(
entity_descriptor=field_descriptor.entity_descriptor,
user=user,
callback_data=callback_data,
)
field_index = ( field_index = (
field_sequence.index(field_descriptor.name) field_sequence.index(field_descriptor.name)
if callback_data.context if callback_data.context

View File

@@ -1,4 +1,3 @@
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
@@ -7,12 +6,7 @@ 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 ( from ....model.descriptors import FieldEditButton, CommandButton, InlineButton
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
@@ -23,8 +17,6 @@ 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 (
@@ -97,11 +89,10 @@ 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, context): if button.visibility and not button.visibility(entity_item):
continue continue
if isinstance(button, FieldEditButton) and can_edit: if isinstance(button, FieldEditButton) and can_edit:
@@ -166,10 +157,7 @@ async def 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):
if iscoroutinefunction(button.command): btn_cdata = button.command(callback_data, entity_item)
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,
@@ -187,26 +175,13 @@ 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):
if iscoroutinefunction(button.inline_button): btn_row.append(button.inline_button(entity_item))
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)),
@@ -216,7 +191,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=field_sequence[0], field_name=form.edit_field_sequence[0],
).pack(), ).pack(),
) )
) )
@@ -263,47 +238,25 @@ 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 ( if field_descriptor.is_visible:
field_descriptor.is_visible is not None field_caption = await get_callable_str(
and not field_descriptor.is_visible field_descriptor.caption, field_descriptor, entity_item
):
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),
) )
else: if field_descriptor.caption_value:
value = await get_value_repr( value = await get_callable_str(
value=getattr(entity_item, field_descriptor.field_name), field_descriptor.caption_value,
field_descriptor=field_descriptor, field_descriptor,
locale=user.lang, entity_item,
) 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 = await get_value_repr(
value=getattr(entity_item, field_descriptor.field_name),
field_descriptor=field_descriptor,
locale=user.lang,
)
item_text += f"\n{field_caption or field_descriptor.name}:{f' <b>{value}</b>' if value else ''}"
context = pop_navigation_context(navigation_stack) context = pop_navigation_context(navigation_stack)
if context: if context:

View File

@@ -1,4 +1,3 @@
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
@@ -6,8 +5,6 @@ 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 quickbot.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
@@ -50,22 +47,10 @@ async def entity_delete_callback(query: CallbackQuery, **kwargs):
) )
if callback_data.data == "yes": if callback_data.data == "yes":
entity = await entity_descriptor.type_.remove( 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:

View File

@@ -18,7 +18,6 @@ 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
@@ -117,14 +116,6 @@ 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)),
@@ -132,7 +123,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=field_sequence[0], field_name=form_item.edit_field_sequence[0],
form_params=form_list.item_form, form_params=form_list.item_form,
).pack(), ).pack(),
) )

View File

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

View File

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

View File

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

View File

@@ -24,31 +24,26 @@ 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_DOMAIN: str = "localhost"
TELEGRAM_WEBHOOK_SHEME: str = "https"
TELEGRAM_WEBHOOK_PORT: int = 443
@property
def TELEGRAM_WEBHOOK_URL(self) -> str:
return f"{self.TELEGRAM_WEBHOOK_SHEME}://{self.TELEGRAM_WEBHOOK_DOMAIN}{
f':{self.TELEGRAM_WEBHOOK_PORT}'
if (
(
self.TELEGRAM_WEBHOOK_PORT != 80
and self.TELEGRAM_WEBHOOK_SHEME == 'http'
)
or (
self.TELEGRAM_WEBHOOK_PORT != 443
and self.TELEGRAM_WEBHOOK_SHEME == 'https'
)
)
else ''
}"
TELEGRAM_BOT_SERVER: str = "https://api.telegram.org"
TELEGRAM_BOT_SERVER_IS_LOCAL: bool = False
TELEGRAM_BOT_TOKEN: str = "changethis" TELEGRAM_BOT_TOKEN: str = "changethis"
ADMIN_TELEGRAM_ID: int ADMIN_TELEGRAM_ID: int

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,59 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 5.1 KiB

View File

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

View File

@@ -1,8 +1,6 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Callable, Any from typing import Callable, Any
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
@@ -23,12 +21,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()
@@ -37,8 +35,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) as state: async with app.lifespan(app):
yield state yield
else: else:
yield yield
@@ -46,10 +44,10 @@ 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")
@@ -90,14 +88,8 @@ class QBotApp[UserType: UserBase](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"),
) )
@@ -132,16 +124,18 @@ class QBotApp[UserType: UserBase](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="/telegram", tags=["telegram"]) self.include_router(telegram_router, prefix="/api/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
@@ -156,6 +150,7 @@ class QBotApp[UserType: UserBase](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:
@@ -168,7 +163,10 @@ class QBotApp[UserType: UserBase](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():
@@ -195,13 +193,13 @@ class QBotApp[UserType: UserBase](FastAPI):
) )
await self.bot.set_webhook( await self.bot.set_webhook(
url=f"{self.config.TELEGRAM_WEBHOOK_URL}/telegram/webhook", url=f"{self.config.API_URL}/api/telegram/webhook",
drop_pending_updates=True, drop_pending_updates=True,
allowed_updates=self.allowed_updates, allowed_updates=self.allowed_updates,
secret_token=self.bot_auth_token, secret_token=self.bot_auth_token,
) )
async def bot_close(self): async def bot_close(self):
await self.bot.delete_webhook() await self.bot.delete_webhook()
await self.bot.log_out()
await self.bot.close()

View File

@@ -1,14 +0,0 @@
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

View File

@@ -12,7 +12,6 @@ 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
@@ -65,33 +64,7 @@ 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) # type: FieldInfo sm_descriptor = descriptor_kwargs.pop("sm_descriptor", None)
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
@@ -184,6 +157,23 @@ class BotEntityMetaclass(SQLModelMetaclass):
fields_descriptors=bot_fields_descriptors, fields_descriptors=bot_fields_descriptors,
) )
descriptor_fields_sequence = [
key
for key, val in bot_fields_descriptors.items()
if not (val.is_optional or val.name == "id")
]
entity_descriptor: EntityDescriptor = namespace["bot_entity_descriptor"]
if entity_descriptor.default_form.edit_field_sequence is None:
entity_descriptor.default_form.edit_field_sequence = (
descriptor_fields_sequence
)
for form in entity_descriptor.forms.values():
if form.edit_field_sequence is None:
form.edit_field_sequence = descriptor_fields_sequence
for field_descriptor in bot_fields_descriptors.values(): for field_descriptor in bot_fields_descriptors.values():
field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"] field_descriptor.entity_descriptor = namespace["bot_entity_descriptor"]

View File

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

View File

@@ -93,7 +93,7 @@ class _BaseFieldDescriptor:
description: str | LazyProxy | EntityFieldCaptionCallable | None = None description: str | LazyProxy | EntityFieldCaptionCallable | None = None
edit_prompt: str | LazyProxy | EntityFieldCaptionCallable | None = None edit_prompt: str | LazyProxy | EntityFieldCaptionCallable | None = None
caption_value: EntityFieldCaptionCallable | None = None caption_value: EntityFieldCaptionCallable | None = None
is_visible: bool | None = None is_visible: bool = True
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,7 +102,6 @@ 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)
@@ -164,9 +163,6 @@ 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)
@@ -201,13 +197,6 @@ class CommandCallbackContext[UT: UserBase]:
kwargs: dict[str, Any] = field(default_factory=dict) kwargs: dict[str, Any] = field(default_factory=dict)
@dataclass(kw_only=True)
class EntityEventContext:
db_session: AsyncSession
app: "QBotApp"
message: Message | CallbackQuery | None = None
@dataclass(kw_only=True) @dataclass(kw_only=True)
class BotCommand: class BotCommand:
name: str name: str

View File

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

View File

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

View File

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

View File

View File

@@ -222,40 +222,3 @@ 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

1366
uv.lock generated

File diff suppressed because it is too large Load Diff