Yii2-Like Widgets для FastAPI: экономия 75% кода в админке — Блог
$ cat python-yii2-like-widget-pack/article.md

Yii2-Like Widgets для FastAPI: экономия 75% кода в админке

Предыстория

У нас есть проект LocalGuide — платформа для бронирования экскурсий на FastAPI + Next.js. Админка написана на Jinja2 шаблонах, и каждая страница — это:

  • Таблица с пагинацией и сортировкой
  • Поле поиска
  • Фильтры по полям
  • Формы создания/редактирования
  • Карточки детального просмотра

И каждый раз мы писали 80+ строк в роутере и 100+ строк в шаблоне. Одинаковые паттерны копировались из страницы в страницу.

# Типичный роутер до виджетов — 80+ строк
@router.get("/products")
async def products_page(request: Request, db: Session = Depends(get_db), ...):
    query = db.query(Product)
    if search:
        query = query.filter(Product.title.ilike(f"%{search}%"))
    if city_id:
        query = query.filter(Product.city_id == city_id)
    total = query.count()
    offset = (page - 1) * limit
    if sort == "price_asc":
        products = query.order_by(Product.price).offset(offset).limit(limit).all()
    elif sort == "price_desc":
        products = query.order_by(Product.price.desc()).offset(offset).limit(limit).all()
    # ... и так далее для каждого типа сортировки
    return render_template("admin/products.html", {
        "products": products,
        "pagination": {...},
        "total": total,
        # ... ещё 10 ключей
    })

Это невозможно поддерживать. Каждая новая сущность (города, достопримечательности, события) — копия того же кода.

Откуда идея

Те, кто работал с Yii2, знают его виджеты:

<?= GridView::widget([
    'dataProvider' => $provider,
    'filterModel' => $searchModel,
    'columns' => ['id', 'name', 'price', 'rating'],
]) ?>

Одна строка — и у тебя таблица с сортировкой, пагинацией, поиском, фильтрами. Мы захотели то же самое в Python.

Что получилось

Пакет admin_widgets — 5 Python модулей, 5 Jinja2 макросов и 80 строк JS.

src/admin_widgets/
├── __init__.py           # Инициализация, регистрация в Jinja2
├── pagination.py         # Расчёт страниц, prev/next
├── table.py              # AdminTable — аналог GridView
├── form.py               # AdminForm — аналог ActiveForm
├── detail.py             # AdminDetail — аналог DetailView
├── static/
│   └── widgets.js        # AJAX, debounce, сортировка
└── templates/admin/widgets/
    ├── table.html
    ├── pagination.html
    ├── form.html
    ├── detail.html
    └── filters.html

Быстро: 10 строк вместо 80

from src.admin_widgets import AdminTable

@router.get("/products")
async def products_page(request: Request, db: Session = Depends(get_db), ...):
    table = AdminTable(
        Product, db,
        columns=["external_id", "title", "price", "rating", "duration"],
        sortable=["title", "price", "rating"],
        searchable=["title"],
        labels={
            "external_id": "ID",
            "title": "Название",
            "price": "Цена",
        },
        formatters={
            "price": lambda v, r: f"{v} {r.currency}" if v else "—",
            "rating": lambda v, r: f"⭐ {v}" if v else "—",
        },
        row_url="/admin/products/{id}",
    )

    data = table.render(request, page=1, per_page=20, search=search)
    return render_template("admin/products.html", {"table": data})

Шаблон — 3 строки:

{% from "admin/widgets/table.html" import render_table %}
{{ render_table(table) }}

Результат

МетрикаДоПослеЭкономия
Код роутера~80 строк~15 строк81%
Код шаблона~100 строк~5 строк95%
Время на новую страницу~2 часа~15 минут87%
Повторное использование0%100%

Как это работает

1. AdminTable

Класс-обёртка над SQLAlchemy query. Цепочка:

query → filter(search) → filter(filters) → order_by(sort) → count() → paginate() → all()

Каждый шаг — отдельный метод. Форматтеры преобразуют данные перед рендером:

formatters={
    "price": lambda v, r: f"{v} {r.currency}",  # v = value, r = row object
    "rating": lambda v, r: f"⭐ {v}" if v else "—",
}

2. Pagination

Класс Pagination считает:

  • pages — общее число страниц
  • visible_pages() — номера для отображения (с ... для пропуска)
  • has_prev / has_next — есть ли prev/next
  • offset — для query.offset()
pag = Pagination(page=1, per_page=20, total=4594)
pag.pages          # 230
pag.visible_pages()  # [1, -1, 5, 6, 7, 8, 9, -2, 230]

3. Jinja2 макросы

Макрос render_table() рендерит:

<div class="admin-table-container">
  <div class="table-header">
    <h3>Продукты</h3>
    <input class="widget-search" placeholder="Поиск...">
  </div>
  <table class="widget-table">
    <thead>
      <th class="sortable" data-col="title">Название ↑</th>
      <th class="sortable" data-col="price">Цена</th>
    </thead>
    <tbody>
      <tr class="clickable" data-url="/admin/products/123">
        <td>123</td><td>Обзорная экскурсия</td><td>1500 RUB</td>
      </tr>
    </tbody>
  </table>
  <!-- Pagination -->
</div>

4. JavaScript — 80 строк

Чистый vanilla JS, без зависимостей:

// Сортировка — AJAX fetch + замена контента
document.querySelectorAll('.sortable[data-url]').forEach(th => {
    th.onclick = () => fetchWidget(th.dataset.url, th.closest('.admin-table-container'));
});

// Live-search с debounce 300ms
searchInput.oninput = debounce((e) => {
    const url = new URL(window.location);
    url.searchParams.set('search', e.target.value);
    fetchWidget(url.toString(), e.target.closest('.admin-table-container'));
}, 300);

// Пагинация — AJAX вместо reload
document.querySelectorAll('.pagination-btn[data-page]').forEach(btn => {
    btn.onclick = (e) => {
        e.preventDefault();
        const url = new URL(window.location);
        url.searchParams.set('page', btn.dataset.page);
        fetchWidget(url.toString(), btn.closest('.admin-table-container'));
    };
});

Graceful degradation

  • Без JS — всё работает через обычные GET (перезагрузка страницы)
  • С JS — AJAX fetch, плавная замена контента, debounce

Интеграция в FastAPI

# app.py
from src.admin_widgets import init_widgets
from src.backend.routes_admin import jinja_env

app = FastAPI()
init_widgets(app, jinja_env)  # Регистрирует виджеты и монтирует static

Инициализация делает 2 вещи:

  1. Добавляет AdminTable, AdminForm, AdminDetail, Pagination в jinja_env.globals
  2. Монтирует widgets.js на /admin-widgets/widgets.js

Формы и DetailView

Кроме таблиц, есть виджеты форм и карточек:

# AdminForm — авто-генерация формы из модели
form = AdminForm(City, obj=city_instance,
    fields=["name", "latitude", "longitude"],
    readonly=["external_id"])
data = form.render()
# AdminDetail — карточка объекта
detail = AdminDetail(city,
    attributes=["id", "name", "country_name", "latitude", "longitude"],
    formatters={"name": lambda v: v.upper()})
data = detail.render()

Что дальше

  • Перевести все существующие страницы (products, cities, logs, sights) на виджеты
  • Добавить фильтр-чипы (как в интернет-магазинах)
  • Экспорт таблицы в CSV/Excel
  • Inline-редактирование ячеек (как в Yii2 editable columns)
  • Вынести в отдель pip-пакет для переиспользования

Итого

ФайлСтрок
pagination.py72
table.py134
form.py82
detail.py58
__init__.py32
Python всего~378
Шаблоны~200
JS~80
CSS (inline в layout)~30
Итого~688 строк

Один раз написали — используем на каждой странице админки. Каждая новая страница = 15 минут вместо 2 часов.


Репозиторий: LocalGuide / alice-v3 Стек: FastAPI + SQLAlchemy + Jinja2 + Vanilla JS Вдохновение: Yii2 GridView, ActiveForm, DetailView