Сегодня мы создадим полноценное веб-приложение на FastAPI, которое будет взаимодействовать с Telegram-ботом через WebApp и вебхуки. В основе проекта — асинхронное взаимодействие с базой данных SQLite с помощью SQLAlchemy, что позволит нам реализовать масштабируемое и эффективное приложение.

Проект: Telegram-бот с WebApp на FastAPI

Мы разработаем демонстрационный Telegram-бот для парикмахерской с использованием Aiogram версии 3.13.1 в качестве основного фреймворка для работы с ботом.

С помощью WebApp бот будет принимать заявки от клиентов, а FastAPI выступит в роли веб-сервера. Кроме того, мы подключим асинхронный ORM SQLAlchemy для работы с базой данных SQLite и предложим полноценный веб-интерфейс как для клиентов, так и для администраторов — и все это в одном проекте на базе FastAPI!

Стек технологий:

  • Backend: FastAPI

  • Telegram API: Aiogram 3 + технология вебхуков

  • ORM: SQLAlchemy (aiosqlite)

  • База данных: SQLite

  • Миграции: Alembic

Основные компоненты проекта:

  • Telegram WebApp: Прием и обработка заявок.

  • Веб-интерфейс: Страницы для оформления и просмотра заявок, доступные клиентам и администраторам.

  • Telegram-бот: Поддержка Web App в командном меню, инлайн-кнопках и текстовой клавиатуре.

  • База данных: Хранение информации о заявках, пользователях, мастерах и услугах.

Что будет реализовано:

  • Прием заявок через Telegram WebApp.

  • Просмотр заявок:

    • Клиенты смогут видеть свои заявки в личном кабинете.

    • Администраторы видят все заявки через веб-интерфейс.

  • Асинхронная работа с базой данных через aiosqlite и SQLAlchemy.

  • Миграции базы данных с использованием Alembic.

План работы:

Мы пошагово разберем, как создать Telegram-бота на FastAPI для обработки заявок парикмахерской. Настроим веб-сервер, создадим необходимые страницы, подключим асинхронную базу данных и реализуем функционал для клиентов и администраторов. Кроме того, разберем настройку вебхуков для взаимодействия с Telegram и организацию асинхронной обработки запросов.

После завершения разработки мы проведем деплой приложения. Для этого я выбрал сервис Amvera Cloud, который предлагает ряд преимуществ для разработчиков. Он предоставляет бесплатное доменное имя с поддержкой HTTPS, что гарантирует безопасность ваших пользователей. Благодаря простоте настройки, вы сможете развернуть проект всего за несколько минут. Достаточно загрузить файлы приложения на сервер, используя заранее сгенерированный файл настроек.

Amvera Cloud также поддерживает интеграцию с Git, что облегчает процесс обновления вашего приложения. Вы можете переносить проект напрямую или через Git, что делает управление версиями и развертывание более удобным и эффективным. Сервис позволяет вам не отвлекаться на технические детали настройки хостинга, что дает возможность сосредоточиться на разработке и улучшении вашего бота.

В дополнение к этому, Amvera предлагает бесплатный баланс при регистрации, что позволяет начать без вложений. Если вы не будете активно использовать сервис, деньги не исчезнут — вы оплачиваете только за работающие приложения. Это делает Amvera идеальным выбором как для новичков, так и для опытных разработчиков, стремящихся быстро и удобно развернуть свои проекты.

Приступаем к подготовке

Для разработки нам понадобится Python, а также базовые знания работы с этим языком. Желательно также иметь опыт разработки Telegram-ботов с помощью Aiogram 3 и создания веб-приложений на FastAPI. Если вы с этими темами еще не знакомы, советую ознакомиться с моими публикациями на Хабре, где я посвятил этим вопросам более 20 объемных статей, начиная с самых основ.

В этой статье мы будем использовать асинхронную SQLAlchemy для работы с базой данных SQLite, так что знание этих инструментов тоже будет очень полезным. Поскольку многие аспекты я уже подробно описывал в предыдущих материалах, здесь я буду давать лишь краткие комментарии к коду и ссылаться на те статьи, где рассматриваемые темы раскрыты более детально.

Доступ к глобальной сети для разработки

Поскольку наш Telegram-бот будет использовать WebApp и вебхуки, на этапе разработки приложению потребуется доступ в глобальную сеть. Для этого нам пригодится один из сервисов для создания туннелей. В качестве примера я буду использовать Ngrok на Windows, но вы можете выбрать любой другой подходящий сервис, например, LocalTunnel, Xtunnel, Tuna и т. д.

Принцип работы таков: мы запускаем FastAPI-приложение на определенном порту (например, 5050), а затем создаем туннель к этому порту. В результате получаем временный домен с поддержкой HTTPS, который и будем использовать в качестве основного домена нашего приложения.

Настройка туннеля с помощью Ngrok

  1. Зарегистрируйтесь на сайте Ngrok и выполните вход.

  2. Скачайте утилиту для вашей операционной системы.

  3. Откройте загруженный файл и введите команду:

ngrok config add-authtoken your_token
  1. Далее запустите туннель на нужный порт:

ngrok http PORT

Например:

ngrok http 5050
  1. Если все выполнено корректно, вы увидите окно с временным доменом, который можно будет использовать для разработки. Скопируйте эту ссылку — она нам пригодится на следующих этапах.

Копируем эту ссылку
Копируем эту ссылку

Создаем Telegram-бота

Теперь создадим самого бота в Telegram и привяжем к нему полученную от Ngrok ссылку как веб-приложение:

  • Откройте BotFather в Telegram и введите команду /new_bot.

  • Придумайте имя бота (на этом этапе можно использовать русский язык).

  • Укажите логин для бота — только латинские буквы без пробелов и специальных символов, в конце обязательно добавьте BOT, bot или Bot.

Сохраните токен бота в надежном месте.
Сохраните токен бота в надежном месте.
  • Активируйте MiniApp. Для этого перейдите в список ботов, выберите созданного бота и откройте блок SETTINGS. Найдите раздел «Configure MiniApp» и нажмите «Enable Mini App».

  • Введите ссылку, полученную от Ngrok.

  • (По желанию) Привяжите MiniApp к командному меню. Вернитесь в настройки бота и выберите опцию «Menu Button». Укажите текст на кнопке и прикрепите ссылку на MiniApp, чтобы пользователь мог быстро перейти на главную страницу вашего приложения.

  • Обязательно перейдите в бота и нажмите «Запустить». Это нужно, чтобы бот мог отправлять вам сообщения, а также чтобы вы могли настроить админку.

Не обращаем на это внимание. Скоро все поправим
Не обращаем на это внимание. Скоро все поправим

Теперь у вас есть готовый бот, настроенный туннель и временный домен. Можно приступать к написанию кода нашего приложения!

Подготовка проекта

Для того чтоб вам было дальше следовать моей логике написания кода, предлагаю сразу создать структуру проекта, которую мы будем постепенно заполнять кодом и файлами.

Создаем проект в IDE в котором вы будете писать код. Я неизменно выбираю Pycharm.

В папке проекта создадим новую дирректорию app и в корне самого проекта создадим файл .env в котором будем хранить критически важные переменные, как токен бота и файл requirements.txt в котором опишем все необходимые нам библиотеки для написания кода.

Получиться должно так.

Это стандартная структура FastApi приложений, которую я использую в каждой своей статье посвященной данной теме.

Заполним файл .env. Ниже пример:

BOT_TOKEN=bot_token 
BASE_SITE=https://7016-80-85-143-232.ngrok-free.app
ADMIN_ID=your_tg_id

Свой телеграмм ID можно получить в этом боте: GetID BOT.

Заполним файл requirements.txt

aiogram==3.13.1
fastapi==0.115.0
pydantic==2.9.2
sqlalchemy==2.0.35
uvicorn==0.31.0
jinja2==3.1.4
pydantic_settings==2.5.2
alembic==1.13.3
aiosqlite==0.20.0

Описание библиотек:

Скрытый текст
  • aiogram==3.13.1 УстановимУстановимУстановимУстановим– Асинхронная библиотека для создания Telegram-ботов на Python. Мы будем использовать ее для работы с Telegram API и внедрения функций, необходимых для MiniApp.

  • fastapi==0.115.0 – Современный, быстрый веб-фреймворк для создания API на Python. Используется для разработки веб-приложения и реализации серверной части нашего проекта.

  • pydantic==2.9.2 – Библиотека для работы с валидацией и управлением данными. Используется в FastAPI для определения схем данных и проверки входных данных.

  • sqlalchemy==2.0.35 – Популярный ORM (Object-Relational Mapping) для Python. Мы будем использовать его для работы с базой данных SQLite в асинхронном режиме.

  • uvicorn==0.31.0 – Высокопроизводительный ASGI-сервер для запуска нашего FastAPI-приложения. Он позволяет обрабатывать асинхронные запросы и обеспечивает эффективную работу приложения.

  • jinja2==3.1.4 – Шаблонизатор, который используется для рендеринга HTML-страниц. Будет применяться для создания веб-интерфейса нашего приложения.

  • pydantic_settings==2.5.2 – Расширение для Pydantic, которое облегчает работу с настройками и конфигурационными файлами приложения.

  • alembic==1.13.3 – Инструмент для управления миграциями базы данных. Позволяет автоматически отслеживать изменения в моделях и обновлять схему базы данных.

  • aiosqlite==0.20.0 – Асинхронный драйвер для работы с SQLite. Используется в связке с SQLAlchemy для обеспечения асинхронного взаимодействия с базой данных.

Установим

pip install -r requirements.txt

Блок работы с базой данных

Многие аспекты, которые мы сейчас будем обсуждать, уже освещены в моих предыдущих статьях. Если пояснений к коду окажется недостаточно, вы всегда можете обратиться к этим публикациям для более детального изучения:

Настройка базы данных

В корневой папке проекта (app) создадим файл database.py, в котором пропишем основные настройки для взаимодействия с базой данных SQLite. Здесь мы настроим асинхронное подключение к базе с использованием SQLAlchemy.

from sqlalchemy import func
from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase
from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine, AsyncSession

database_url = 'sqlite+aiosqlite:///db.sqlite3'
engine = create_async_engine(url=database_url)
async_session_maker = async_sessionmaker(engine, class_=AsyncSession)


class Base(AsyncAttrs, DeclarativeBase):
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())

Этот код создает асинхронное подключение к базе данных с помощью SQLite и SQLAlchemy. Класс Base будет использоваться для создания моделей таблиц, которые автоматически добавляют поля created_at и updated_at для отслеживания времени создания и обновления записей. При работе с базой данных мы будем использовать async_session_maker для создания сессий.

Описание моделей таблиц

Таблицы в SQLAlchemy представлены моделями (классами), которые описывают структуру таблицы и её столбцы. Затем с этими колонками можно работать как с объектами. Для этого создадим папку api в папке app и в ней файл models.py. Ниже приведен код, описывающий наши модели:

Скрытый текст
from sqlalchemy import String, BigInteger, Integer, Date, Time, ForeignKey, Enum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
import enum


class User(Base):
    __tablename__ = 'users'

    telegram_id: Mapped[int] = mapped_column(BigInteger,
                                             primary_key=True)  # Уникальный идентификатор пользователя в Telegram
    first_name: Mapped[str] = mapped_column(String, nullable=False)  # Имя пользователя
    username: Mapped[str] = mapped_column(String, nullable=True)  # Telegram username

    # Связь с заявками (один пользователь может иметь несколько заявок)
    applications: Mapped[list["Application"]] = relationship(back_populates="user")


class Master(Base):
    __tablename__ = 'masters'

    master_id: Mapped[int] = mapped_column(Integer, primary_key=True,
                                           autoincrement=True)  # Уникальный идентификатор мастера
    master_name: Mapped[str] = mapped_column(String, nullable=False)  # Имя мастера

    # Связь с заявками (один мастер может иметь несколько заявок)
    applications: Mapped[list["Application"]] = relationship(back_populates="master")


class Service(Base):
    __tablename__ = 'services'

    service_id: Mapped[int] = mapped_column(Integer, primary_key=True,
                                            autoincrement=True)  # Уникальный идентификатор услуги
    service_name: Mapped[str] = mapped_column(String, nullable=False)  # Название услуги

    # Связь с заявками (одна услуга может быть частью нескольких заявок)
    applications: Mapped[list["Application"]] = relationship(back_populates="service")


class Application(Base):
    __tablename__ = 'applications'

    class GenderEnum(enum.Enum):
        male = "Мужской"
        female = "Женский"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)  # Уникальный идентификатор заявки
    user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('users.telegram_id'))  # Внешний ключ на пользователя
    master_id: Mapped[int] = mapped_column(Integer, ForeignKey('masters.master_id'))  # Внешний ключ на мастера
    service_id: Mapped[int] = mapped_column(Integer, ForeignKey('services.service_id'))  # Внешний ключ на услугу
    appointment_date: Mapped[Date] = mapped_column(Date, nullable=False)  # Дата заявки
    appointment_time: Mapped[Time] = mapped_column(Time, nullable=False)  # Время заявки
    gender: Mapped[GenderEnum] = mapped_column(Enum(GenderEnum), nullable=False)
    client_name: Mapped[str] = mapped_column(String, nullable=False)  # Имя пользователя
    # Связи с пользователем, мастером и услугой
    user: Mapped["User"] = relationship(back_populates="applications")
    master: Mapped["Master"] = relationship(back_populates="applications")
    service: Mapped["Service"] = relationship(back_populates="applications")

Пояснение к моделям

  • User: Таблица users хранит данные о пользователях Telegram, включая их уникальный идентификатор (telegram_id), имя (first_name) и, при наличии, имя пользователя (username). Связана с таблицей заявок (applications) — один пользователь может создавать множество заявок.

  • Master: Таблица masters хранит информацию о мастерах, указывая их уникальный идентификатор (master_id) и имя (master_name). Также связана с таблицей заявок — один мастер может обрабатывать несколько заявок.

  • Service: Таблица services содержит информацию об услугах, включая их уникальный идентификатор (service_id) и название (service_name). Связана с заявками — одна услуга может быть указана во многих заявках.

  • Application: Таблица applications — это основная таблица для хранения информации о заявках. В ней есть поля для идентификации пользователя, мастера и услуги, а также время и дату заявки. Здесь также введено поле gender для указания пола клиента, которое реализовано с помощью перечисления (Enum). С помощью relationship устанавливаются связи с таблицами User, Master и Service.

Теперь у нас есть базовые настройки для работы с асинхронной базой данных SQLite и описаны модели для хранения пользователей, мастеров, услуг и заявок. Все модели наследуются от базового класса Base, который автоматически добавляет временные метки для отслеживания изменений записей.

Модели созданы, но они пока не стали таблицами. Исправим это.

Настройка Alembic и первая миграция

Теперь настроим Alembic для управления миграциями базы данных и создадим свою первую миграцию. Это позволит нам отслеживать изменения в моделях и автоматически обновлять структуру базы данных по мере развития проекта.

Инициализация Alembic

Сначала перейдите в папку app через терминал:

cd app

Теперь инициализируем Alembic с поддержкой асинхронного взаимодействия с базой данных:

alembic init -t async migration

После выполнения этой команды в папке app появятся новая папка migration и файл alembic.ini. Теперь перенесем файл alembic.ini на уровень выше, в корень проекта, чтобы он располагался рядом с папкой app.

Настройка файла alembic.ini

В файле alembic.ini заменим строку:

script_location = migration

на

script_location = app/migration

Это изменение сделано для удобства, так как все последующие команды миграций и для запуска FastApi проекта мы будем вводить из корневой папки проекта, а не из папки app.

Настройка migration/env.py для работы с базой данных

Теперь нам нужно внести изменения в файл app/migration/env.py, чтобы Alembic мог корректно работать с нашей базой данных. Откройте файл и замените его содержимое следующим образом:

Было

import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context


config = context.config


if config.config_file_name is not None:
    fileConfig(config.config_file_name)


target_metadata = None

Стало

import sys
from os.path import dirname, abspath

sys.path.insert(0, dirname(dirname(abspath(__file__))))

import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.database import Base, database_url
from app.api.models import User, Master, Service, Application

config = context.config
config.set_main_option("sqlalchemy.url", database_url)
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

target_metadata = Base.metadata

Остальную часть кода файла env.py оставляем без изменений.

Ключевое изменение — добавление корневой директории проекта в системный путь. Это необходимо, чтобы модули импортировались корректно, что позволит запускать проект и выполнять миграции базы данных непосредственно из корня проекта.

Затем мы импортировали наш базовый класс, URL подключения к базе данных и установили метаданные. Это завершает подготовку Alembic для работы с миграциями. Теперь, если в будущем добавятся новые модели таблиц, их достаточно будет импортировать в этот файл, а остальную часть оставляем без изменений.

Создание первой миграции

Теперь мы готовы создать первую миграцию и сгенерировать базу данных с необходимыми таблицами.

Для этого в терминале возвращаемся в корневую директорию проекта:

cd ../

Сгенерируем файл миграций с помощью команды:

alembic revision --autogenerate -m "Initial revision"

На данном этапе мы только создали файл миграций. Теперь применим миграции, чтобы создать таблицы в базе данных:

alembic upgrade head

После выполнения этой команды в корне проекта появится файл db.sqlite3, содержащий наши таблицы: users, masters, services, applications.

Теперь, чтоб не перегружать этот учебный проект кодом и лишним функционалом заполним таблицу с мастерами и услугами данными. В последствии эта информация будет использована для списка выбора услуг / мастеров.

Таблица мастеров

Таблица услуг

Методы для работы с базой данных

Как и ранее, мой подход к этому вопросу в контексте FastApi подразумевает создание некоего базового класса с универсальными методами для взаимодействия с базой данных через алхимию. Далее, идет наследование от этого базового класса и добавление индивидуальных методов под таблицу если требуется.

В папке app создадим папку dao, а внутри нее файл base.py. Заполним этот файл.

Скрытый текст
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.future import select
from sqlalchemy import update as sqlalchemy_update, delete as sqlalchemy_delete
from app.database import async_session_maker


class BaseDAO:
    model = None

    @classmethod
    async def find_one_or_none_by_id(cls, data_id: int):
        """
        Асинхронно находит и возвращает один экземпляр модели по указанным критериям или None.

        Аргументы:
            data_id: Критерии фильтрации в виде идентификатора записи.

        Возвращает:
            Экземпляр модели или None, если ничего не найдено.
        """
        async with async_session_maker() as session:
            query = select(cls.model).filter_by(id=data_id)
            result = await session.execute(query)
            return result.scalar_one_or_none()

    @classmethod
    async def find_one_or_none(cls, **filter_by):
        """
        Асинхронно находит и возвращает один экземпляр модели по указанным критериям или None.

        Аргументы:
            **filter_by: Критерии фильтрации в виде именованных параметров.

        Возвращает:
            Экземпляр модели или None, если ничего не найдено.
        """
        async with async_session_maker() as session:
            query = select(cls.model).filter_by(**filter_by)
            result = await session.execute(query)
            return result.scalar_one_or_none()

    @classmethod
    async def find_all(cls, **filter_by):
        """
        Асинхронно находит и возвращает все экземпляры модели, удовлетворяющие указанным критериям.

        Аргументы:
            **filter_by: Критерии фильтрации в виде именованных параметров.

        Возвращает:
            Список экземпляров модели.
        """
        async with async_session_maker() as session:
            query = select(cls.model).filter_by(**filter_by)
            result = await session.execute(query)
            return result.scalars().all()

    @classmethod
    async def add(cls, **values):
        """
        Асинхронно создает новый экземпляр модели с указанными значениями.

        Аргументы:
            **values: Именованные параметры для создания нового экземпляра модели.

        Возвращает:
            Созданный экземпляр модели.
        """
        async with async_session_maker() as session:
            async with session.begin():
                new_instance = cls.model(**values)
                session.add(new_instance)
                try:
                    await session.commit()
                except SQLAlchemyError as e:
                    await session.rollback()
                    raise e
                return new_instance

  • find_one_or_none_by_id(cls, data_id: int)

    • Асинхронно ищет запись в базе данных по уникальному идентификатору (id). Возвращает экземпляр модели, либо None, если запись не найдена.

  • find_one_or_none(cls, **filter_by)

    • Асинхронно находит и возвращает одну запись, соответствующую указанным критериям (например, name="John"). Если записи нет, возвращает None.

  • find_all(cls, **filter_by)

    • Асинхронно возвращает все записи, удовлетворяющие заданным критериям. Полезен для получения списка экземпляров модели с возможностью фильтрации.

  • add(cls, **values)

    • Асинхронно создает новую запись в базе данных, используя переданные параметры. Добавляет запись, фиксирует изменения, а в случае ошибки откатывает транзакцию. Возвращает созданный экземпляр модели.

Эти методы обеспечивают стандартный набор операций для работы с базой данных и делают код для конкретных таблиц лаконичнее и переиспользуемым. В полном исходном коде вы найдете более расширенную версию этого базового класса.

Создание индивидуальных классов для работы с таблицами

Опишем индивидуальные классы для взаимодействия с таблицами в базе данных. Классы, которые не требуют дополнительных методов, просто наследуются от базового класса BaseDAO, что позволяет легко использовать их с минимальным кодом. Разместим их в файле dao.py в папке api.

from sqlalchemy.future import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import joinedload
from app.dao.base import BaseDAO
from app.api.models import User, Service, Application, Master
from app.database import async_session_maker

class UserDAO(BaseDAO):
    model = User

class ServiceDAO(BaseDAO):
    model = Service

class MasterDAO(BaseDAO):
    model = Master

Эти классы унаследованы от BaseDAO и используют универсальные методы базового класса для работы с данными. Например, чтобы получить всех пользователей, теперь достаточно одной строки:

all_users = await UserDAO.find_all()

Это делает код более чистым и удобным в использовании, избавляя от необходимости передавать модель в каждый метод.

Индивидуальный класс для заявок с дополнительными методами

В отличие от других моделей, работа с заявками (Application) требует расширенных методов для более удобного доступа к связанным данным, таких как информация о мастере, услуге и клиенте. В классе ApplicationDAO добавлены два дополнительных метода: для получения заявок конкретного пользователя и для получения всех заявок.

Скрытый текст
class ApplicationDAO(BaseDAO):
    model = Application

    @classmethod
    async def get_applications_by_user(cls, user_id: int):
        """
        Возвращает все заявки пользователя по user_id с дополнительной информацией
        о мастере и услуге.

        Аргументы:
            user_id: Идентификатор пользователя.

        Возвращает:
            Список заявок пользователя с именами мастеров и услуг.
        """
        async with async_session_maker() as session:
            try:
                # Используем joinedload для ленивой загрузки связанных объектов
                query = (
                    select(cls.model)
                    .options(joinedload(cls.model.master), joinedload(cls.model.service))
                    .filter_by(user_id=user_id)
                )
                result = await session.execute(query)
                applications = result.scalars().all()

                # Возвращаем список словарей с нужными полями
                return [
                    {
                        "application_id": app.id,
                        "service_name": app.service.service_name,  # Название услуги
                        "master_name": app.master.master_name,  # Имя мастера
                        "appointment_date": app.appointment_date,
                        "appointment_time": app.appointment_time,
                        "gender": app.gender.value,
                    }
                    for app in applications
                ]
            except SQLAlchemyError as e:
                print(f"Error while fetching applications for user {user_id}: {e}")
                return None

    @classmethod
    async def get_all_applications(cls):
        """
        Возвращает все заявки в базе данных с дополнительной информацией о мастере и услуге.

        Возвращает:
            Список всех заявок с именами мастеров и услуг.
        """
        async with async_session_maker() as session:
            try:
                # Используем joinedload для загрузки связанных данных
                query = (
                    select(cls.model)
                    .options(joinedload(cls.model.master), joinedload(cls.model.service))
                )
                result = await session.execute(query)
                applications = result.scalars().all()

                # Возвращаем список словарей с нужными полями
                return [
                    {
                        "application_id": app.id,
                        "user_id": app.user_id,
                        "service_name": app.service.service_name,  # Название услуги
                        "master_name": app.master.master_name,  # Имя мастера
                        "appointment_date": app.appointment_date,
                        "appointment_time": app.appointment_time,
                        "client_name": app.client_name,  # Имя клиента
                        "gender": app.gender.value  # Пол клиента
                    }
                    for app in applications
                ]
            except SQLAlchemyError as e:
                print(f"Error while fetching all applications: {e}")
                return None

Описание класса ApplicationDAO

Класс ApplicationDAO наследуется от BaseDAO и включает дополнительные методы для удобной работы с заявками:

  1. get_applications_by_user(cls, user_id: int)

    • Получает все заявки, созданные конкретным пользователем, используя его идентификатор (user_id).

    • Применяет joinedload для ленивой загрузки связанных данных (мастер, услуга) и возвращает их в виде списка словарей.

    • Форматирует данные в человеко-читаемый вид: вместо ID мастера или услуги возвращает их имена, а поле gender преобразуется в понятный текст.

  2. get_all_applications(cls)

    • Возвращает все заявки в базе данных, включая информацию о мастерах, услугах и клиентах.

    • Также использует joinedload для загрузки связанных данных и возвращает полный список заявок в виде словарей.

    • Этот метод особенно полезен для отображения всех заявок, например, в административной панели.

Эти дополнительные методы делают работу с заявками более гибкой, предоставляя детальную информацию в удобном для использования формате.

На данный момент у меня в планах начать большой цикл публикаций по работе с асинхроной SQLAlchemy через Python. Если хотели бы видеть от меня подробный разбор, обязательно сообщите об этом в комментариях. Так я пойму скольким будет нужен мой разбор этой темы.

Подготовка фронта

Всего в нашем MiniApp-приложении будет использовано 3 странички: главная страница, которую мы привяжем в командное меню, страница с формой заявки и страница с просмотром заявок (у администратора там будут видны все заявки, а у пользователя только свои).

Пример работы с генерацией страниц
Пример работы с генерацией страниц

Как обычно, создание этих трех страниц я доверил бесплатному сервису WebSim, о котором подробно рассказывал в одной из своих прошлых статей. Всего за 5–6 промтов удалось получить именно то, что мне было нужно, поэтому в этот раз не буду акцентировать внимание на процессе. В дальнейшем я представлю готовые HTML-шаблоны и подробно разберу JavaScript, который будем использовать в нашем MiniApp.

Этот скрипт будет включать как элементы официального JavaScript для MiniApp от Telegram, так и наши собственные адаптации для интеграции с FastAPI. К разбору кода мы вернемся чуть позже. Полный исходный код со стилями, а также эксклюзивный контент можно найти в моем бесплатном Telegram-канале «Легкий путь в Python».

Работа с переменными окружения

Чтобы упростить управление настройками и конфиденциальными данными, мы будем использовать переменные окружения. Для этого мы ранее установили модуль pydantic_settings. Теперь создадим файл config.py в папке app и определим в нем класс, который будет обрабатывать эти переменные.

Код файла config.py

import os
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    BOT_TOKEN: str
    BASE_SITE: str
    ADMIN_ID: int
    model_config = SettingsConfigDict(
        env_file=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env")
    )

    def get_webhook_url(self) -> str:
        """Возвращает URL вебхука с кодированием специальных символов."""
        return f"{self.BASE_SITE}/webhook"


settings = Settings()

Теперь нам достаточно будет импортировать переменную settings и через точку доставать значения нужных переменных из файла .env в проекте. Кроме того, для генерации ссылки на вебхук можно будет вызвать метод таким образом:

settings.get_webhook_url()

Берите на заметку, работать с этой библиотекой очень удобно во всех проектах.

Теперь немного теории

Друзья, сейчас мы будем переходить блоку, в котором мы опишем связку FastApi и телеграмм бота на Aiogram3. Прежде чем мы перейдем к написанию кода, я бы хотел описать общие принципы того что мы будем делать.

Начать стоит с того, что нашего телеграмм бота будет поднимать FastApi приложение. За эту часть отвечает блок с вебхуками. Часть же со страницами MiniApp может вполне себе существовать как отдельное приложение, просто я решил продемонстрировать как оно все работает в одном месте.

Поэтому, мы сейчас все разделим на два условных блока:

  • Телеграмм бот на вебхуках

  • Приложение FastApi (фронтенд и бэкенд)

Теперь подробно поговорим о том, как работают телеграмм боты на вебхуках в связке с FastApi и дальше подготовим код нашего бота.

Как это работает для нашего бота

Мы будем использовать этот механизм для интеграции бота с FastAPI через вебхуки. При запуске приложения FastAPI мы:

  1. Установим вебхук с помощью метода bot.set_webhook().

  2. Укажем URL вебхука и разрешенные типы обновлений.

  3. При завершении работы приложения автоматически удалим вебхук.

Код и объяснение

  1. Установка вебхука при запуске приложения:

    • При запуске FastAPI-приложения вызывается метод bot.set_webhook() для установки вебхука.

    • Вебхук — это URL, на который Telegram будет отправлять обновления (например, сообщения от пользователей). Мы указываем этот URL и необходимые параметры, чтобы бот мог корректно обрабатывать все поступающие обновления.

  2. Удаление вебхука при завершении работы приложения:

    • Когда приложение завершает свою работу, вебхук удаляется. Это необходимо, чтобы Telegram перестал отправлять обновления на наш сервер, обеспечивая чистоту работы и предотвращая возможные конфликты в будущем.

  3. Маршрут для обработки вебхуков:

    • Определяем маршрут @app.post("/webhook"), который будет обрабатывать запросы от Telegram.

    • Когда Telegram отправляет обновление на вебхук, FastAPI принимает его и передает в бота для дальнейшей обработки.

Простой пример кода

from fastapi import FastAPI, Request
from contextlib import asynccontextmanager
import logging
from your_bot_module import bot, dp, settings  # Импортируем необходимые компоненты бота


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Код, выполняющийся при запуске приложения
    webhook_url = settings.get_webhook_url()  # Получаем URL вебхука
    await bot.set_webhook(
        url=webhook_url,
        allowed_updates=dp.resolve_used_update_types(),
        drop_pending_updates=True
    )
    logging.info(f"Webhook set to {webhook_url}")
    yield  # Приложение работает
    # Код, выполняющийся при завершении работы приложения
    await bot.delete_webhook()
    logging.info("Webhook removed")

    
# Инициализация FastAPI с методом жизненного цикла
app = FastAPI(lifespan=lifespan)


# Маршрут для обработки вебхуков
@app.post("/webhook")
async def webhook(request: Request) -> None:
    logging.info("Received webhook request")
    update = await request.json()  # Получаем данные из запроса
    # Обрабатываем обновление через диспетчер (dp) и передаем в бот
    await dp.feed_update(bot, update)
    logging.info("Update processed")

Объяснение кода

  1. Метод жизненного цикла lifespan:

    • Выполняется при запуске FastAPI-приложения. Здесь вызывается bot.set_webhook(), который устанавливает вебхук для Telegram-бота.

    • webhook_url — это URL, куда Telegram будет отправлять обновления. Этот URL берется из настроек (settings).

    • Указываем разрешенные типы обновлений (allowed_updates) и очищаем ожидающие сообщения (drop_pending_updates=True).

    • После yield выполняется код, который удаляет вебхук при завершении работы приложения, вызывая bot.delete_webhook(). Это гарантирует, что Telegram перестанет отправлять обновления на наш сервер.

  2. Маршрут @app.post("/webhook"):

    • Этот обработчик вызывается каждый раз, когда Telegram отправляет обновление на наш вебхук.

    • При получении запроса мы считываем его JSON-данные (await request.json()), валидируем их и передаем диспетчеру (dp) для обработки ботом.

    • dp.feed_update(bot, update) обрабатывает полученное обновление и отправляет необходимые действия обратно в Telegram.

Что происходит

  • При запуске приложения: Устанавливается вебхук, и Telegram начинает отправлять обновления (сообщения, команды) на указанный URL.

  • Во время работы приложения: FastAPI принимает запросы на адрес /webhook и обрабатывает их с помощью бота, который затем выполняет необходимые действия.

  • При завершении работы приложения: Вебхук удаляется, и Telegram прекращает отправку обновлений на наш сервер, что обеспечивает корректное завершение работы бота и освобождение ресурсов.

Таким образом, с помощью методов жизненного цикла FastAPI и маршрутов мы легко подключаем и запускаем Telegram-бота на вебхуках!

Пишем код телеграмм бота на Aiogram 3

Теперь, когда мы разобрались с общим механизмом, можно приступать к написанию кода нашего телеграмм бота. Для этого создадим в папке app ещё одну папку с именем bot. В ней бы будем описывать хендлеры бота, утилиты, клавиатуры и прочий код, который будет иметь отношение только к телеграмм боту (кода, на самом деле, там будет не особо много).

Для начала создадим файл app/bot/create_bot.py и заполним его следующим образом:

from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode

from app.config import settings

bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher()


async def start_bot():
    try:
        await bot.send_message(settings.ADMIN_ID, f'Я запущен?.')
    except:
        pass


async def stop_bot():
    try:
        await bot.send_message(settings.ADMIN_ID, 'Бот остановлен. За что??')
    except:
        pass

Этот код описывает создание и настройку нашего Telegram-бота, а также содержит функции, которые уведомляют администратора о запуске и остановке бота.

Описание кода

  1. Инициализация бота:

    • Создаем экземпляр Bot, используя токен, который берется из переменных окружения (settings.BOT_TOKEN).

    • Настраиваем режим парсинга сообщений как HTML (parse_mode=ParseMode.HTML), чтобы бот мог обрабатывать HTML-теги в сообщениях.

    • Инициализируем Dispatcher для управления событиями и взаимодействиями с ботом.

  2. Функция start_bot():

    • Асинхронная функция, которая отправляет сообщение администратору (ADMIN_ID), когда бот запускается.

    • Обернута в try...except блок для обработки возможных ошибок, например, если не удалось отправить сообщение.

  3. Функция stop_bot():

    • Асинхронная функция, которая отправляет сообщение администратору, когда бот останавливается.

    • Также обернута в try...except блок для предотвращения ошибок при попытке отправить сообщение.

Те, кто читал внимательно, наверняка заметили, что функции start_bot и stop_bot отлично вписываются в методы жизненного цикла FastAPI. Мы вызовем start_bot при запуске приложения, а stop_bot — при его завершении.

Подготовим клавиатуры бота

Клавиатур у нас будет не сильно много, поэтому опишем их в одном файле.

Для начале в папке bot создаем папку keyboards, а внутри файл kbs.py. Заполним его.

Скрытый текст
from aiogram.types import ReplyKeyboardMarkup, WebAppInfo, InlineKeyboardMarkup
from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder

from app.config import settings


def main_keyboard(user_id: int, first_name: str) -> ReplyKeyboardMarkup:
    kb = ReplyKeyboardBuilder()
    url_applications = f"{settings.BASE_SITE}/applications?user_id={user_id}"
    url_add_application = f'{settings.BASE_SITE}/form?user_id={user_id}&first_name={first_name}'
    kb.button(text="? Мои заявки", web_app=WebAppInfo(url=url_applications))
    kb.button(text="? Оставить заявку", web_app=WebAppInfo(url=url_add_application))
    kb.button(text="ℹ️ О нас")
    if user_id == settings.ADMIN_ID:
        kb.button(text="? Админ панель")
    kb.adjust(1)
    return kb.as_markup(resize_keyboard=True)


def back_keyboard() -> ReplyKeyboardMarkup:
    kb = ReplyKeyboardBuilder()
    kb.button(text="? Назад")
    kb.adjust(1)
    return kb.as_markup(resize_keyboard=True)


def admin_keyboard(user_id: int) -> InlineKeyboardMarkup:
    url_applications = f"{settings.BASE_SITE}/admin?admin_id={user_id}"
    kb = InlineKeyboardBuilder()
    kb.button(text="? На главную", callback_data="back_home")
    kb.button(text="? Смотреть заявки", web_app=WebAppInfo(url=url_applications))
    kb.adjust(1)
    return kb.as_markup()


def app_keyboard(user_id: int, first_name: str) -> InlineKeyboardMarkup:
    kb = InlineKeyboardBuilder()
    url_add_application = f'{settings.BASE_SITE}/form?user_id={user_id}&first_name={first_name}'
    kb.button(text="? Оставить заявку", web_app=WebAppInfo(url=url_add_application))
    kb.adjust(1)
    return kb.as_markup()

Описание кода: создание клавиатур для Telegram-бота

Этот код создает различные клавиатуры для взаимодействия пользователей с Telegram-ботом, включая кнопки для перехода на веб-приложения (например, формы заявок) и кнопки для навигации внутри бота.

  1. Функция main_keyboard(user_id: int, first_name: str) -> ReplyKeyboardMarkup:

    • Создает основную клавиатуру для бота.

    • Добавляет три кнопки: "? Мои заявки", "? Оставить заявку", "ℹ️ О нас".

    • Кнопки "Мои заявки" и "Оставить заявку" содержат WebAppInfo, перенаправляющий на соответствующие страницы веб-приложения.

    • Если user_id совпадает с ADMIN_ID (администратор), добавляется кнопка "? Админ панель".

    • Возвращает клавиатуру с возможностью изменения размера (resize_keyboard=True).

  2. Функция back_keyboard() -> ReplyKeyboardMarkup:

    • Создает клавиатуру с одной кнопкой "? Назад".

    • Используется для навигации назад в интерфейсе бота.

    • Возвращает клавиатуру с возможностью изменения размера.

  3. Функция admin_keyboard(user_id: int) -> InlineKeyboardMarkup:

    • Создает инлайн-клавиатуру для администратора.

    • Добавляет две кнопки: "? На главную" (с callback_data для обратного вызова) и "? Смотреть заявки" (с WebAppInfo, ведущим на страницу заявок администратора).

    • Возвращает инлайн-клавиатуру для использования внутри сообщений.

  4. Функция app_keyboard(user_id: int, first_name: str) -> InlineKeyboardMarkup:

    • Создает инлайн-клавиатуру для добавления заявки.

    • Содержит кнопку "? Оставить заявку", ведущую на форму заполнения заявки.

    • Возвращает инлайн-клавиатуру для использования внутри сообщений.

Основная цель кода

Этот код демонстрирует, как использовать ссылки на веб-приложения (MiniApp) в инлайн- и текстовых клавиатурах Telegram-бота. Мы уже подключали WebApp к командному меню, а теперь вы знаете, как задействовать ссылки на MiniApp в различных форматах интерфейса бота.

Обратите внимание на формат генерации этих ссылок:

url_applications = f"{settings.BASE_SITE}/applications?user_id={user_id}"
url_add_application = f'{settings.BASE_SITE}/form?user_id={user_id}&first_name={first_name}'
url_applications = f"{settings.BASE_SITE}/admin?admin_id={user_id}" 

Здесь мы передаем необходимые параметры, такие как user_id и first_name, в запросы, создавая динамические ссылки. Те, кто знаком с моими статьями по FastAPI, сразу заметят, что речь идет о параметрах пути и запроса, которые мы будем использовать в ссылках.

В дальнейшем мы научим наше FastAPI-приложение обрабатывать эти параметры в соответствующих эндпоинтах, чтобы оно могло выполнять определенные действия в зависимости от полученных значений. Когда мы перейдем к описанию эндпоинтов страниц, этот процесс станет более ясным. Потерпите немного, скоро все встанет на свои места!

Подготовим утилиты Telegram бота

В контексте этого приложения слово «утилиты» звучит громко, но, для чистоты кода я решил некоторые его куски вынести в утилиты. В частности, утилит будет всего две. Первая будет просто возвращать текст описания «О нас». В рабочем проекте этот текст хранился бы в базе данных, но я решил захардкодить его.

И вторая утилита будет приветствовать пользователя и отправлять ему то или иное сообщение.

Давайте в папке bot создадим папку utils, а внутри папки файл utils.py и без лишних пояснений напишем код.

Скрытый текст
from aiogram.types import Message
from app.bot.keyboards.kbs import main_keyboard


def get_about_us_text() -> str:
    return """
? ЭЛЕГАНТНАЯ ПАРИКМАХЕРСКАЯ "СТИЛЬ И ШАРМ" ?

Добро пожаловать в мир изысканной красоты и непревзойденного стиля!

✨ Наша миссия:
Мы стремимся раскрыть вашу уникальную красоту, подчеркнуть индивидуальность и подарить уверенность в себе.

? Наши услуги:
• Стрижки и укладки для любого типа волос
• Окрашивание и колорирование
• Уходовые процедуры для волос
• Макияж и визаж
• Маникюр и педикюр

?‍? Наши мастера:
Талантливая команда профессионалов с многолетним опытом и постоянным стремлением к совершенству. Мы следим за последними трендами и используем инновационные техники.

? Наша атмосфера:
Погрузитесь в атмосферу уюта и релаксации. Каждый визит к нам - это не просто процедура, а настоящий ритуал красоты и заботы о себе.

? Почему выбирают нас:
• Индивидуальный подход к каждому клиенту
• Использование премиальной косметики
• Гарантия качества и результата
• Уютная и стильная обстановка
• Удобное расположение в центре города

Откройте для себя мир стиля вместе с "СТИЛЬ И ШАРМ"!
Запишитесь на консультацию прямо сейчас и сделайте первый шаг к вашему новому образу.

✨ Ваша красота - наше призвание! ✨
"""


async def greet_user(message: Message, is_new_user: bool) -> None:
    """
    Приветствует пользователя и отправляет соответствующее сообщение.
    """
    greeting = "Добро пожаловать" if is_new_user else "С возвращением"
    status = "Вы успешно зарегистрированы!" if is_new_user else "Рады видеть вас снова!"
    await message.answer(
        f"{greeting}, <b>{message.from_user.full_name}</b>! {status}\n"
        "Чем я могу помочь вам сегодня?",
        reply_markup=main_keyboard(user_id=message.from_user.id, first_name=message.from_user.first_name)
    )

Проверьте чтоб были выполнены все импорты!

Подготовка хендлеров Telegram бота

Наконец-то мы подошли к части, которую, уверен, ждали многие из вас — подготовка хендлеров (функций) для нашего бота.

Для этого создадим папку handlers внутри директории bot. В ней разместим два Python-файла:

  • user_router.py: здесь будут описаны все хендлеры, связанные с пользователями.

  • admin_router.py: здесь будут описаны все методы для работы администратора.

Пишем пользовательские хендлеры

Здесь все довольно просто. Мы опишем:

  1. Метод регистрации пользователя: будет сохранять пользователя в базе данных, если его там еще нет.

  2. Метод возврата на главную страницу: возвращает пользователя на главный экран бота.

  3. Метод "О нас": отправляет пользователю текст с информацией о компании.

Все необходимые заготовки у нас уже готовы, так что приступаем к написанию кода хендлеров!

Скрытый текст
from aiogram import Router, F
from aiogram.filters import CommandStart
from aiogram.types import Message
from app.api.dao import UserDAO
from app.bot.keyboards.kbs import app_keyboard
from app.bot.utils.utils import greet_user, get_about_us_text

user_router = Router()


@user_router.message(CommandStart())
async def cmd_start(message: Message) -> None:
    """
    Обрабатывает команду /start.
    """
    user = await UserDAO.find_one_or_none(telegram_id=message.from_user.id)

    if not user:
        await UserDAO.add(
            telegram_id=message.from_user.id,
            first_name=message.from_user.first_name,
            username=message.from_user.username
        )

    await greet_user(message, is_new_user=not user)


@user_router.message(F.text == '? Назад')
async def cmd_back_home(message: Message) -> None:
    """
    Обрабатывает нажатие кнопки "Назад".
    """
    await greet_user(message, is_new_user=False)


@user_router.message(F.text == "ℹ️ О нас")
async def about_us(message: Message):
    kb = app_keyboard(user_id=message.from_user.id, first_name=message.from_user.first_name)
    await message.answer(get_about_us_text(), reply_markup=kb)

Описание кода

  1. Инициализация роутера:

    • user_router = Router() — создаем роутер, который будет обрабатывать все команды и сообщения, связанные с пользователями.

  2. cmd_start:

    • Обрабатывает команду /start.

    • Ищет пользователя в базе данных по его telegram_id с помощью UserDAO.find_one_or_none().

    • Если пользователь не найден, добавляет его в базу данных (UserDAO.add()), сохраняя telegram_id, first_name и username.

    • Вызывает функцию greet_user(), отправляющую приветственное сообщение. Передает параметр is_new_user=True, если это новый пользователь.

  3. cmd_back_home:

    • Обрабатывает нажатие кнопки "? Назад".

    • Вызывает greet_user() для возврата к главному меню. Устанавливает is_new_user=False, так как пользователь уже взаимодействовал с ботом.

  4. about_us:

    • Обрабатывает нажатие кнопки "ℹ️ О нас".

    • Создает инлайн-клавиатуру с помощью app_keyboard(), передавая в нее user_id и first_name.

    • Отправляет сообщение с текстом "О нас" и прикрепляет клавиатуру для дальнейшего взаимодействия.

Этот код обеспечивает базовую навигацию по боту, включая регистрацию новых пользователей, возврат к главному меню и предоставление информации о проекте.

Создание главного файла приложения FastApi

Теперь нам остается привязать этот роутер в главный файл, который будет запускать наше FastApi приложение. Опишем мы все в файле main.py, который поместим в корень папки app.

import logging
from contextlib import asynccontextmanager
from app.bot.create_bot import bot, dp, stop_bot, start_bot
from app.bot.handlers.user_router import user_router
from app.config import settings
from aiogram.types import Update
from fastapi import FastAPI, Request

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')


@asynccontextmanager
async def lifespan(app: FastAPI):
    logging.info("Starting bot setup...")
    dp.include_router(user_router)
    await start_bot()
    webhook_url = settings.get_webhook_url()
    await bot.set_webhook(url=webhook_url,
                          allowed_updates=dp.resolve_used_update_types(),
                          drop_pending_updates=True)
    logging.info(f"Webhook set to {webhook_url}")
    yield
    logging.info("Shutting down bot...")
    await bot.delete_webhook()
    await stop_bot()
    logging.info("Webhook deleted")


app = FastAPI(lifespan=lifespan)


@app.post("/webhook")
async def webhook(request: Request) -> None:
    logging.info("Received webhook request")
    update = Update.model_validate(await request.json(), context={"bot": bot})
    await dp.feed_update(bot, update)
    logging.info("Update processed")

Этот код объединяет все элементы, о которых мы ранее говорили, для создания полноценного бота на основе FastAPI и aiogram, работающего через вебхуки. Здесь мы используем методы жизненного цикла FastAPI для запуска и остановки бота, а также настройки вебхука для взаимодействия с Telegram API.

Из нового тут только то что мы импортировали функции для запуска и остановки бота в метод жизненного цикла FastApi и там же зарегистрировали наш созданный роутер.

Первый запуск приложения

Для этого в терминале, с корневой папки проекта (не папка app) выполним команду:

uvicorn app.main:app --port 5050

Видим, что после ввода команды бот запустился и сообщил нам об этом. Давайте проверим работают ли наши хендлеры.

При входе нас зарегистрировало.
При входе нас зарегистрировало.
Метод с «О нас».
Метод с «О нас».

Мы видим, что все методы успешно отработали и, если все было выполнено корректно, то мы должны были попапасть в базу данных. Проверим.

Успешно зарегистрированы.
Успешно зарегистрированы.

Пишем простую админ-панель

Теперь опишем простую админку для нашего бота. Смысл админки в том, что только пользователь с ADMIN_ID будет иметь доступ к разделу админки. Там его будет ждать специальная ссылка MiniApp, которая позволит получить список всех заявок.

Все опишем в файле bot / handlers / admin_router.py

from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from app.bot.keyboards.kbs import main_keyboard, admin_keyboard
from app.config import settings

admin_router = Router()


@admin_router.message(F.text == '? Админ панель', F.from_user.id.in_([settings.ADMIN_ID]))
async def admin_panel(message: Message):
    await message.answer(
        f"Здравствуйте, <b>{message.from_user.full_name}</b>!\n\n"
        "Добро пожаловать в панель администратора. Здесь вы можете:\n"
        "• Просматривать все текущие заявки\n"
        "• Управлять статусами заявок\n"
        "• Анализировать статистику\n\n"
        "Для доступа к полному функционалу, пожалуйста, перейдите по ссылке ниже.\n"
        "Мы постоянно работаем над улучшением и расширением возможностей панели.",
        reply_markup=admin_keyboard(user_id=message.from_user.id)
    )


@admin_router.callback_query(F.data == 'back_home')
async def cmd_back_home_admin(callback: CallbackQuery):
    await callback.answer(f"С возвращением, {callback.from_user.full_name}!")
    await callback.message.answer(
        f"С возвращением, <b>{callback.from_user.full_name}</b>!\n\n"
        "Надеемся, что работа в панели администратора была продуктивной. "
        "Если у вас есть предложения по улучшению функционала, "
        "пожалуйста, сообщите нам.\n\n"
        "Чем еще я могу помочь вам сегодня?",
        reply_markup=main_keyboard(user_id=callback.from_user.id, first_name=callback.from_user.first_name)
    )

Тут из того, что заслуживает внимание – это использование магического метода для проверки является ли пользователь администратором.

@admin_router.message(F.text == '? Админ панель', F.from_user.id.in_([settings.ADMIN_ID]))

Описал проверку, как проверку на список администраторов. На будущее, если администраторов будет больше чем один.

Все остальное – это просто текст и метод для возврата на главную страницу с инлайн клавиатуры.

Зарегистрируем админку в main.py и проверим все ли работает.

from app.bot.handlers.admin_router import admin_router
@asynccontextmanager
async def lifespan(app: FastAPI):
    logging.info("Starting bot setup...")
    dp.include_router(user_router)
    dp.include_router(admin_router)

Метод корректно отработал и тут, что значит, что с разработкой телеграмм бота на вебхуках мы закончили и теперь можно приступать ко второму большому блоку в котором мы опишем наш MiniApp через FastApi.

Пишем код фронтенд части с API методами

В данном боле, так как мной было написано очень много статей на тему разработки приложений через FastApi я не буду так подробно разбирать код, а буду акцентированно рассказывать только про часть, которая имеет отношение к JS и обмене данными с телеграмм ботом.

Подробнее про разработку на FastApi вы сможете глянуть в этих публикациях.

Условно, весь FastApi проект можно разбить на 2 части: страницы и api-методы (у нас он будет один). Так мы и разделим.

Создадим две папки в папке app:

  • pages: тут опишем роутер с эндпоинтами, который будет поднимать наши страницы

  • api: эту папку мы уже начали заполнять файлами и там нам останется создать API метод, который будет отвечать за регистрацию заявок в базе данных и за уведомление об этом клиентов и администраторов.

Поскольку наш проект будет содержать значительное количество JavaScript-кода, изображений и стилей, для удобства и чистоты организации кода мы воспользуемся механизмом работы со статическими файлами в FastAPI. Это позволит хранить все статические ресурсы (фото, стили, скрипты) в одном месте и обеспечить к ним простой доступ.

Чтобы внедрить поддержку статических файлов, внесем изменения в файл main.py:

from fastapi.staticfiles import StaticFiles

app.mount('/static', StaticFiles(directory='app/static'), name='static')

app.mount('/static', StaticFiles(...)): Эта строка подключает папку app/static в качестве каталога для хранения статических файлов. Мы "монтируем" папку /static так, что теперь все ресурсы (изображения, CSS, JavaScript и пр.) будут доступны по URL, начинающимся с /static.

Теперь, при необходимости использовать статические файлы в HTML-шаблонах или скриптах, достаточно указать путь, начинающийся с /static, чтобы получить к ним доступ. Например, для подключения стилей можно использовать:

 <link rel="stylesheet" href="/static/css/style.css">

Создадим папку static в папке app и заполним ее следующими папками:

  • css: тут будут храниться стили

  • img: тут будут храниться фотографии (в нашем проекте 1 фото)

  • js: тут будут JavaScript файлы.

Эндпоинты для рендеринга страниц

В папке app/pages создадим файл router.py

И начнем мы с того, что подготовим код для рендеринга главной страницы нашего сайта. Вот код:

from fastapi import APIRouter
from fastapi.templating import Jinja2Templates
from fastapi.requests import Request
from fastapi.responses import HTMLResponse


router = APIRouter(prefix='', tags=['Фронтенд'])
templates = Jinja2Templates(directory='app/templates')


@router.get("/", response_class=HTMLResponse)
async def read_root(request: Request):
    return templates.TemplateResponse("index.html",
                                      {"request": request, "title": "Элегантная парикмахерская"})
  1. Импорт модулей:

    • APIRouter из FastAPI: для создания и регистрации маршрутов.

    • Jinja2Templates: для рендеринга HTML-шаблонов.

    • Request: объект запроса клиента.

    • HTMLResponse: тип ответа в виде HTML.

  2. Создание маршрутизатора:

    router = APIRouter(prefix='', tags=['Фронтенд'])
  3. Инициализация шаблонизатора:

    templates = Jinja2Templates(directory='app/templates')
  4. Создание маршрута для главной страницы:

    @router.get("/", response_class=HTMLResponse)
    async def read_root(request: Request):
        return templates.TemplateResponse("index.html", {"request": request, "title": "Главная страница"})
    

Этот код создает и регистрирует маршрут для главной страницы нашего сайта. Когда пользователь переходит по корневому адресу ("/"), функция рендерит HTML-шаблон (index.html) и возвращает его в ответе. Благодаря использованию Jinja2Templates, мы можем динамически вставлять данные (например, title) в HTML-шаблон, что делает страницу более гибкой и информативной.

Как вы заметили, код подразумевает, что существует папка templates, а в этой папке файл index.html. Давайте его создадим, но, перед этим, выполним небольшую подготовку.

Создаем папку templates в корне app и внутри создадим файл base.py.

Заполним его следующим образом:

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Мое FastAPI Приложение{% endblock %}</title>
    {% block styles %}{% endblock %}
</head>
<body>
{% block content %} {% endblock %}

<script src="https://telegram.org/js/telegram-web-app.js"></script>
{% block scripts %} {% endblock %}
</body>
</html>

Файл base.html — это базовый (основной) шаблон, который задает структуру HTML-документов нашего приложения. Мы создали его для облегчения рендеринга других страниц сайта. Этот шаблон содержит общие элементы (например, метатеги, стили, скрипты), которые будут использоваться на всех страницах.

Основные блоки:

  1. {% block title %} — позволяет подставлять динамический заголовок на каждой странице.

  2. {% block styles %} — место для вставки индивидуальных стилей на страницах, которые будут использовать этот шаблон.

  3. {% block content %} — основной блок для содержимого страницы, куда будет вставляться уникальный контент каждой конкретной страницы.

  4. {% block scripts %} — позволяет добавлять индивидуальные скрипты, необходимые для каждой страницы.

Использование base.html позволяет переиспользовать общий каркас страницы и добавлять на его основе уникальное содержимое, значительно упрощая работу с шаблонами и повышая читабельность кода.

Главное, что мы сделали в этом файле это выполнили импорт JavaScript от телеграмм глобально. То есть, теперь все страницы которые будут наследоваться от этого файла, автоматически будут содержать JS Telegram, а следовательно, мы получим функционал JS телеграмм на каждой странице.

Файл index.html.

Как я описывал ранее, тут я не буду приводить код стилей, так как они не имеют прямого отношения к этой статье, а сосредоточимся мы только на JS и HTML.

Код index.html

Скрытый текст
{% extends "base.html" %}

{% block title %}
{{ title }}
{% endblock %}

{% block styles %}
<link rel="stylesheet" href="/static/css/index.css">
{% endblock %}

{% block content %}
<header>
    <img class="header-image"
         src="/static/img/photo.jpg"
         alt="Элегантный интерьер парикмахерской с кожаными креслами и зеркалами" width="1500" height="1000">
    <div class="header-content">
        <svg class="scissors" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
            <path d="M30,40 L70,80 M30,80 L70,40 M50,60 L90,20 M50,60 L10,20"/>
        </svg>
        <h1>{{ title }}</h1>
    </div>
</header>
<main>
    <h2>Откройте для себя мир стиля</h2>
    <div class="divider"></div>
    <p>Наши <span class="highlight">талантливые мастера</span> создадут для вас <span class="highlight">неповторимый образ</span>,
        который подчеркнет вашу индивидуальность и шарм.</p>

    <!-- Кнопка для записи -->
    <a id="book-button" class="btn">Записаться сейчас</a>
</main>
{% endblock %}

{% block scripts %}
<script src="/static/js/index.js"></script>
{% endblock %}

Тут я использовал наши статические файлы по полной, импортировав JS скрипты (которые мы совсем скоро опишем), стили и фото, которое будет использовано в шапке главной страницы.

Кроме того, я использовал переменную title, которую мы передали в нашем FastApi эндпоинте, который будет отвечать за главную страницу сайта. Остальные аспекты дополнительного внимания не заслуживают.

Теперь опишем файл static/js/index.js

Скрытый текст
document.addEventListener('DOMContentLoaded', function () {
    const user = Telegram.WebApp.initDataUnsafe.user;

    const bookButton = document.getElementById('book-button');

    bookButton.addEventListener('click', function () {
        // Если пользователь существует, добавляем его user_id и first_name в URL, иначе редирект без него
        if (user && user.id) {
            window.location.href = `/form?user_id=${user.id}&first_name=${user.first_name}`;
        } else {
            window.location.href = `/form`;
        }
    });
});

Этот код — JavaScript, который выполняется после загрузки страницы (DOMContentLoaded).

  1. Получение данных пользователя: Использует объект Telegram.WebApp.initDataUnsafe.user для извлечения информации о пользователе, который открыл MiniApp в Telegram. Использование этого кода стало возможным из‑за того, что мы в файле base.html глобально импортировали скрипты JS.

  2. Кнопка бронирования: Находит кнопку с ID book‑button и добавляет к ней обработчик событий (click).

  3. Обработка клика: При нажатии на кнопку происходит проверка:

    • Если данные о пользователе доступны (user.id), происходит перенаправление на страницу /form, добавляя user_id и first_name в URL.

    • Если данных нет, происходит простой редирект на /form.

Этот код обеспечивает динамическое формирование URL на основе наличия данных о пользователе Telegram. Эндпоинт, который будет возвращать форму с заявкой мы совсем скоро подготовим.

Теперь зарегистрируем роутер pages в main.py и выполним запуск FastApi приложения.

app.include_router(router_pages)

При клике на «Записаться сейчас» мы пока будем получать ошибку, что страница не найдена. Исправим это. В файле pages/router.py опишем эндпоинт, который будет рендерить страницу с формой.

Как вы видите, тут мы уже взаимодействуем с базой данных, так как нам нужно достать список мастеров и список услуг.

Кроме того, обратите внимание, тут мы уже обрабатываем переменные user_id и first_name:

  • Переменная first_name нужна для того чтоб подставить ее в поле «Имя» в нашей анкете. То есть, мы будем автоматически подставлять поле имя и брать мы ее будем с телеграмм переменной first_name

  • Переменная user_id выполняет больше функций. Она позволяет после заполнения анкеты закрепить за пользователем его заявку. Кроме того, благодаря этой переменной бот будет понимать кому отправлять сообщение со словами "Ваша заявка заполнена».

Теперь подготовим файл templates/form.html

Скрытый текст
{% extends "base.html" %}

{% block title %}
{{ title }}
{% endblock %}

{% block styles %}
<link rel="stylesheet" href="/static/css/form.css">
{% endblock %}

{% block content %}
<main>
    <form id="appointmentForm">
        <h1>Запись на услуги</h1>

        <div class="form-group">
            <label for="name">Имя</label>
            <input type="text" id="name" name="name" value="{{ first_name }}" required placeholder="Введите ваше имя">
        </div>

        <div class="form-group">
            <label for="gender">Пол</label>
            <select id="gender" name="gender" required>
                <option value="" disabled selected>Выберите пол</option>
                <option value="male_Мужской">Мужской</option>
                <option value="female_Женский">Женский</option>
            </select>
        </div>

        <div class="form-group">
            <label for="service">Услуга</label>
            <select id="service" name="service" required>
                <option value="" disabled selected>Выберите услугу</option>
                <!-- Цикл для вывода списка услуг -->
                {% for service in services %}
                <option value="{{ service.service_id }}_{{ service.service_name }}">{{ service.service_name }}</option>
                {% endfor %}
            </select>
        </div>

        <div class="form-group">
            <label for="date">Дата услуги</label>
            <input type="date" id="date" name="date" required>
        </div>

        <div class="form-group">
            <label for="time">Время услуги</label>
            <input type="time" id="time" name="time" required>
        </div>

        <div class="form-group">
            <label for="stylist">Мастер</label>
            <select id="stylist" name="stylist" required>
                <option value="" disabled selected>Выберите мастера</option>
                <!-- Цикл для вывода списка мастеров -->
                {% for master in masters %}
                <option value="{{ master.master_id }}_{{ master.master_name }}">{{ master.master_name }}</option>
                {% endfor %}
            </select>
        </div>

        <input type="hidden" id="user_id" value="{{ user_id }}">
        <button type="submit" class="btn">Записаться</button>
    </form>
</main>

<!-- Попап -->
{% include 'include/popup.html' %}

{% endblock %}

{% block scripts %}
<script src="/static/js/form.js"></script>
{% endblock %}

Тут мы используем синтаксис Jinja2 для того чтоб отобразить мастеров в списке с мастерами и услуги в выпадающем списке с услугами.

Кроме того, вы обратили внимание на строку.

<!-- Попап -->
{% include 'include/popup.html' %}

В качестве демонстрации того, как включать отдельные куски, я решил вынести всплывающий попап «Вы зарегистрированы» в отдельный блок кода.

В папке templates я создал папку include, а в ней файл popup.html. Вот его содержимое

<div id="popup" class="popup">
    <div class="popup-content">
        <h2>Спасибо за вашу запись!</h2>
        <p id="popupMessage"></p>
        <button class="btn" id="closePopup">Закрыть</button>
    </div>
</div>

Прежде чем мы перейдем к написанию JS для формы, который будет отвечать за передачу информации с формы в базу данных и телеграмм бота, давайте запустим приложение и посмотрим на форму, которая у нас получилась.

У нас получилась достаточно стильная и функциональная форма. Тут есть возможность выбора даты с календаря и удобного ввода времени. Кроме того, мы привязали услуги и мастеров с базы данных и теперь эта информация отображается в выпадающем списке.

Пишем API метод

Перед тем как мы закончим логику страницы с формой, нам необходимо написать метод, который будет обрабатывать информацию, переданную с формы. Наш метод будет:

  • Проверять валидны ли данные

  • Будет сохранять заявку в базу данных

  • Будет уведомлять админа о том, что была новая заявка

  • Будет отправлять пользователю информацию о том, что его заявка сохранена в боте

В папке app/api создадим файл schemas.py и там пропишем модель Pydantic.

from pydantic import BaseModel, Field
from datetime import date, time


# Модель для валидации данных
class AppointmentData(BaseModel):
    name: str = Field(..., min_length=2, max_length=50, description="Имя клиента")
    gender: str = Field(..., min_length=2, max_length=50, description="Пол клиента")
    service: str = Field(..., min_length=2, max_length=50, description="Услуга клиента")
    stylist: str = Field(..., min_length=2, max_length=50, description="Имя мастера")
    appointment_date: date = Field(..., description="Дата назначения")  # Переименовал поле
    appointment_time: time = Field(..., description="Время назначения")  # Переименовал поле
    user_id: int = Field(..., description="ID пользователя Telegram")

Эта модель не только проверяет валидность входящих данных, но и трансформирует данные из JSON, отправляемого с фронтенда, в валидный формат для дальнейшей обработки на бэкенде.

Теперь опишем наш единственный API метод. Для этого в папке api создадим метод router.py и заполним его так.

Скрытый текст
from fastapi import APIRouter
from fastapi.requests import Request
from fastapi.responses import JSONResponse
from app.api.schemas import AppointmentData
from app.bot.create_bot import bot
from app.api.dao import ApplicationDAO
from app.bot.keyboards.kbs import main_keyboard
from app.config import settings

router = APIRouter(prefix='/api', tags=['API'])


@router.post("/appointment", response_class=JSONResponse)
async def create_appointment(request: Request):
    # Получаем и валидируем JSON данные
    data = await request.json()
    validated_data = AppointmentData(**data)

    master_id, master_name = validated_data.stylist.split('_')
    service_id, service_name = validated_data.service.split('_')
    gender, gender_name = validated_data.gender.split('_')

    # Формируем сообщение для пользователя
    message = (
        f"? <b>{validated_data.name}, ваша заявка успешно принята!</b>\n\n"
        "? <b>Информация о вашей записи:</b>\n"
        f"? <b>Имя клиента:</b> {validated_data.name}\n"
        f"?‍? <b>Пол клиента:</b> {gender_name}\n"
        f"? <b>Услуга:</b> {service_name}\n"
        f"✂️ <b>Мастер:</b> {master_name}\n"
        f"? <b>Дата записи:</b> {validated_data.appointment_date}\n"
        f"⏰ <b>Время записи:</b> {validated_data.appointment_time}\n\n"
        "Спасибо за выбор нашей студии! ✨ Мы ждём вас в назначенное время."
    )

    # Сообщение администратору
    admin_message = (
        "? <b>Новая запись!</b>\n\n"
        "? <b>Детали заявки:</b>\n"
        f"? Имя клиента: {validated_data.name}\n"
        f"? Услуга: {service_name}\n"
        f"✂️ Мастер: {master_name}\n"
        f"? Дата: {validated_data.appointment_date}\n"
        f"⏰ Время: {validated_data.appointment_time}\n"
        f"?‍? Пол клиента: {gender_name}"
    )

    # Добавление заявки в базу данных
    await ApplicationDAO.add(
        user_id=validated_data.user_id,
        master_id=master_id,
        service_id=service_id,
        appointment_date=validated_data.appointment_date,
        appointment_time=validated_data.appointment_time,
        client_name=validated_data.name,
        gender=gender
    )
    kb = main_keyboard(user_id=validated_data.user_id, first_name=validated_data.name)
    # Отправка сообщений через бота
    await bot.send_message(chat_id=validated_data.user_id, text=message, reply_markup=kb)
    await bot.send_message(chat_id=settings.ADMIN_ID, text=admin_message, reply_markup=kb)

    # Возвращаем успешный ответ
    return {"message": "success!"}

Этот код позволяет обрабатывать запросы на создание записей, проверять и сохранять данные, а также информировать пользователя и администратора через Telegram-бота.

Здесь наиболее интересным моментом является интеграция объекта bot. Этот пример наглядно демонстрирует, насколько удобно использовать Telegram-бота в эндпоинтах FastAPI. Рекомендую внимательно разобраться в этом коде, так как его понимание откроет возможности для создания более сложных и мощных интеграций Telegram в ваше API.

Теперь мы получили метод для обработки входящий данных. Остается написать JS-скрипт, который будет собирать данные с нашей формы, а затем будет делать их отправку нашему API методу. Сделаем это!

Файл staic/js/form.js

Скрытый текст
document.getElementById('appointmentForm').addEventListener('submit', function (e) {
    e.preventDefault();

    const name = document.getElementById('name').value;
    const service = document.getElementById('service').options[document.getElementById('service').selectedIndex].text;
    const date = document.getElementById('date').value;
    const time = document.getElementById('time').value;

    const popupMessage = `${name}, вы записаны на ${service.toLowerCase()} ${date} в ${time}.`;
    document.getElementById('popupMessage').textContent = popupMessage;

    document.getElementById('popup').style.display = 'flex';
});

document.getElementById('closePopup').addEventListener('click', async function () {
    const name = document.getElementById('name').value.trim();
    const service = document.getElementById('service').value.trim();
    const date = document.getElementById('date').value;
    const time = document.getElementById('time').value;
    const userId = document.getElementById('user_id').value;
    const stylist = document.getElementById('stylist').value.trim();
    const gender = document.getElementById('gender').value.trim();

    // Проверяем валидность полей
    if (name.length < 2 || name.length > 50) {
        alert("Имя должно быть от 2 до 50 символов.");
        return;
    }

    if (gender.length < 2 || gender.length > 50) {
        alert("Пол должен быть от 2 до 50 символов.");
        return;
    }

    if (service.length < 2 || service.length > 50) {
        alert("Услуга должна быть от 2 до 50 символов.");
        return;
    }

    if (stylist.length < 2 || stylist.length > 50) {
        alert("Имя мастера должно быть от 2 до 50 символов.");
        return;
    }

    // Создаем объект с данными
    const appointmentData = {
        name: name,
        gender: gender,
        service: service,
        appointment_date: date,
        appointment_time: time,
        stylist: stylist,
        user_id: userId
    };

    // Преобразуем объект в JSON строку
    const jsonData = JSON.stringify(appointmentData);

    // Отправляем POST запрос на /appointment
    try {
        const response = await fetch('/api/appointment', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: jsonData
        });
        const result = await response.json();
        console.log('Response from /form:', result);

        // Закрываем Telegram WebApp через 100 мс
        setTimeout(() => {
            window.Telegram.WebApp.close();
        }, 100);
    } catch (error) {
        console.error('Error sending POST request:', error);
    }
});

// Анимация появления элементов при загрузке страницы
function animateElements() {
    const elements = document.querySelectorAll('h1, .form-group, .btn');
    elements.forEach((el, index) => {
        setTimeout(() => {
            el.style.opacity = '1';
            el.style.transform = 'translateY(0)';
        }, 100 * index);
    });
}

// Стили для анимации
var styleSheet = document.styleSheets[0];
styleSheet.insertRule(`
    h1, .form-group, .btn {
        opacity: 0;
        transform: translateY(20px);
        transition: opacity 0.5s ease, transform 0.5s ease;
    }
`, styleSheet.cssRules.length);

// Плавное появление страницы при загрузке
window.addEventListener('load', function () {
    document.body.style.opacity = '1';
    animateElements();
});

styleSheet.insertRule(`
    body {
        opacity: 0;
        transition: opacity 0.5s ease;
`, styleSheet.cssRules.length);

// Добавляем текущую дату в поле даты
document.addEventListener('DOMContentLoaded', (event) => {
    const today = new Date().toISOString().split('T')[0];
    document.getElementById('date').setAttribute('min', today);
});

Я решил привести полную версию кода для демонстрации общей логики работы страницы с формой, но тут нас больше всего будет интересовать метод, который будет срабатывать при закрытии окна подтверждающего успешное заполнение данных формы и их отправку.

document.getElementById('closePopup').addEventListener('click', async function () {….}

Его смысл в том, что при действии закрытия всплывающего окна (клик на кнопку «Закрыть») начинает происходить магия JS.

Сначала мы достаем значения из каждого поля формы. Тут, обратите внимание, что мы хоть явно не указывали Telegram ID пользователя в форме, JS его все равно будет видеть. Это благодаря данным строкам в шаблоне.

<input type="hidden" id="user_id" value="{{ user_id }}">

Далее происходит следующее. Мы забираем все данные с формы, проводим простую валидацию и затем формируем JSON с данными с заявкой, которые после передаем на наш созданный эндпоинт.

Кроме того, внимания заслуживает эти строки.

// Закрываем Telegram WebApp через 100 мс
setTimeout(() => {
    window.Telegram.WebApp.close();
}, 100);

Тут мы единственный раз за все время обращаемся к JS телеграмм и только для того чтоб закрыть окно MiniApp.

Таким образом мы максимально минимизировали влияние JS телеграмм на нашу систему, настроив свою собственную логику. Думаю, что это объяснение заслуживает лайка к данной статье как минимум.

Проверим.

Заполняю данные в форме

Отправляю данные

Поскольку я являюсь и администратором, и клиентом, я сразу же получаю два сообщения в боте: одно с подтверждением, что заявка принята, и второе — с уведомлением о новой записи в моей парикмахерской.

Осталось только оформить странички для просмотра заявок и наш бот будет полностью готов.

Страницы просмотра заявок

Мы создадим две страницы для просмотра заявок: одну для администратора и другую для клиента, но использовать будем один и тот же HTML-шаблон.

В файле pages/router.py опишем два эндпоинта.

Эндпоинт для просмотра всех заявок (админ-панель)

@router.get("/admin", response_class=HTMLResponse)
async def read_root(request: Request, admin_id: int = None):
    data_page = {"request": request, "access": False, 'title_h1': "Панель администратора"}
    if admin_id is None or admin_id != settings.ADMIN_ID:
        data_page['message'] = 'У вас нет прав для получения информации о заявках!'
        return templates.TemplateResponse("applications.html", data_page)
    else:
        data_page['access'] = True
        data_page['applications'] = await ApplicationDAO.get_all_applications()
        return templates.TemplateResponse("applications.html", data_page)

Здесь все достаточно просто. Мы проверяем переданный admin_id и сравниваем его с settings.ADMIN_ID. Если идентификатор совпадает, предоставляем доступ к странице. На самой странице отображается информация обо всех заявках из базы данных.

Важно: Этот метод проверки идентификации нельзя назвать безопасным. В реальных проектах не рекомендуется использовать подобный подход, но в данном примере мы обошлись без дополнительной реализации блоков авторизации и аутентификации.

Эндпоинт для просмотра своих заявок (панель клиента)

@router.get("/applications", response_class=HTMLResponse)
async def read_root(request: Request, user_id: int = None):
    data_page = {"request": request, "access": False, 'title_h1': "Мои записи"}
    user_check = await UserDAO.find_one_or_none(telegram_id=user_id)

    if user_id is None or user_check is None:
        data_page['message'] = 'Пользователь, по которому нужно отобразить заявки, не указан или не найден в базе данных'
        return templates.TemplateResponse("applications.html", data_page)
    else:
        applications = await ApplicationDAO.get_applications_by_user(user_id=user_id)
        data_page['access'] = True
        if len(applications):
            data_page['applications'] = await ApplicationDAO.get_applications_by_user(user_id=user_id)
            return templates.TemplateResponse("applications.html", data_page)
        else:
            data_page['message'] = 'У вас нет заявок!'
            return templates.TemplateResponse("applications.html", data_page)

Этот эндпоинт чуть более сложный, так как здесь мы проверяем, существует ли пользователь, а затем проверяем наличие его заявок. Логика та же: если пользователь найден, получаем все его заявки и отображаем их на странице.

HTML-шаблон страницы с заявками

Скрытый текст
{% extends "base.html" %}

{% block title %}
Панель просмотра заявок
{% endblock %}

{% block styles %}
<link rel="stylesheet" href="/static/css/applications.css">
{% endblock %}

{% block content %}
<main>
    <h1>{{ title_h1 }}</h1>

    {% if access %}
    <table id="appointments-table">
        <thead>
        <tr>
            <th>Дата</th>
            <th>Время</th>
            <th>Услуга</th>
            <th>Мастер</th>
        </tr>
        </thead>
        <tbody>
        {% for application in applications %}
        <tr>
            <td>{{ application.appointment_date }}</td>
            <td>{{ application.appointment_time }}</td>
            <td>{{ application.service_name }}</td>
            <td>{{ application.master_name }}</td>
        </tr>
        {% endfor %}
        </tbody>
    </table>
    {% else %}
    <p>{{ message }}</p>
    {% endif %}
</main>
{% endblock %}

{% block scripts %}
<script src="/static/js/applications.js"></script>
{% endblock %}

Основная часть отрисовки контента здесь выполняется с помощью Jinja2. В случае, если доступ разрешен (access), отображается таблица с заявками. В противном случае выводится сообщение об отсутствии прав доступа или заявок.

JavaScript для анимации и прокрутки

Скрытый текст
// Анимация появления элементов
function animateElements() {
    const elements = document.querySelectorAll('h1, table');
    elements.forEach((el, index) => {
        setTimeout(() => {
            el.style.opacity = '1';
            el.style.transform = 'translateY(0)';
        }, 200 * index);
    });
}

// Запуск анимации при загрузке страницы
window.addEventListener('load', animateElements);

// Обработчик для прокрутки на мобильных устройствах
document.addEventListener('DOMContentLoaded', (event) => {
    const main = document.querySelector('main');
    let isScrolling;

    main.addEventListener('scroll', function () {
        window.clearTimeout(isScrolling);
        isScrolling = setTimeout(function () {
            console.log('Scrolling has stopped.');
        }, 66);
    }, false);
});

Почти всю работу по отрисовке выполняет Jinja2. В JavaScript добавлена небольшая анимация для плавного появления элементов на странице и обработчик для прокрутки на мобильных устройствах.

Теперь проверим и протестируем все вместе!

Теперь, когда наш бот полностью готов, осталось выполнить его запуск удаленно.

Деплой проекта на Amvera Cloud

Деплой на сервис Amvera Cloud максимально простой. Изначально, необходимо подготовить файл с настройками проекта. Это можно сделать как самостоятельно (скопировать мой код), так и прямо на сайте Amvera, при создании проекта.

Файл настроек помещается в файл amvera.yml, который ложится в корень проекта. Вот пример файла с настройками.

meta:
  environment: python
  toolchain:
    name: pip
    version: 3.12
build:
  requirementsPath: requirements.txt
run:
  persistenceMount: /data
  containerPort: 8000
  command: uvicorn app.main:app --host 0.0.0.0 --port 8000

 Важно! Для обеспечения сохранности данных из базы данных на Amvera необходимо создать папку data в корне проекта и переместить туда файл базы данных Sqlite перед деплоем. Не забудьте предварительно обновить путь в файле database.py.

Тут мы описали простую инструкцию для запуска нашего проекта на сервисе Amvera Cloud.

Теперь остается только доставить эти файлы в AmveraCloud. Для данной задачи я воспользуюсь внутренним интерфейсом на сайте сервиса. Альтернативный способ доставки – GIT с его стандартными командами.

Приступаем к деплою.

  • Выполняем регистрацию в Amvera Cloud, если ее не было (новенькие получают 111 рублей на основной счет в подарок)

  • Переходим в раздел проектов и нажимаем на «Создать проект»

  • Даем проекту название и выбираем тариф. Для учебного проекта достаточно «Начального».

  • Теперь загрузим файлы приложения в проект. Я выбираю вариант «Через интерфейс»

  • На следующем экране вы заметите, что настройки автоматически подгрузились. Проверяем чтоб все было корректно и жмем на «Завершить».

Теперь нам необходимо получить бесплатное доменное имя с https протоколом. Для этого заходим в созданный проект, перемещаемся на вкладку «Настройки» и там добавляем бесплатное доменное имя.

Теперь нам нужно заменить в файле .env доменное имя на то что мы получили и перезаписать файл через раздел репозиторий.

Перезаписываем ссылку на MiniApp в Botfather

Пересобираем проект и ждем пока наш бот сообщит о том, что он работает.

Бота можно поклацать тут: https://t.me/fastapi_webhook_miniappBOT

Ещё статья по теме разработки ботов через WebApp: Telegram Mini App. Как создать Web App с нуля

Выводы

В этой статье мы на практике разобрали, как связать Telegram-бот с FastAPI через вебхуки и создали полноценное веб-приложение для записи клиентов в парикмахерскую.

Мы реализовали обработку данных и интеграцию бота в эндпоинты FastAPI, что позволило автоматически обрабатывать заявки и мгновенно уведомлять клиентов и администратора. Благодаря гибкости FastAPI и возможностям Aiogram мы смогли разработать удобный и функциональный сервис для бизнеса.

Надеюсь, мне удалось научить вас создавать такие полноценные приложения на FastAPI. Если вам удалось разобраться в этой теме после прочтения статьи, буду рад вашей обратной связи в виде лайка или комментария. Это будет для меня лучшей мотивацией!

Напоминаю, что полный исходный код этого проекта, а также другой эксклюзивный контент, вы найдете в моем Telegram-канале «Легкий путь в Python».

На этом всё. До скорого!

Комментарии (9)


  1. DTPlayer
    08.10.2024 06:50

    Особо сильно не вчитываясь в саму статью(был интересен только код), могу написать несколько вещей

    1)Не используйте(пожалуйста) call.message.anwser при ответе на inline. Есть edit_text/подобная функция. Telegram ждёт ответа на кнопку(это хоть здесь и реализовано, но накидывает много лишних сообщений, которые можно перекрыть одним edit_text)

    2)Странная логика написания кода. Где-то импорта логически разделены(через Enter), где то посредине импорта стоит sys) Где-то один отступ перед функцией, а где-то два. Странная логика

    3)Закрывать через

    setTimeout(() => {
        window.Telegram.WebApp.close();
    }, 100);

    необязательно) Можно повесить реакцию на кнопку "закрыть/похожую по логике кнопку"

    4)Очень опционально, но улучшает пользовательский опыт - постановка цветов темы из телеграмма (в документации в красках все расписано). То же касается MainButton, showAlert и тд

    Сама тема интересная, но не особо раскрыта тема telegram mini apps js(там очень много интересных функций есть, но про них почему-то никто не говорит)


    1. yakvenalex Автор
      08.10.2024 06:50

      1)Не используйте(пожалуйста) call.message.anwser при ответе на inline. Есть edit_text/подобная функция. Telegram ждёт ответа на кнопку(это хоть здесь и реализовано, но накидывает много лишних сообщений, которые можно перекрыть одним edit_text)

      Бывают разные ситуации. Я писал больше 12-ти крупных статей по аиграм и очень хорошо осведомлен о всех методах. В текущем коде мне показался такой подход более уместный.

      2)Странная логика написания кода. Где-то импорта логически разделены(через Enter), где то посредине импорта стоит sys) Где-то один отступ перед функцией, а где-то два. Странная логика

      Это не логика написания кода, а логика его сюда вставки. Когда статья перекатывает за 20 минут чтения начинаются очень серьезные глюки в оформлении. Кроме того, это никакого отношения к логике не имеет.

      "посредине импорта стоит sys". Расскажите, пожалуйста, а как в текущей логике кода получить тот же результат миграций Alembic без использования "посередине импорта sys". Вы знакомы с этой технологией? Приложение запускается с корня проекта. Нужно к нему прикрутить модели алхимии и прочее. Просто интересно, просвятите)

      3)Закрывать через

      Это классический синтаксис и как по мне он более понятный.

      4)Очень опционально, но улучшает пользовательский опыт - постановка цветов темы из телеграмма (в документации в красках все расписано). То же касается MainButton, showAlert и тд

      Ограничения. Я всегда стараюсь в подобных проектах отходить от готового кода JavaScript телеграм. Как по мне он сильно ограничивает.

      Сама тема интересная, но не особо раскрыта тема telegram mini apps js(там очень много интересных функций есть, но про них почему-то никто не говорит)

      Есть FastApi и JavaScript. Зачем использовать чужое если можно написать полностью свое и под себя?)

      Я думаю причина многих вопросов в том, что вы не вчитывались в статью, а, точнее, не читали.


  1. DTPlayer
    08.10.2024 06:50

    В текущем коде мне показался такой подход более уместный.

    С точки зрения пользователя - показатель незнания API у разработчика. Хотя-бы ради приличия delete перед answer вставляйте)

    "посредине импорта стоит sys". Расскажите, пожалуйста, а как в текущей логике кода получить тот же результат миграций Alembic без использования "посередине импорта sys"

    Здесь имеется в виду обрывание импортов ради вызова sys, да, с Alembic я не знаком, но не нудаю что прям жизненно необходимо написать 2 импорта, потом вызов sys(в котором я не вижу ничего плохо, для меня странно только его расположение в коде), а потом только остальные импорты.

    Это классический синтаксис и как по мне он более понятный.

    Классический синтаксис - через const, куда помещается Telegram.WebApp. А закрытие через setTimeout - очень странная штука(никогда не видел код, который бы так закрывал mini app)

    Ограничения. Я всегда стараюсь в подобных проектах отходить от готового кода JavaScript телеграм. Как по мне он сильно ограничивает.

    Никогда такого не заметил. Даже наоборот - сильно улучшал пользовательский опыт. Да, есть определенные ограничения, но они опциональны(в том плане, что если вы хотите - вы можете получить готовый код, но с определенными ограничениями). Очень часто замечаю, что в Mini App только одна тема(если делаете свои цвета - делайте 2 темы тогда, темную и светлую), Teelgram позволяет получить состояние темы пользователя (вместе с эвентом кстати)

    Есть FastApi и JavaScript. Зачем использовать чужое если можно написать полностью свое и под себя?)

    А зачем тогда использовать FastAPI, если есть socket?)

    А если без шуток - не изобретайте велосипед, все равно одно колесо будет квадратное, а другое треугольное.


    1. yakvenalex Автор
      08.10.2024 06:50

      Здесь имеется в виду обрывание импортов ради вызова sys, да, с Alembic я не знаком, но не нудаю что прям жизненно необходимо написать 2 импорта, потом вызов sys(в котором я не вижу ничего плохо, для меня странно только его расположение в коде), а потом только остальные импорты.

      Я считаю, что вам нужно разобраться в вопросе, а после делать замечания к таким кускам кода.

      В данном случае мы «обманули» Alembic, заставив его думать, что он находится в папке app. Это необходимо, чтобы Alembic правильно обрабатывал импорты и понимал, как они работают в контексте проекта.

      Благодаря такому подходу появилась возможность выполнять импорт класса Base и модели. То есть, это, действительно "жизненно важно", так как по другому Alembic работать не будет.

      А зачем тогда использовать FastAPI, если есть socket?)

      Судя по этой шутке вы и с FastApi не особо знакомы, да?


  1. DTPlayer
    08.10.2024 06:50

    Судя по этой шутке вы и с FastApi не особо знакомы, да?

    Почему? Много писал на FastAPI и считаю его хорошей библиотекой для создания API. Просто шутка заключается в том, что вы создаёте свой велосипед, а не используете готовый(который кстати стоит рядом и просит, чтобы вы его использовали)

    В данном случае мы «обманули» Alembic, заставив его думать, что он находится в папке app. Это необходимо, чтобы Alembic правильно обрабатывал импорты и понимал, как они работают в контексте проекта.

    Тут хорошо, тогда вопросов нет. Просто для меня это правда выглядело странно, но если так он работает - ладно.


    1. yakvenalex Автор
      08.10.2024 06:50

      FastApi в этом проекте закрыл вопрос подъема бота (вебхуки) и отрисовал страницы для MiniApp. Если я уже прикрутил его, почему тогда не использовать его по прямому назначению? По вашей анологии в гараже стоит и велосипед и мотоцикл. Зачем ехать на велосипеде?

      А как вы работаете с базами данных в своих FastApi приложениях без Alembic?


      1. DTPlayer
        08.10.2024 06:50

        А как вы работаете с базами данных в своих FastApi приложениях без Alembic?

        Я впринципе не использую алхимию как таковую. Вместо нее - tortoise orm, более удобный и не надо заворачивать каждый раз в сессию.

        Если я уже прикрутил его, почему тогда не использовать его по прямому назначению?

        А я говорил что это плохо? Просто я прошу не крутить велосипеды в js коде и все. А с FastAPI - просто шутка.


  1. alek0585
    08.10.2024 06:50

    В основе проекта — асинхронное взаимодействие с базой данных SQLite с помощью SQLAlchemy, что позволит нам реализовать масштабируемое и эффективное приложение.

    Я может быть не до конца осознаю масштабы масштабирования SQLite.. Хотелось бы узнать что за масштабирование тут имеется ввиду.


    1. yakvenalex Автор
      08.10.2024 06:50

      Смысл не в SQLite, а в SQLAlchemy. Достаточно ссылку заменить на подключение и все прекрасно будет работать с PostgreSQL. Тот же код. Возможно некорректно выразился)