FastAdmin | Documentation

  • Created: 7 March 2023
  • Updated: 02 December 2024

Introduction

FastAdmin is an easy-to-use Admin Dashboard App for FastAPI/Django/Flask inspired by Django Admin.

FastAdmin was built with relations in mind and admiration for the excellent and popular Django Admin. It's engraved in its design that you may configure your admin dashboard for FastAPI/Django/Flask easiest way.

FastAdmin is designed to be minimalistic, functional and yet familiar.


Getting Started

If you have any questions that are beyond the scope of the documentation, Please feel free to email us.

Installation

Follow the steps below to setup FastAdmin:

Install the package using pip:

Note: For zsh and macos use: pip install fastadmin[fastapi,django]

  

pip install fastadmin[fastapi,django]  # for fastapi with django orm
pip install fastadmin[fastapi,tortoise-orm]  # for fastapi with tortoise orm
pip install fastadmin[fastapi,pony]  # for fastapi with pony orm
pip install fastadmin[fastapi,sqlalchemy]  # for fastapi with sqlalchemy orm
pip install fastadmin[django]  # for django with django orm
pip install fastadmin[django,pony]  # for django with pony orm
pip install fastadmin[flask,sqlalchemy]  # for flask with sqlalchemy

  

Install the package using poetry:

  

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

  

Configure required settings using virtual environment variables:

Note: You can add these variables to .env and use python-dotenv to load them. See all settings here

  

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

  

Quick Tutorial

Setup FastAdmin for a 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

  
from uuid import UUID

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

from fastadmin import TortoiseModelAdmin, 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)

    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",)

    async def authenticate(self, username: str, password: str) -> UUID | int | None:
        user = await User.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

  
  
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 bcrypt
from sqlalchemy import Boolean, Integer, String, select
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)

    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, password):
        sessionmaker = self.get_sessionmaker()
        async with sessionmaker() as session:
            query = select(User).filter_by(username=username, password=password, is_superuser=True)
            result = await session.scalars(query)
            user = result.first()
            if not user:
                return None
            if not bcrypt.checkpw(password.encode(), user.hash_password.encode()):
                return None
            return user.id

  
  
import bcrypt
from pony.orm import Database, PrimaryKey, Required, 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)

    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, password):
        user = next((f for f in self.model_cls.select(username=username, password=password, is_superuser=True)), None)
        if not user:
            return None
        if not bcrypt.checkpw(password.encode(), user.hash_password.encode()):
            return None
        return user.id

  

Settings

There are settings with default values:

Note: Export virtual environment variables or create .env file with variables and use 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)

  

Note: 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 example for Tortoise ORM
  
  
See example for Tortoise ORM
  

Methods and Attributes

There are methods and attributes for Dashboard Widget Admin:

  
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

  

Note: Please see antd charts for x_field_filter_widget_props.

Chart Types

There are widget types which fastadmin dashboard supports:

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

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

  

Note: Please see antd charts for more details (e.g. to see how they look like).


Model Admins

Registering Models

  
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)

    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}),
    }
    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, 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 example for Tortoise ORM

See example for Tortoise ORM

Authentication

You have to implement methods authenticate and change_password in Modal Admin for User model. See example above.

Methods and Attributes

There are methods and attributes for Model Admin:

  
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) -> 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_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) -> 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

    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(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:
                return datetime.datetime.fromisoformat(value).time()
            case WidgetType.DatePicker | 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.
        """
        objs, total = await self.orm_get_list(
            offset=offset,
            limit=limit,
            search=search,
            sort_by=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) -> 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 | 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 and is_valid_base64(payload[upload_field.name]):
                await self.orm_save_upload_field(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(obj)

    async def delete_model(self, id: UUID | int) -> 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.
        """
        objs, _ = await self.orm_get_list(offset=offset, limit=limit, search=search, sort_by=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

  

Specific methods and attributes for Model Admin:

  
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, 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 | 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

There are form field types for model admin:

  
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"

  

Note: Please see antd components for more details (e.g. to see how they look like).


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, 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 example for Tortoise ORM

See example for Tortoise ORM

Methods and Attributes

There are methods and attributes for Inline Model Admin:

See BaseModelAdmin class methods and attributes in model admin section.

Specific methods and attributes for Inline Model Admin:

  
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's new added, changed, fixed, improved or updated in the latest versions.

v0.2.16

Added new setting ADMIN_DISABLE_CROP_IMAGE. So,we can configure crop images on upload.

v0.2.15

Fix password logic for user.

v0.2.14

Make permissions functions awaitable. Bump frontend/backend packages.

v0.2.13

Fix edit page frontend issue for Date field.

v0.2.12

Remove python-dotenv dep. Bump django. Add django example.

v0.2.11

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

v0.2.10

Fix issue empty m2m. Optimisation on unit tests. Fix for pony orm. Optimisation on search for tortoise orm.

v0.2.9

Fix issue with modal inline dialogs. Fix issue with m2m multiple select.

v0.2.8

Fix sqlalchemy delete functionality. Add more examples.

v0.2.7

Fix helpers function. Add regexps.

v0.2.6

Add edit btn for async select.

v0.2.5

Fix for async select in inlines.

v0.2.4

Fix dashboard widgets and auto register inlines.

v0.2.3

Fix filters issue on lists. Remove jinja from dependencies.

v0.2.2

Fix bugs with datetime.

v0.2.1

Update packages. Fix linters and tests in vite frontend. Removed pydantic from dependencies.

v0.2.0

Update packages. Use vite instead obsolete react-scripts.