FastAdmin | Documentation

  • Created: 7 March 2023
  • Updated: 19 February 2026

Introduction

FastAdmin is an easy-to-use admin dashboard for FastAPI, Django, and Flask, inspired by Django Admin.

FastAdmin is built with relationships in mind and admiration for Django Admin. Its design focuses on making it as easy as possible to configure your admin dashboard for FastAPI, Django, or Flask.

FastAdmin aims to be minimal, functional, and familiar.


Getting Started

If you have questions beyond this documentation, feel free to email us.

Installation

Follow the steps below to set up FastAdmin:

Install the package with pip:

On zsh and macOS, use quotes: pip install 'fastadmin[fastapi,django]'

  

pip install fastadmin[fastapi,django]        # FastAPI with Django ORM
pip install fastadmin[fastapi,tortoise-orm]  # FastAPI with Tortoise ORM
pip install fastadmin[fastapi,pony]          # FastAPI with Pony ORM
pip install fastadmin[fastapi,sqlalchemy]    # FastAPI with SQLAlchemy (includes greenlet)
pip install fastadmin[django]                # Django with Django ORM
pip install fastadmin[django,pony]           # Django with Pony ORM
pip install fastadmin[flask,sqlalchemy]      # Flask with SQLAlchemy (includes greenlet)

  

Or install with Poetry:

  

poetry add 'fastadmin[fastapi,django]'
poetry add 'fastadmin[fastapi,tortoise-orm]'
poetry add 'fastadmin[fastapi,pony]'
poetry add 'fastadmin[fastapi,sqlalchemy]'
poetry add 'fastadmin[django]'
poetry add 'fastadmin[django,pony]'
poetry add 'fastadmin[flask,sqlalchemy]'

  

When using SQLAlchemy, the greenlet package is required (included in the fastadmin[sqlalchemy] extra).

Configure the required settings with environment variables:

You can add these variables to a .env file and load them with python-dotenv. See all settings in the full documentation.

  

export ADMIN_USER_MODEL=User
export ADMIN_USER_MODEL_USERNAME_FIELD=username
export ADMIN_SECRET_KEY=secret_key

  

Quick Tutorial

Set up FastAdmin for your framework

  
from fastapi import FastAPI

from fastadmin import fastapi_app as admin_app

app = FastAPI()

app.mount("/admin", admin_app)

  
  
from django.urls import path

from fastadmin import get_django_admin_urls as get_admin_urls
from fastadmin.settings import settings

urlpatterns = [
    path(f"{settings.ADMIN_PREFIX}/", get_admin_urls()),
]

  
  
from flask import Flask

from fastadmin import flask_app as admin_app

app = Flask(__name__)

app.register_blueprint(admin_app, url_prefix="/admin")

  

Register ORM models

  
import typing as tp
from uuid import UUID

import bcrypt
from tortoise import fields
from tortoise.models import Model

from fastadmin import TortoiseModelAdmin, WidgetType, register


class User(Model):
    username = fields.CharField(max_length=255, unique=True)
    hash_password = fields.CharField(max_length=255)
    is_superuser = fields.BooleanField(default=False)
    is_active = fields.BooleanField(default=False)
    avatar_url = fields.TextField(null=True)

    def __str__(self):
        return self.username


@register(User)
class UserAdmin(TortoiseModelAdmin):
    exclude = ("hash_password",)
    list_display = ("id", "username", "is_superuser", "is_active")
    list_display_links = ("id", "username")
    list_filter = ("id", "username", "is_superuser", "is_active")
    search_fields = ("username",)
    formfield_overrides = {  # noqa: RUF012
        "username": (WidgetType.SlugInput, {"required": True}),
        "password": (WidgetType.PasswordInput, {"passwordModalForm": True}),
        "avatar_url": (
            WidgetType.Upload,
            {
                "required": False,
                # Disable crop image for upload field
                # "disableCropImage": True,
            },
        ),
    }

    async def authenticate(self, username: str, password: str) -> int | None:
        user = await self.model_cls.filter(username=username, is_superuser=True).first()
        if not user:
            return None
        if not bcrypt.checkpw(password.encode(), user.hash_password.encode()):
            return None
        return user.id

    async def change_password(self, id: UUID | int | str, password: str) -> None:
        user = await self.model_cls.filter(id=id).first()
        if not user:
            return
        user.hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
        await user.save(update_fields=("hash_password",))

    async def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None:
        # convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it)
        setattr(obj, field, base64)
        await obj.save(update_fields=(field,))

  
  
from django.db import models

from fastadmin import DjangoModelAdmin, register


class User(models.Model):
    username = models.CharField(max_length=255, unique=True)
    hash_password = models.CharField(max_length=255)
    is_superuser = models.BooleanField(default=False)
    is_active = models.BooleanField(default=False)

    def __str__(self):
        return self.username


@register(User)
class UserAdmin(DjangoModelAdmin):
    exclude = ("hash_password",)
    list_display = ("id", "username", "is_superuser", "is_active")
    list_display_links = ("id", "username")
    list_filter = ("id", "username", "is_superuser", "is_active")
    search_fields = ("username",)

    def authenticate(self, username, password):
        obj = User.objects.filter(username=username, is_superuser=True).first()
        if not obj:
            return None
        if not obj.check_password(password):
            return None
        return obj.id

  
  
import typing as tp
import uuid

import bcrypt
from sqlalchemy import Boolean, Integer, String, Text, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

from fastadmin import SqlAlchemyModelAdmin, register

sqlalchemy_engine = create_async_engine(
    "sqlite+aiosqlite:///:memory:",
    echo=True,
)
sqlalchemy_sessionmaker = async_sessionmaker(sqlalchemy_engine, expire_on_commit=False)


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, nullable=False)
    username: Mapped[str] = mapped_column(String(length=255), nullable=False)
    hash_password: Mapped[str] = mapped_column(String(length=255), nullable=False)
    is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
    is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
    avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True)

    def __str__(self):
        return self.username


@register(User, sqlalchemy_sessionmaker=sqlalchemy_sessionmaker)
class UserAdmin(SqlAlchemyModelAdmin):
    exclude = ("hash_password",)
    list_display = ("id", "username", "is_superuser", "is_active")
    list_display_links = ("id", "username")
    list_filter = ("id", "username", "is_superuser", "is_active")
    search_fields = ("username",)

    async def authenticate(self, username: str, password: str) -> uuid.UUID | int | None:
        sessionmaker = self.get_sessionmaker()
        async with sessionmaker() as session:
            query = select(self.model_cls).filter_by(username=username, password=password, is_superuser=True)
            result = await session.scalars(query)
            obj = result.first()
            if not obj:
                return None
            if not bcrypt.checkpw(password.encode(), obj.hash_password.encode()):
                return None
            return obj.id

    async def change_password(self, id: uuid.UUID | int | str, password: str) -> None:
        sessionmaker = self.get_sessionmaker()
        async with sessionmaker() as session:
            hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
            query = update(self.model_cls).where(User.id.in_([id])).values(hash_password=hash_password)
            await session.execute(query)
            await session.commit()

    async def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None:
        sessionmaker = self.get_sessionmaker()
        async with sessionmaker() as session:
            # convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it)
            query = update(self.model_cls).where(User.id.in_([obj.id])).values(**{field: base64})
            await session.execute(query)
            await session.commit()

  
  
import typing as tp
import uuid

import bcrypt
from pony.orm import Database, LongStr, Optional, PrimaryKey, Required, commit, db_session

from fastadmin import PonyORMModelAdmin, register

db = Database()
db.bind(provider="sqlite", filename=":memory:", create_db=True)


class User(db.Entity):  # type: ignore [name-defined]
    _table_ = "user"
    id = PrimaryKey(int, auto=True)
    username = Required(str)
    hash_password = Required(str)
    is_superuser = Required(bool, default=False)
    is_active = Required(bool, default=False)
    avatar_url = Optional(LongStr, nullable=True)

    def __str__(self):
        return self.username


@register(User)
class UserAdmin(PonyORMModelAdmin):
    exclude = ("hash_password",)
    list_display = ("id", "username", "is_superuser", "is_active")
    list_display_links = ("id", "username")
    list_filter = ("id", "username", "is_superuser", "is_active")
    search_fields = ("username",)

    @db_session
    def authenticate(self, username: str, password: str) -> uuid.UUID | int | None:
        obj = next((f for f in User.select(username=username, password=password, is_superuser=True)), None)  # fmt: skip
        if not obj:
            return None
        if not bcrypt.checkpw(password.encode(), obj.hash_password.encode()):
            return None
        return obj.id

    @db_session
    def change_password(self, id: uuid.UUID | int, password: str) -> None:
        obj = next((f for f in self.model_cls.select(id=id)), None)
        if not obj:
            return
        hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
        obj.hash_password = hash_password
        commit()

    @db_session
    def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None:
        obj = next((f for f in self.model_cls.select(id=obj.id)), None)
        if not obj:
            return
        # convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it)
        setattr(obj, field, base64)
        commit()

  

Settings

The following settings have default values:

Set environment variables or create a .env file and load it with the python-dotenv package.

  
class Settings:
    """Settings"""

    # This value is the prefix you used for mounting FastAdmin app for FastAPI.
    ADMIN_PREFIX: str = os.getenv("ADMIN_PREFIX", "admin")

    # This value is the site name on sign-in page and on header.
    ADMIN_SITE_NAME: str = os.getenv("ADMIN_SITE_NAME", "FastAdmin")

    # This value is the logo path on sign-in page.
    ADMIN_SITE_SIGN_IN_LOGO: str = os.getenv("ADMIN_SITE_SIGN_IN_LOGO", "/admin/static/images/sign-in-logo.svg")

    # This value is the logo path on header.
    ADMIN_SITE_HEADER_LOGO: str = os.getenv("ADMIN_SITE_HEADER_LOGO", "/admin/static/images/header-logo.svg")

    # This value is the favicon path.
    ADMIN_SITE_FAVICON: str = os.getenv("ADMIN_SITE_FAVICON", "/admin/static/images/favicon.png")

    # This value is the primary color for FastAdmin.
    ADMIN_PRIMARY_COLOR: str = os.getenv("ADMIN_PRIMARY_COLOR", "#009485")

    # This value is the session id key to store session id in http only cookies.
    ADMIN_SESSION_ID_KEY: str = os.getenv("ADMIN_SESSION_ID_KEY", "admin_session_id")

    # This value is the expired_at period (in sec) for session id.
    ADMIN_SESSION_EXPIRED_AT: int = os.getenv("ADMIN_SESSION_EXPIRED_AT", 144000)  # in sec

    # This value is the date format for JS widgets.
    ADMIN_DATE_FORMAT: str = os.getenv("ADMIN_DATE_FORMAT", "YYYY-MM-DD")

    # This value is the datetime format for JS widgets.
    ADMIN_DATETIME_FORMAT: str = os.getenv("ADMIN_DATETIME_FORMAT", "YYYY-MM-DD HH:mm")

    # This value is the time format for JS widgets.
    ADMIN_TIME_FORMAT: str = os.getenv("ADMIN_TIME_FORMAT", "HH:mm:ss")

    # This value is the name for User db/orm model class for authentication.
    ADMIN_USER_MODEL: str = os.getenv("ADMIN_USER_MODEL")

    # This value is the username field for User db/orm model for for authentication.
    ADMIN_USER_MODEL_USERNAME_FIELD: str = os.getenv("ADMIN_USER_MODEL_USERNAME_FIELD")

    # This value is the key to securing signed data - it is vital you keep this secure,
    # or attackers could use it to generate their own signed values.
    ADMIN_SECRET_KEY: str = os.getenv("ADMIN_SECRET_KEY")

    # This value disables the crop image feature in FastAdmin.
    ADMIN_DISABLE_CROP_IMAGE: bool = os.getenv("ADMIN_DISABLE_CROP_IMAGE", False)

  

Settings without default values are required.


Dashboard Widget Admins

Registering Widgets

Register dashboard widgets

  
import datetime

from tortoise import Tortoise, fields
from tortoise.models import Model

from fastadmin import DashboardWidgetAdmin, DashboardWidgetType, WidgetType, register_widget


class DashboardUser(Model):
    username = fields.CharField(max_length=255, unique=True)
    hash_password = fields.CharField(max_length=255)
    is_superuser = fields.BooleanField(default=False)
    is_active = fields.BooleanField(default=False)

    def __str__(self):
        return self.username


@register_widget
class UsersDashboardWidgetAdmin(DashboardWidgetAdmin):
    title = "Users"
    dashboard_widget_type = DashboardWidgetType.ChartLine

    x_field = "date"
    x_field_filter_widget_type = WidgetType.DatePicker
    x_field_filter_widget_props: dict[str, str] = {"picker": "month"}  # noqa: RUF012
    x_field_periods = ["day", "week", "month", "year"]  # noqa: RUF012

    y_field = "count"

    async def get_data(
        self,
        min_x_field: str | None = None,
        max_x_field: str | None = None,
        period_x_field: str | None = None,
    ) -> dict:
        conn = Tortoise.get_connection("default")

        if not min_x_field:
            min_x_field_date = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(days=360)
        else:
            min_x_field_date = datetime.datetime.fromisoformat(min_x_field)
        if not max_x_field:
            max_x_field_date = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1)
        else:
            max_x_field_date = datetime.datetime.fromisoformat(max_x_field)

        if not period_x_field or period_x_field not in (self.x_field_periods or []):
            period_x_field = "month"

        results = await conn.execute_query_dict(
            """
                SELECT
                    to_char(date_trunc($1, "user"."created_at")::date, 'dd/mm/yyyy') "date",
                    COUNT("user"."id") "count"
                FROM "user"
                WHERE "user"."created_at" >= $2 AND "user"."created_at" <= $3
                GROUP BY "date" ORDER BY "date"
            """,
            [period_x_field, min_x_field_date, max_x_field_date],
        )
        return {
            "results": results,
            "min_x_field": min_x_field_date.isoformat(),
            "max_x_field": max_x_field_date.isoformat(),
            "period_x_field": period_x_field,
        }

  
  
import datetime

from django.db import connection, models

from fastadmin import DashboardWidgetAdmin, DashboardWidgetType, WidgetType, register_widget


class DashboardUser(models.Model):
    username = models.CharField(max_length=255, unique=True)
    hash_password = models.CharField(max_length=255)
    is_superuser = models.BooleanField(default=False)
    is_active = models.BooleanField(default=False)

    def __str__(self):
        return self.username


@register_widget
class UsersDashboardWidgetAdmin(DashboardWidgetAdmin):
    title = "Users"
    dashboard_widget_type = DashboardWidgetType.ChartLine

    x_field = "date"
    x_field_filter_widget_type = WidgetType.DatePicker
    x_field_filter_widget_props: dict[str, str] = {"picker": "month"}  # noqa: RUF012
    x_field_periods = ["day", "week", "month", "year"]  # noqa: RUF012

    y_field = "count"

    def get_data(  # type: ignore [override]
        self,
        min_x_field: str | None = None,
        max_x_field: str | None = None,
        period_x_field: str | None = None,
    ) -> dict:
        def dictfetchall(cursor):
            columns = [col[0] for col in cursor.description]
            return [dict(zip(columns, row, strict=True)) for row in cursor.fetchall()]

        with connection.cursor() as c:
            if not min_x_field:
                min_x_field_date = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(days=360)
            else:
                min_x_field_date = datetime.datetime.fromisoformat(min_x_field)
            if not max_x_field:
                max_x_field_date = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1)
            else:
                max_x_field_date = datetime.datetime.fromisoformat(max_x_field)

            if not period_x_field or period_x_field not in (self.x_field_periods or []):
                period_x_field = "month"

            c.execute(
                """
                    SELECT
                        to_char(date_trunc($1, "user"."created_at")::date, 'dd/mm/yyyy') "date",
                        COUNT("user"."id") "count"
                    FROM "user"
                    WHERE "user"."created_at" >= $2 AND "user"."created_at" <= $3
                    GROUP BY "date" ORDER BY "date"
                """,
                [period_x_field, min_x_field_date, max_x_field_date],
            )
            results = dictfetchall(c)
            return {
                "results": results,
                "min_x_field": min_x_field_date.isoformat(),
                "max_x_field": max_x_field_date.isoformat(),
                "period_x_field": period_x_field,
            }

  

See the Tortoise ORM example above.

See the Tortoise ORM example above.

Methods and Attributes

The following methods and attributes are available for dashboard widget admins:

  
class DashboardWidgetAdmin:
    title: str
    dashboard_widget_type: DashboardWidgetType
    x_field: str
    y_field: str | None = None
    series_field: str | None = None
    x_field_filter_widget_type: WidgetType | None = None
    x_field_filter_widget_props: dict[str, Any] | None = None
    x_field_periods: list[str] | None = None

    async def get_data(
        self,
        min_x_field: str | None = None,
        max_x_field: str | None = None,
        period_x_field: str | None = None,
    ) -> dict[str, Any]:
        """This method is used to get data for dashboard widget.

        :params min_x_field: A minimum value for x_field.
        :params max_x_field: A maximum value for x_field.
        :params period_x_field: A period value for x_field.
        :return: A dict with data.
        """
        raise NotImplementedError

  

See antd charts for x_field_filter_widget_props.

Chart Types

The FastAdmin dashboard supports the following widget types:

  
class DashboardWidgetType(str, Enum):
    """Dashboard Widget type"""

    ChartLine = "ChartLine"
    ChartArea = "ChartArea"
    ChartColumn = "ChartColumn"
    ChartBar = "ChartBar"
    ChartPie = "ChartPie"

  

See antd charts for more details (e.g. how they look).


Model Admins

Registering Models

  
import typing as tp
from uuid import UUID

import bcrypt
from tortoise import fields
from tortoise.models import Model

from fastadmin import TortoiseModelAdmin, WidgetType, action, register


class ModelUser(Model):
    username = fields.CharField(max_length=255, unique=True)
    hash_password = fields.CharField(max_length=255)
    is_superuser = fields.BooleanField(default=False)
    is_active = fields.BooleanField(default=False)

    avatar_url = fields.TextField(null=True)

    def __str__(self):
        return self.username


@register(ModelUser)
class UserAdmin(TortoiseModelAdmin):
    list_display = ("username", "is_superuser", "is_active")
    list_display_links = ("username",)
    list_filter = (
        "username",
        "is_superuser",
        "is_active",
    )
    search_fields = (
        "id",
        "username",
    )
    fieldsets = (
        (None, {"fields": ("username", "hash_password")}),
        ("Permissions", {"fields": ("is_active", "is_superuser")}),
    )
    formfield_overrides = {  # noqa: RUF012
        "username": (WidgetType.SlugInput, {"required": True}),
        "password": (WidgetType.PasswordInput, {"passwordModalForm": True}),
        "avatar_url": (
            WidgetType.Upload,
            {
                "required": False,
                # Disable crop image for upload field
                # "disableCropImage": True,
            },
        ),
    }
    actions = (
        *TortoiseModelAdmin.actions,
        "activate",
        "deactivate",
    )

    async def authenticate(self, username: str, password: str) -> int | None:
        user = await self.model_cls.filter(phone=username, is_superuser=True).first()
        if not user:
            return None
        if not bcrypt.checkpw(password.encode(), user.hash_password.encode()):
            return None
        return user.id

    async def change_password(self, id: UUID | int | str, password: str) -> None:
        user = await self.model_cls.filter(id=id).first()
        if not user:
            return
        user.hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
        await user.save(update_fields=("hash_password",))

    @action(description="Set as active")
    async def activate(self, ids: list[int]) -> None:
        await self.model_cls.filter(id__in=ids).update(is_active=True)

    @action(description="Deactivate")
    async def deactivate(self, ids: list[int]) -> None:
        await self.model_cls.filter(id__in=ids).update(is_active=False)

    async def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None:
        # convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it)
        setattr(obj, field, base64)
        await obj.save(update_fields=(field,))

  

See example for Tortoise ORM

See the Tortoise ORM example

See the Tortoise ORM example

Authentication

You must implement authenticate and change_password in the model admin for the User model. See the example above.

Methods and Attributes

The following methods and attributes are available for model admins:

  
class BaseModelAdmin:
    """Base class for model admin"""

    # Use it only if you use several orms in your project.
    model_name_prefix: str | None = None

    # A list of actions to make available on the change list page.
    # You have to implement methods with names like action_name in your ModelAdmin class and decorate them with @action decorator.
    # Example of usage:
    #
    # actions = ("make_published",)
    # @action(
    #     description="Mark selected stories as published",
    # )
    # async def make_published(self, objs: list[Any]) -> None:
    #     ...
    actions: Sequence[str] = ()

    # Controls where on the page the actions bar appears.
    # By default, the admin changelist displays actions at the top of the page (actions_on_top = False; actions_on_bottom = True).
    # Example of usage: actions_on_top = True
    actions_on_top: bool = False

    # Controls where on the page the actions bar appears.
    # By default, the admin changelist displays actions at the top of the page (actions_on_top = False; actions_on_bottom = True).
    # Example of usage: actions_on_bottom = False
    actions_on_bottom: bool = True

    # Controls whether a selection counter is displayed next to the action dropdown. By default, the admin changelist will display it
    # Example of usage: actions_selection_counter = False
    actions_selection_counter: bool = True

    # Not supported setting
    # date_hierarchy

    # This attribute overrides the default display value for record's fields that are empty (None, empty string, etc.). The default value is - (a dash).
    # Example of usage: empty_value_display = "N/A"
    empty_value_display: str = "-"

    # This attribute, if given, should be a list of field names to exclude from the form.
    # Example of usage: exclude = ("password", "otp")
    exclude: Sequence[str] = ()

    # Use the fields option to make simple layout changes in the forms on the “add” and “change” pages
    # such as showing only a subset of available fields, modifying their order, or grouping them into rows.
    # For more complex layout needs, see the fieldsets option.
    # Example of usage: fields = ("id", "mobile_number", "email", "is_superuser", "is_active", "created_at")
    fields: Sequence[str] = ()

    # Set fieldsets to control the layout of admin “add” and “change” pages.
    # fieldsets is a list of two-tuples, in which each two-tuple represents a fieldset on the admin form page. (A fieldset is a “section” of the form.)
    fieldsets: Sequence[tuple[str | None, dict[str, Sequence[str]]]] = ()

    # By default, a ManyToManyField is displayed in the admin dashboard with a select multiple.
    # However, multiple-select boxes can be difficult to use when selecting many items.
    # Adding a ManyToManyField to this list will instead use a nifty unobtrusive JavaScript “filter” interface that allows searching within the options.
    # The unselected and selected options appear in two boxes side by side. See filter_vertical to use a vertical interface.
    # Example of usage: filter_horizontal = ("groups", "user_permissions")
    filter_horizontal: Sequence[str] = ()

    # Same as filter_horizontal, but uses a vertical display of the filter interface with the box of unselected options appearing above the box of selected options.
    # Example of usage: filter_vertical = ("groups", "user_permissions")
    filter_vertical: Sequence[str] = ()

    # Not supported setting
    # form

    # This provides a quick-and-dirty way to override some of the Field options for use in the admin.
    # formfield_overrides is a dictionary mapping a field class to a dict
    # of arguments to pass to the field at construction time.
    # Example of usage:
    # formfield_overrides = {
    #     "description": (WidgetType.RichTextArea, {})
    # }
    formfield_overrides: dict[str, tuple[WidgetType, dict]] = {}  # noqa: RUF012

    # Set list_display to control which fields are displayed on the list page of the admin.
    # If you don't set list_display, the admin site will display a single column that displays the __str__() representation of each object
    # Example of usage: list_display = ("id", "mobile_number", "email", "is_superuser", "is_active", "created_at")
    list_display: Sequence[str] = ()

    # Use list_display_links to control if and which fields in list_display should be linked to the “change” page for an object.
    # Example of usage: list_display_links = ("id", "mobile_number", "email")
    list_display_links: Sequence[str] = ()

    # A dictionary containing the field names and the corresponding widget type and
    # column widths (px, %) for the list view.
    # Example of usage:
    # list_display_widths = {
    #     "id": "100px",
    # }
    list_display_widths: dict[str, str] = {}  # noqa: RUF012

    # Set list_filter to activate filters in the tabel columns of the list page of the admin.
    # Example of usage: list_filter = ("is_superuser", "is_active", "created_at")
    list_filter: Sequence[str] = ()

    # By default, applied filters are preserved on the list view after creating, editing, or deleting an object.
    # You can have filters cleared by setting this attribute to False.
    # Example of usage: preserve_filters = False
    preserve_filters: bool = True

    # Set list_max_show_all to control how many items can appear on a “Show all” admin change list page.
    # The admin will display a “Show all” link on the change list only if the total result count is less than or equal to this setting. By default, this is set to 200.
    # Example of usage: list_max_show_all = 100
    list_max_show_all: int = 200

    # Set list_per_page to control how many items appear on each paginated admin list page. By default, this is set to 10.
    # Example of usage: list_per_page = 50
    list_per_page = 10

    # Set list_select_related to tell ORM to use select_related() in retrieving the list of objects on the admin list page.
    # This can save you a bunch of database queries.
    # Example of usage: list_select_related = ("user",)
    list_select_related: Sequence[str] = ()

    # Set ordering to specify how lists of objects should be ordered in the admin views.
    # This should be a list or tuple in the same format as a model's ordering parameter.
    # Example of usage: ordering = ("-created_at",)
    ordering: Sequence[str] = ()

    # Not supported setting
    # paginator

    # When set, the given fields will use a bit of JavaScript to populate from the fields assigned.
    # The main use for this functionality is
    # to automatically generate the value for SlugField fields from one or more other fields.
    # The generated value is produced by concatenating the values of the source fields,
    # and then by transforming that result into a valid slug
    # (e.g. substituting dashes for spaces and lowercasing ASCII letters).
    # prepopulated_fields: dict[str, Sequence[str]] = {}

    # By default, FastAPI admin uses a select-box interface (select) for fields that are ForeignKey or have choices set.
    # If a field is present in radio_fields, FastAPI admin will use a radio-button interface instead.
    # Example of usage: radio_fields = ("user",)
    radio_fields: Sequence[str] = ()

    # Not supported setting (all fk, m2m uses select js widget as default)
    # autocomplete_fields

    # By default, FastAPI admin uses a select-box interface (select) for fields that are ForeignKey.
    # Sometimes you don't want to incur the overhead of having to select all the related instances to display in the drop-down.
    # raw_id_fields is a list of fields you would like to change into an Input widget for either a ForeignKey or ManyToManyField.
    # Example of usage: raw_id_fields = ("user",)
    raw_id_fields: Sequence[str] = ()

    # By default the admin shows all fields as editable.
    # Any fields in this option (which should be a list or tuple) will display its data as-is and non-editable.
    # Example of usage: readonly_fields = ("created_at",)
    readonly_fields: Sequence[str] = ()

    # Set search_fields to enable a search box on the admin list page.
    # This should be set to a list of field names that will be searched whenever somebody submits a search query in that text box.
    # Example of usage: search_fields = ("mobile_number", "email")
    search_fields: Sequence[str] = ()

    # Set search_help_text to specify a descriptive text for the search box which will be displayed below it.
    # Example of usage: search_help_text = "Search by mobile number or email"
    search_help_text: str = ""

    # Set show_full_result_count to control whether the full count of objects should be displayed
    # on a filtered admin page (e.g. 99 results (103 total)).
    # If this option is set to False, a text like 99 results (Show all) is displayed instead.
    # Example of usage: show_full_result_count = True
    show_full_result_count: bool = False

    # By default, the list page allows sorting by all model fields
    # If you want to disable sorting for some columns, set sortable_by to a collection (e.g. list, tuple, or set)
    # of the subset of list_display that you want to be sortable.
    # An empty collection disables sorting for all columns.
    # Example of usage: sortable_by = ("mobile_number", "email")
    sortable_by: Sequence[str] = ()

    # An override to the verbose_name from the model's inner Meta class.
    verbose_name: str | None = None

    # An override to the verbose_name_plural from the model's inner Meta class.
    verbose_name_plural: str | None = None

    def __init__(self, model_cls: Any):
        """This method is used to initialize admin class.

        :params model_cls: an orm/db model class.
        """
        self.model_cls = model_cls

    @staticmethod
    def get_model_pk_name(orm_model_cls: Any) -> str:
        """This method is used to get model pk name.

        :return: A str.
        """
        raise NotImplementedError

    def get_model_fields_with_widget_types(
        self,
        with_m2m: bool | None = None,
        with_upload: bool | None = None,
    ) -> list[ModelFieldWidgetSchema]:
        """This method is used to get model fields with widget types.

        :params with_m2m: a flag to include m2m fields.
        :params with_upload: a flag to include upload fields.
        :return: A list of ModelFieldWidgetSchema.
        """
        raise NotImplementedError

    async def orm_get_list(
        self,
        offset: int | None = None,
        limit: int | None = None,
        search: str | None = None,
        sort_by: str | None = None,
        filters: dict | None = None,
    ) -> tuple[list[Any], int]:
        """This method is used to get list of orm/db model objects.

        :params offset: an offset for pagination.
        :params limit: a limit for pagination.
        :params search: a search query.
        :params sort_by: a sort by field name.
        :params filters: a dict of filters.
        :return: A tuple of list of objects and total count.
        """
        raise NotImplementedError

    async def orm_get_obj(self, id: UUID | int | str) -> Any | None:
        """This method is used to get orm/db model object.

        :params id: an id of object.
        :return: An object or None.
        """
        raise NotImplementedError

    async def orm_serialize_obj_by_id(self, id: UUID | int | str) -> dict | None:
        """Serialize object by id (e.g. in a single session to avoid DetachedInstanceError).

        Override in ORM layer to run get+serialize inside one session when needed.
        """
        obj = await self.orm_get_obj(id)
        if obj is None:
            return None
        return await self.serialize_obj(obj)

    async def orm_save_obj(self, id: UUID | Any | None, payload: dict) -> Any:
        """This method is used to save orm/db model object.

        :params id: an id of object.
        :params payload: a dict of payload.
        :return: An object.
        """
        raise NotImplementedError

    async def orm_delete_obj(self, id: UUID | int | str) -> None:
        """This method is used to delete orm/db model object.

        :params id: an id of object.
        :return: None.
        """
        raise NotImplementedError

    async def orm_get_m2m_ids(self, obj: Any, field: str) -> list[int | UUID]:
        """This method is used to get m2m ids.

        :params obj: an object.
        :params field: a m2m field name.

        :return: A list of ids.
        """
        raise NotImplementedError

    async def orm_save_m2m_ids(self, obj: Any, field: str, ids: list[int | UUID]) -> None:
        """This method is used to get m2m ids.

        :params obj: an object.
        :params field: a m2m field name.
        :params ids: a list of ids.

        :return: A list of ids.
        """
        raise NotImplementedError

    async def orm_save_upload_field(self, obj: Any, field: str, base64: str) -> None:
        """This method is used to save upload field.

        :params obj: an object.
        :params field: a m2m field name.
        :params base64: a base64 string.

        :return: A list of ids.
        """
        raise NotImplementedError

    @classmethod
    def get_sessionmaker(cls) -> Any:
        """This method is used to get db session maker for sqlalchemy.

        :return: A db session maker.
        """
        return cls.db_session_maker

    @classmethod
    def set_sessionmaker(cls, db_session_maker: Any) -> None:
        """This method is used to set db session maker for sqlalchemy.

        :params db_session: a db session maker.
        :return: None.
        """
        cls.db_session_maker = db_session_maker

    def get_fields_for_serialize(self) -> set[str]:
        """This method is used to get fields for serialize.

        :return: A set of fields.
        """
        fields = self.get_model_fields_with_widget_types()
        fields_for_serialize = {field.name for field in fields}
        if self.fields:
            fields_for_serialize &= set(self.fields)
        if self.exclude:
            fields_for_serialize -= set(self.exclude)
        if self.list_display:
            fields_for_serialize |= set(self.list_display)
        return fields_for_serialize

    def resolve_sort_by(self, sort_by: str) -> str:
        """Resolve sort_by to the actual ORM ordering expression.

        For display columns with sorter=str, returns that string; otherwise returns sort_by.
        """
        if not sort_by:
            return sort_by
        field_name = sort_by.lstrip("-")
        display_fn = getattr(self, field_name, None)
        if display_fn and getattr(display_fn, "is_display", False):
            sorter = getattr(display_fn, "sorter", False)
            if isinstance(sorter, str):
                prefix = "-" if sort_by.startswith("-") else ""
                return f"{prefix}{sorter}"
        return sort_by

    async def serialize_obj_attributes(
        self, obj: Any, attributes_to_serizalize: list[ModelFieldWidgetSchema]
    ) -> dict[str, Any]:
        """Serialize orm model obj attribute to dict.

        :params obj: an object.
        :params attributes_to_serizalize: a list of attributes to serialize.
        :return: A dict of serialized attributes.
        """
        serialized_dict = {field.name: getattr(obj, field.column_name) for field in attributes_to_serizalize}
        if inspect.iscoroutinefunction(obj.__str__):
            str_fn = obj.__str__
        else:
            str_fn = sync_to_async(obj.__str__)
        serialized_dict["__str__"] = await str_fn()
        return serialized_dict

    async def _serialize_obj_after_save(self, obj: Any) -> dict:
        """Serialize object after save; re-fetch if detached (e.g. SQLAlchemy after commit)."""
        try:
            return await self.serialize_obj(obj)
        except Exception as exc:
            # SQLAlchemy DetachedInstanceError when session is closed after commit
            if exc.__class__.__name__ != "DetachedInstanceError":
                raise
            pk_name = self.get_model_pk_name(self.model_cls)
            pk = getattr(obj, pk_name, None)
            if pk is None:
                raise
            result = await self.orm_serialize_obj_by_id(pk)
            if result is None:
                raise
            return result

    async def serialize_obj(self, obj: Any, list_view: bool = False) -> dict:
        """Serialize orm model obj to dict.

        :params obj: an object.
        :params exclude_fields: a list of fields to exclude.
        :return: A dict.
        """
        fields = self.get_model_fields_with_widget_types()
        fields_for_serialize = self.get_fields_for_serialize()

        obj_dict = {}
        attributes_to_serizalize = []
        for field in fields:
            if field.name not in fields_for_serialize:
                continue
            if field.is_m2m and list_view:
                continue
            if field.is_m2m:
                obj_dict[field.name] = await self.orm_get_m2m_ids(obj, field.column_name)
            else:
                attributes_to_serizalize.append(field)

        obj_dict.update(await self.serialize_obj_attributes(obj, attributes_to_serizalize))

        for field_name in fields_for_serialize:
            display_field_function = getattr(self, field_name, None)
            if not display_field_function or not hasattr(display_field_function, "is_display"):
                continue

            if inspect.iscoroutinefunction(display_field_function):
                display_field_function_fn = display_field_function
            else:
                display_field_function_fn = sync_to_async(display_field_function)

            obj_dict[field_name] = await display_field_function_fn(obj)

        return obj_dict

    def deserialize_value(self, field: ModelFieldWidgetSchema, value: Any) -> Any:
        if not value:
            return value
        match field.form_widget_type:
            case WidgetType.TimePicker:
                try:
                    return datetime.datetime.fromisoformat(value).time()
                except ValueError:
                    return datetime.time.fromisoformat(value)
            case WidgetType.DatePicker:
                return datetime.datetime.fromisoformat(value).date()
            case WidgetType.DateTimePicker:
                return datetime.datetime.fromisoformat(value)
            case _:
                return value

    async def get_list(
        self,
        offset: int | None = None,
        limit: int | None = None,
        search: str | None = None,
        sort_by: str | None = None,
        filters: dict | None = None,
    ) -> tuple[list[dict], int]:
        """This method is used to get list of seriaized objects.

        :params offset: an offset for pagination.
        :params limit: a limit for pagination.
        :params search: a search query.
        :params sort_by: a sort by field name.
        :params filters: a dict of filters.
        :return: A tuple of list of dict and total count.
        """
        resolved_sort_by = self.resolve_sort_by(sort_by) if sort_by else None
        objs, total = await self.orm_get_list(
            offset=offset,
            limit=limit,
            search=search,
            sort_by=resolved_sort_by,
            filters=filters,
        )
        serialized_objs = []
        for obj in objs:
            serialized_objs.append(await self.serialize_obj(obj, list_view=True))
        return serialized_objs, total

    async def get_obj(self, id: UUID | int | str) -> dict | None:
        """This method is used to get serialized object by id.

        :params id: an id of object.
        :return: A dict or None.
        """
        obj = await self.orm_get_obj(id)
        if not obj:
            return None
        return await self.serialize_obj(obj)

    async def save_model(self, id: UUID | int | str | None, payload: dict) -> dict | None:
        """This method is used to save orm/db model object.

        :params id: an id of object.
        :params payload: a payload from request.
        :return: A saved object or None.
        """
        fields = self.get_model_fields_with_widget_types(with_m2m=False, with_upload=False)
        m2m_fields = self.get_model_fields_with_widget_types(with_m2m=True)
        upload_fields = self.get_model_fields_with_widget_types(with_upload=True)

        fields_payload = {
            field.column_name: self.deserialize_value(field, payload[field.name])
            for field in fields
            if field.name in payload
        }
        obj = await self.orm_save_obj(id, fields_payload)
        if not obj:
            return None

        for upload_field in upload_fields:
            if upload_field.name in payload:
                if inspect.iscoroutinefunction(self.orm_save_upload_field):
                    orm_save_upload_field_fn = self.orm_save_upload_field
                else:
                    orm_save_upload_field_fn = sync_to_async(self.orm_save_upload_field)  # type: ignore [arg-type]
                await orm_save_upload_field_fn(obj, upload_field.column_name, payload[upload_field.name])

        for m2m_field in m2m_fields:
            if m2m_field.name in payload:
                await self.orm_save_m2m_ids(obj, m2m_field.column_name, payload[m2m_field.name])

        return await self._serialize_obj_after_save(obj)

    async def delete_model(self, id: UUID | int | str) -> None:
        """This method is used to delete orm/db model object.

        :params id: an id of object.
        :return: None.
        """
        await self.orm_delete_obj(id)

    async def get_export(
        self,
        export_format: ExportFormat | None,
        offset: int | None = None,
        limit: int | None = None,
        search: str | None = None,
        sort_by: str | None = None,
        filters: dict | None = None,
    ) -> StringIO | BytesIO | None:
        """This method is used to get export data (str or bytes stream).

        :params export_format: a n export format (CSV at default).
        :params offset: an offset for pagination.
        :params limit: a limit for pagination.
        :params search: a search query.
        :params sort_by: a sort by field name.
        :params filters: a dict of filters.
        :return: A StringIO or BytesIO object.
        """
        resolved_sort_by = self.resolve_sort_by(sort_by) if sort_by else None
        objs, _ = await self.orm_get_list(
            offset=offset, limit=limit, search=search, sort_by=resolved_sort_by, filters=filters
        )
        fields = self.get_model_fields_with_widget_types(with_m2m=False)

        export_fields = [f.name for f in fields]

        match export_format:
            case ExportFormat.CSV:
                output = StringIO()
                writer = csv.DictWriter(output, fieldnames=export_fields)
                writer.writeheader()
                for obj in objs:
                    obj_dict = await self.serialize_obj(obj, list_view=True)
                    obj_dict = {k: v for k, v in obj_dict.items() if k in export_fields}
                    writer.writerow(obj_dict)
                output.seek(0)
                return output
            case ExportFormat.JSON:

                class JSONEncoder(json.JSONEncoder):
                    def default(self, obj):
                        try:
                            return super().default(obj)
                        except TypeError:
                            return str(obj)

                output = StringIO()
                json.dump([await self.serialize_obj(obj, list_view=True) for obj in objs], output, cls=JSONEncoder)
                output.seek(0)
                return output
            case _:
                return None

    async def has_add_permission(self, user_id: UUID | int | None = None) -> bool:
        """This method is used to check if user has permission to add new model instance.

        :param user_id: The user id.
        :return: A boolean value.
        """
        return True

    async def has_change_permission(self, user_id: UUID | int | None = None) -> bool:
        """This method is used to check if user has permission to change model instance.

        :param user_id: The user id.
        :return: A boolean value.
        """
        return True

    async def has_delete_permission(self, user_id: UUID | int | None = None) -> bool:
        """This method is used to check if user has permission to delete model instance.

        :param user_id: The user id.
        :return: A boolean value.
        """
        return True

    async def has_export_permission(self, user_id: UUID | int | None = None) -> bool:
        """This method is used to check if user has permission to export model instance.

        :param user_id: The user id.
        :return: A boolean value.
        """
        return True

  

Model-admin-specific methods and attributes:

  
class ModelAdmin(BaseModelAdmin):
    """This class is used to create admin model class."""

    # Normally, objects have three save options: “Save”, “Save and continue editing”, and “Save and add another”.
    # If save_as is True, “Save and add another” will be replaced
    # by a “Save as new” button that creates a new object (with a new ID) rather than updating the existing object.
    # Example of usage: save_as = True
    save_as: bool = False

    # When save_as_continue=True, the default redirect after saving the new object is to the change view for that object.
    # If you set save_as_continue=False, the redirect will be to the changelist view.
    # Example of usage: save_as_continue = False
    save_as_continue: bool = False

    # Normally, the save buttons appear only at the bottom of the forms.
    # If you set save_on_top, the buttons will appear both on the top and the bottom.
    # Example of usage: save_on_top = True
    save_on_top: bool = False

    # Set view_on_site to control whether or not to display the “View on site” link.
    # This link should bring you to a URL where you can display the saved object.
    # Example of usage: view_on_site = "http://example.com"
    view_on_site: str | None = None

    # Inlines
    inlines: Sequence[type[InlineModelAdmin]] = ()

    async def authenticate(self, username: str, password: str) -> UUID | int | None:
        """This method is used to implement authentication for settings.ADMIN_USER_MODEL orm/db model.

        :params username: a value for user model settings.ADMIN_USER_MODEL_USERNAME_FIELD field.
        :params password: a password.
        :return: An user id or None.
        """
        raise NotImplementedError

    async def change_password(self, id: UUID | int | str, password: str) -> None:
        """This method is used to change user password.

        :params id: An user id.
        :params password: A new password.
        """
        raise NotImplementedError

    async def save_model(self, id: UUID | int | str | None, payload: dict) -> dict | None:
        """This method is used to save orm/db model object.

        :params id: an id of object.
        :params payload: a payload from request.
        :return: A saved object or None.
        """
        obj = await super().save_model(id, payload)
        fields = self.get_model_fields_with_widget_types(with_m2m=False, with_upload=False)
        password_fields = [field.name for field in fields if field.form_widget_type == WidgetType.PasswordInput]
        if obj and id is None and password_fields:
            # save hashed password for create
            pk_name = self.get_model_pk_name(self.model_cls)
            pk = obj[pk_name]
            password_values = [payload[field] for field in password_fields if field in payload]
            if password_values:
                await self.change_password(pk, password_values[0])
        return obj

  

Form Field Types

The following form field types are available for model admins:

  
class WidgetType(str, Enum):
    """Widget type"""

    Input = "Input"
    InputNumber = "InputNumber"
    SlugInput = "SlugInput"
    EmailInput = "EmailInput"
    PhoneInput = "PhoneInput"
    UrlInput = "UrlInput"
    PasswordInput = "PasswordInput"
    TextArea = "TextArea"
    RichTextArea = "RichTextArea"
    JsonTextArea = "JsonTextArea"
    Select = "Select"
    AsyncSelect = "AsyncSelect"
    AsyncTransfer = "AsyncTransfer"
    Switch = "Switch"
    Checkbox = "Checkbox"
    TimePicker = "TimePicker"
    DatePicker = "DatePicker"
    DateTimePicker = "DateTimePicker"
    RangePicker = "RangePicker"
    RadioGroup = "RadioGroup"
    CheckboxGroup = "CheckboxGroup"
    Upload = "Upload"

  

See antd components for more details (e.g. how they look).

Use formfield_overrides to customize widget props per field. You can set label for a custom field label and help for description text below the field:

  
formfield_overrides = {
    "username": (
        WidgetType.SlugInput,
        {
            "required": True,
            "label": "Custom label",
            "help": "Detailed description of the field",
        },
    ),
}
  

Inline Model Admins

Registering Inlines

  
from uuid import UUID

import bcrypt
from tortoise import fields
from tortoise.models import Model

from fastadmin import TortoiseInlineModelAdmin, TortoiseModelAdmin, WidgetType, action, register


class InlineUser(Model):
    username = fields.CharField(max_length=255, unique=True)
    hash_password = fields.CharField(max_length=255)
    is_superuser = fields.BooleanField(default=False)
    is_active = fields.BooleanField(default=False)

    def __str__(self):
        return self.username


class InlineUserMessage(Model):
    user = fields.ForeignKeyField("models.InlineUser", related_name="messages")
    message = fields.TextField()

    def __str__(self):
        return self.message


class UserMessageAdminInline(TortoiseInlineModelAdmin):
    model = InlineUserMessage
    list_display = ("user", "message")
    list_display_links = ("user", "message")
    list_filter = ("user", "message")
    search_fields = ("user", "message")


@register(InlineUser)
class UserAdmin(TortoiseModelAdmin):
    list_display = ("username", "is_superuser", "is_active")
    list_display_links = ("username",)
    list_filter = (
        "username",
        "is_superuser",
        "is_active",
    )
    search_fields = (
        "id",
        "username",
    )
    fieldsets = (
        (None, {"fields": ("username", "hash_password")}),
        ("Permissions", {"fields": ("is_active", "is_superuser")}),
    )
    formfield_overrides = {  # noqa: RUF012
        "username": (WidgetType.SlugInput, {"required": True}),
        "password": (WidgetType.PasswordInput, {"passwordModalForm": True}),
    }
    actions = (
        *TortoiseModelAdmin.actions,
        "activate",
        "deactivate",
    )

    inlines = (UserMessageAdminInline,)

    async def authenticate(self, username: str, password: str) -> int | None:
        user = await self.model_cls.filter(phone=username, is_superuser=True).first()
        if not user:
            return None
        if not bcrypt.checkpw(password.encode(), user.hash_password.encode()):
            return None
        return user.id

    async def change_password(self, id: UUID | int | str, password: str) -> None:
        user = await self.model_cls.filter(id=id).first()
        if not user:
            return
        user.hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
        await user.save(update_fields=("hash_password",))

    @action(description="Set as active")
    async def activate(self, ids: list[int]) -> None:
        await self.model_cls.filter(id__in=ids).update(is_active=True)

    @action(description="Deactivate")
    async def deactivate(self, ids: list[int]) -> None:
        await self.model_cls.filter(id__in=ids).update(is_active=False)

  

See example for Tortoise ORM

See the Tortoise ORM example

See the Tortoise ORM example

Methods and Attributes

The following methods and attributes are available for inline model admins:

See the BaseModelAdmin methods and attributes in the model admin section.

Inline-model-specific methods and attributes:

  
class InlineModelAdmin(BaseModelAdmin):
    """This class is used to create admin inline model class."""

    # The model class which the inline is using. This is required.
    model: Any

    # The name of the foreign key on the model.
    # In most cases this will be dealt with automatically, but fk_name must be specified explicitly
    # if there are more than one foreign key to the same parent model.
    fk_name: str | None = None

    # This controls the maximum number of forms to show in the inline.
    # This doesn't directly correlate to the number of objects, but can if the value is small enough.
    # See Limiting the number of editable objects for more information.
    max_num: int = 10

    # This controls the minimum number of forms to show in the inline.
    min_num: int = 1

  

Changelog

See what was added, changed, fixed, or improved in the latest versions.

v0.3.3

Fix DetachedInstanceError when session is closed after commit.

Fix list display widths.

Fix flask issues.

v0.3.2

Add formfield_overrides example. Add label and help props.

v0.3.1

Fix sqlalchemy required fields. Fix CI.

v0.3.0

Clean up documentation. Update dependencies. Fix linters and tests. Frontend refactoring.

v0.2.22

Fix upload base64 widget; add disableCropImage prop. Fix examples.

v0.2.21

Fix cleaning of async select fields on forms.

v0.2.20

Fix _id fields handling. Bump backend and frontend packages.

v0.2.19

Fix is_pk for Tortoise ORM.

v0.2.18

Fix M2M/FK handling for SQLAlchemy with PostgreSQL (convert str to int).

v0.2.17

Fix FK handling for SQLAlchemy with PostgreSQL (convert str to int).

v0.2.16

Add ADMIN_DISABLE_CROP_IMAGE setting to configure image cropping on upload.

v0.2.15

Fix password logic for user model.

v0.2.14

Make permission functions awaitable. Bump frontend and backend packages.

v0.2.13

Fix edit page frontend issue for Date field.

v0.2.12

Remove python-dotenv dependency. Bump Django. Add Django example.

v0.2.11

Fix examples. Fix Pony ORM (delete, update M2M). Allow sorting by custom columns. Fix list_display ordering.

v0.2.10

Fix empty M2M issue. Optimize unit tests. Fix Pony ORM. Optimize Tortoise ORM search.

v0.2.9

Fix modal inline dialogs. Fix M2M multiple select.

v0.2.8

Fix SQLAlchemy delete functionality. Add more examples.

v0.2.7

Fix helper functions. Add regex support.

v0.2.6

Add edit button for async select.

v0.2.5

Fix async select in inlines.

v0.2.4

Fix dashboard widgets and auto-register inlines.

v0.2.3

Fix filter issue on list views. Remove Jinja from dependencies.

v0.2.2

Fix datetime-related bugs.

v0.2.1

Update packages. Fix linters and tests in Vite frontend. Remove Pydantic from dependencies.

v0.2.0

Update packages. Use Vite instead of deprecated react-scripts.