Быстрый старт

От пустой директории до входа в админку с настоящей моделью на странице — примерно за пятнадцать минут.

Доступно с версии v0.1.0

Всё проходит целиком на SQLite, для которого не нужно ничего запускать заранее. Переход на PostgreSQL в конце — это одна строка.

1. Установка

mkdir shop && cd shop
uv init
uv add "fastfort[sqlalchemy,sqlite,cli]"

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

2. База данных, которой владеет ваш проект

FastFort никогда не открывает собственное соединение. Он принимает фабрику сессий, которую вы уже построили.

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass


engine = create_async_engine("sqlite+aiosqlite:///./shop.db")

# expire_on_commit=False is not a preference. The admin's views read attributes
# off an object after the unit of work has committed — the label in a success
# message, the primary key to redirect to. With the default, each of those is a
# synchronous refresh against an async session, which raises MissingGreenlet.
session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
    engine, expire_on_commit=False
)

3. Модели

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

import datetime as dt
from decimal import Decimal

import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column, relationship

from .db import Base


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(sa.String(255), unique=True)
    full_name: Mapped[str | None] = mapped_column(sa.String(160), default=None)
    hashed_password: Mapped[str] = mapped_column(sa.String(255), default="")
    is_active: Mapped[bool] = mapped_column(default=True)
    is_staff: Mapped[bool] = mapped_column(default=False)
    is_superuser: Mapped[bool] = mapped_column(default=False)
    created_at: Mapped[dt.datetime] = mapped_column(
        sa.DateTime(timezone=True), default=lambda: dt.datetime.now(dt.UTC)
    )

    # Every foreign-key cell, every picker option and every delete confirmation
    # renders this. Without it they all show `<User object at 0x…>`.
    def __str__(self) -> str:
        return self.email


class Category(Base):
    __tablename__ = "categories"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(sa.String(120), unique=True)

    def __str__(self) -> str:
        return self.name


class Product(Base):
    __tablename__ = "products"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(sa.String(200))
    description: Mapped[str | None] = mapped_column(sa.Text(), default=None)
    price: Mapped[Decimal] = mapped_column(sa.Numeric(12, 2), default=Decimal("0.00"))
    stock: Mapped[int] = mapped_column(default=0)
    is_active: Mapped[bool] = mapped_column(default=True)
    created_at: Mapped[dt.datetime] = mapped_column(
        sa.DateTime(timezone=True), default=lambda: dt.datetime.now(dt.UTC)
    )

    category_id: Mapped[int | None] = mapped_column(
        sa.ForeignKey("categories.id"), default=None
    )
    category: Mapped[Category | None] = relationship()

    def __str__(self) -> str:
        return self.name

Пять традиционных имён атрибутов на User — идентичность, пароль, активность, персонал, суперпользователь — определяются автоматически, поэтому set_user_model не требует аргументов. Назовите их иначе — и передавайте явно.

4. Регистрация в админке

@admin.register выполняется во время импорта и буферизует регистрацию — именно поэтому этот модуль импортирует модели и больше ничего. Модуль админки, импортирующий приложение, стал бы циклическим импортом в любом проекте.

from fastfort import admin

from .models import Category, Product, User


@admin.register(User, key="accounts.user")
class UserAdmin(admin.ModelAdmin):
    list_display = ("id", "email", "full_name", "is_active", "is_staff", "created_at")
    list_filter = ("is_active", "is_staff", "is_superuser")
    search_fields = ("email", "full_name")
    ordering = ("-created_at",)
    readonly_fields = ("created_at",)
    group_name = "Accounts"
    icon = "users"


@admin.register(Category, key="shop.category")
class CategoryAdmin(admin.ModelAdmin):
    list_display = ("id", "name")
    search_fields = ("name",)
    ordering = ("name",)
    group_name = "Shop"
    icon = "folder"


@admin.register(Product, key="shop.product")
class ProductAdmin(admin.ModelAdmin):
    list_display = ("id", "name", "category", "price", "stock", "is_active")
    list_filter = ("is_active", "category", "price")
    search_fields = ("name", "description")
    ordering = ("-created_at",)
    # Without this, rendering the category column costs one query per row.
    select_related = ("category",)
    readonly_fields = ("created_at",)
    group_name = "Shop"
    icon = "box"

    actions = ("delete", "archive")

    @admin.action("Archive", icon="box")
    async def archive(self, adapter, objects):
        for product in objects:
            await adapter.update(product, {"is_active": False})
        return f"{len(objects)} products archived."

5. Монтирование

from contextlib import asynccontextmanager

from fastapi import FastAPI

from fastfort import FastFort, FastFortSettings
from fastfort.orm.sqlalchemy import SQLAlchemyBackend

from app.db import Base, engine, session_factory
from app.models import User

fort = FastFort(
    settings=FastFortSettings(project_name="Shop"),
    backend=SQLAlchemyBackend(session_factory=session_factory, base=Base),
)
fort.set_user_model(User, identity_field="email")

# Imports app/admin.py so its decorators run. `fort.autodiscover("app")` is the
# alternative: it walks the package for every `admin.py` it can find.
fort.include_admin("app.admin")


@asynccontextmanager
async def lifespan(_: FastAPI):
    async with engine.begin() as connection:
        await connection.run_sync(Base.metadata.create_all)
    yield
    await engine.dispose()


app = FastAPI(lifespan=lifespan)
fort.mount(app)

У FastFortSettings нет значения secret_key по умолчанию, и это осознанное решение: фреймворк, поставляющий такое значение, гарантирует, что какое-нибудь развёртывание будет работать именно с ним. Сгенерируйте свой ключ и поместите его в переменные окружения.

6. Ключ, аккаунт, сервер

export FASTFORT_SECRET_KEY=$(uv run fastfort generate-secret)

FF_PASSWORD='choose-something-real' uv run fastfort createsuperuser \
    --app main:fort \
    --identity you@example.com \
    --password-env FF_PASSWORD \
    --no-input

uv run uvicorn main:app --reload

--password-env, а не --password: пароль, переданный как аргумент, виден в истории команд оболочки и в выводе ps.

Откройте http://127.0.0.1:8000/admin и войдите.

7. Проверка конфигурации

uv run fastfort check --app main:fort --deploy

Она собирает все проблемы вместо того, чтобы упасть на первой же, и завершается ненулевым кодом — так что ею можно блокировать развёртывание:

Shop · 3 model(s)
warn  security.cookie_secure=False sends the session cookie over plain HTTP.
      Set FASTFORT_SECURITY__COOKIE_SECURE=true and serve over HTTPS.
warn  debug=True exposes tracebacks and internal state. Set FASTFORT_DEBUG=false.

2 problem(s) found.

Переход на PostgreSQL

Одна строка, плюс extra-зависимость:

uv add "fastfort[postgres]"
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/shop")

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

Дальше