mcpField
F
fastapi-starlette-admin

fastapi-starlette-admin

github.com/shyxin開発664 回インストール1 日前に更新
リポジトリ

fastapi-starlette-admin について

fastapi-starlette-adminは、新規または既存のFastAPIプロジェクトにstarlette-adminのCRUD管理パネルを追加するスキルです。データベース方式と認証要件に応じた設定を案内し、統合用ファイルのテンプレートとトラブルシューティングを提供します。

主な機能

  1. 01既存プロジェクトの構成を確認
  2. 02データベース方式に応じて設定
  3. 03管理ビューとダッシュボードを生成
  4. 04セッションベース認証を設定
  5. 05ミドルウェアと管理ルートを統合
  6. 06よくある統合問題を診断

活用シーン

  1. 01FastAPIプロジェクトに管理パネルを追加
  2. 02管理画面向けに非同期DBを適合
  3. 03認証付きCRUD画面を生成
  4. 04メニュー欠落やログイン転送を診断

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/shyxin/skills --skill fastapi-starlette-admin

インストール後、Claude Code で/fastapi-starlette-adminを入力して読み込まれたか確認します。

詳細

FastAPI + Starlette-Admin のクイック統合

FastAPI プロジェクトに starlette-admin ベースの CRUD 管理パネルをすばやく追加します。

起動条件

ユーザーが「admin を追加」「管理画面を追加」「starlette-admin を統合」「admin panel」「バックエンド管理」と言ったときに起動します。

前提条件

pip install starlette-admin itsdangerous jinja2
# 如果项目用 async 数据库驱动,还需要同步驱动:
pip install pymysql        # MySQL (对应 aiomysql)
# pip install psycopg2-binary  # PostgreSQL (对应 asyncpg)

ワークフロー

Phase 1: プロジェクトの状態を確認(質問 2~3 件)

コードを生成する前に、必ず以下の質問をユーザーにしてください:

  1. プロジェクトの状態:ゼロから始めますか?それとも既存の FastAPI プロジェクトがありますか?

    • 既存プロジェクト:config.py/settings.py、database.py、main.py、models/を読み込み、既存の構造を確認します
    • 新規プロジェクト:完全な骨組みを生成します
  2. データベースの種類?

    • A) Async メインエンジン(aiomysql / asyncpg / aiosqlite)→ 同期エンジンのアダプターを生成する必要があります
    • B) 完全な同期構成(pymysql / psycopg2 / sqlite)→ 同じエンジンをそのまま使用します
    • C) SQLite ですばやく起動 → 設定を簡略化します
  3. 認証は必要ですか?

    • A) 必要(デフォルト)→ Session ベースの AuthProvider を生成します
    • B) 不要(内部ツール/開発用)→ auth を省略し、auth_provider=Noneにします
Phase 2: コードを生成

Phase 1 の回答に基づいて、以下のファイルを生成します。


ファイル 1:app/admin/__init__.py(新規作成)

コアとなるファクトリー関数です。以下のテンプレートは「既存の async プロジェクト+認証が必要」のケースに合わせたものです。それ以外のケースでは必要に応じて調整してください。

"""Starlette-Admin 管理面板配置。"""
import os

from starlette_admin.contrib.sqla import Admin
from starlette_admin import I18nConfig
from app.admin.views import DashboardView, YourModel1View, YourModel2View  # TODO
from app.admin.auth import AdminAuthProvider
from app.core.database import db_manager  # TODO: 替换为你的数据库管理器


def create_admin(app):
    """创建并配置 starlette-admin,挂载到 FastAPI app。

    ⚠️ 关键:
    1. Admin 构造函数第一个参数是 sync_engine(不是 app)
    2. starlette-admin 需要同步引擎,不能直接用 async 引擎
    3. 调用 admin.mount_to(app) 挂载
    4. templates_dir 必须存在(os.makedirs)
    """
    # 获取同步引擎(starlette-admin 不支持 async 引擎)
    engine = db_manager.get_sync_engine()

    base_dir = os.path.dirname(os.path.abspath(__file__))
    templates_dir = os.path.join(base_dir, 'templates')
    os.makedirs(templates_dir, exist_ok=True)

    admin = Admin(
        engine=engine,
        title="管理后台",  # TODO: 修改为你的标题
        base_url="/admin",
        route_name="admin",
        templates_dir=templates_dir,
        index_view=DashboardView(),
        auth_provider=AdminAuthProvider(),
        i18n_config=I18nConfig(
            default_locale="en",
            language_switcher=["en", "zh_Hant"],  # 语言切换按钮,按需调整
        ),
    )

    # 注册视图
    admin.add_view(YourModel1View(YourModel1, engine))  # TODO
    admin.add_view(YourModel2View(YourModel2, engine))  # TODO

    # 挂载到 FastAPI
    admin.mount_to(app)

    return admin

⚠️ ユーザーが「認証は不要」を選択した場合:

    admin = Admin(
        engine=engine,
        title="管理后台",
        base_url="/admin",
        route_name="admin",
        templates_dir=templates_dir,
        index_view=DashboardView(),
        auth_provider=None,  # 无认证
        i18n_config=I18nConfig(default_locale="en"),
    )

ファイル 2:app/admin/auth.py(新規作成、認証が必要な場合のみ)
"""Starlette-Admin 认证提供者。"""
from starlette.requests import Request
from starlette.responses import Response
from starlette_admin.auth import AdminConfig, AdminUser, AuthProvider
from sqlalchemy.orm import sessionmaker
from app.core.database import db_manager  # TODO
from app.models.user import User  # TODO: 替换为你的用户模型
from app.core.security import pwd_context  # TODO: 替换为你的密码验证工具


class AdminAuthProvider(AuthProvider):
    """Session-based 认证,仅允许管理员登录。"""

    def __init__(self):
        super().__init__()
        engine = db_manager.get_sync_engine()
        self.SessionLocal = sessionmaker(engine)

    async def login(
        self,
        username: str,
        password: str,
        remember_me: bool,
        request: Request,
        response: Response,
    ) -> Response:
        db = self.SessionLocal()
        try:
            user = db.query(User).filter(User.username == username).first()
            if not user or not pwd_context.verify(password, user.password_hash):
                raise ValueError("用户名或密码错误")
            # TODO: 根据你的角色字段调整
            if user.role.value != "admin":
                raise ValueError("仅管理员可登录后台")
            request.session["admin_user_id"] = user.id
            request.session["admin_username"] = user.username
            return response
        finally:
            db.close()  # ⚠️ 必须手动关闭,防止连接池泄漏

    async def is_authenticated(self, request: Request) -> bool:
        user_id = request.session.get("admin_user_id")
        if not user_id:
            return False
        db = self.SessionLocal()
        try:
            user = db.query(User).filter(User.id == user_id).first()
            # TODO: 根据你的角色字段调整
            return user is not None and user.role.value == "admin"
        finally:
            db.close()

    def get_admin_config(self, request: Request) -> AdminConfig:
        username = request.session.get("admin_username", "Admin")
        return AdminConfig(app_title=f"管理后台 — {username}")

    def get_admin_user(self, request: Request) -> AdminUser:
        username = request.session.get("admin_username", "Admin")
        return AdminUser(username=username)

    async def logout(self, request: Request, response: Response) -> Response:
        request.session.clear()
        return response

ファイル 3:app/admin/views.py(新規作成)

DashboardView + ModelView + batch actions を含む完全なテンプレートです。

"""Starlette-Admin 视图定义。"""
import csv
import io
from typing import List, Any

from starlette.requests import Request
from starlette.responses import Response
from starlette_admin import CustomView, action
from starlette_admin.contrib.sqla import ModelView
from starlette_admin.exceptions import ActionFailed
from sqlalchemy import func
from sqlalchemy.orm import Session

from app.models import User, YourModel1, YourModel2  # TODO


class DashboardView(CustomView):
    """管理后台首页仪表盘。

    ⚠️ 关键:模板必须 extends "layout.html",用 {% block content %} 和 {% block header %}
    不要用 {% block body %} —— 会覆盖整个页面结构(侧边栏、导航栏全部丢失)
    """

    def __init__(self):
        super().__init__(
            label="仪表盘",
            icon="fa fa-dashboard",
            path="/",
            template_path="dashboard.html",
            add_to_menu=True,
        )

    async def render(self, request: Request, templates) -> Response:
        session: Session = request.state.session
        stats = self._collect_stats(session)
        return templates.TemplateResponse(
            request=request,
            name="dashboard.html",
            context={"title": "仪表盘", "stats": stats},
        )

    def _collect_stats(self, session: Session) -> dict:
        # TODO: 替换为你自己的统计查询
        return {
            "user_count": session.query(func.count(User.id)).scalar(),
            "model1_count": session.query(func.count(YourModel1.id)).scalar(),
        }


class UserModelView(ModelView):
    """用户管理视图。"""
    # TODO: 替换为你的模型
    model = User
    label = "用户"
    icon = "fa fa-users"

    fields = [User.id, User.username, User.email, User.role, User.is_active,
              User.is_verified, User.created_at]
    search_fields = [User.username, User.email]
    exclude_fields_from_create = ["role", "password_hash"]
    exclude_fields_from_edit = ["role", "password_hash"]
    actions = ["delete", "toggle_active", "export_csv"]

    @action(
        name="toggle_active",
        text="切换激活状态",
        confirmation="确定要切换选中用户的激活状态吗?",
        submit_btn_text="确定",
        submit_btn_class="btn-warning",
        icon_class="fa fa-toggle-on",
    )
    async def toggle_active_action(self, request: Request, pks: List[Any]) -> str:
        session: Session = request.state.session
        users = session.query(User).filter(User.id.in_(pks)).all()
        count = 0
        for user in users:
            user.is_active = not user.is_active
            count += 1
        session.commit()
        return f"已切换 {count} 个用户的激活状态"

    @action(
        name="export_csv",
        text="导出 CSV",
        confirmation="确定要导出选中的数据吗?",
        submit_btn_text="导出",
        submit_btn_class="btn-success",
        icon_class="fa fa-download",
        custom_response=True,  # ⚠️ 返回文件下载时必须设为 True
    )
    async def export_csv_action(self, request: Request, pks: List[Any]) -> Response:
        session: Session = request.state.session
        items = session.query(User).filter(User.id.in_(pks)).all()
        output = io.StringIO()
        writer = csv.writer(output)
        writer.writerow(["ID", "用户名", "邮箱", "角色"])
        for item in items:
            writer.writerow([item.id, item.username, item.email, item.role.value])
        return Response(
            content=output.getvalue(),
            media_type="text/csv",
            headers={"Content-Disposition": "attachment; filename=export.csv"},
        )


class YourModel1View(ModelView):
    """TODO: 替换为你的模型管理视图。"""
    model = YourModel1
    label = "模型1"
    icon = "fa fa-table"

    fields = [YourModel1.id, YourModel1.title]  # TODO
    search_fields = [YourModel1.title]  # TODO
    actions = ["delete", "export_csv"]

    @action(
        name="export_csv",
        text="导出 CSV",
        confirmation="确定要导出选中的数据吗?",
        submit_btn_text="导出",
        submit_btn_class="btn-success",
        icon_class="fa fa-download",
        custom_response=True,
    )
    async def export_csv_action(self, request: Request, pks: List[Any]) -> Response:
        session: Session = request.state.session
        items = session.query(YourModel1).filter(YourModel1.id.in_(pks)).all()
        output = io.StringIO()
        writer = csv.writer(output)
        writer.writerow(["ID", "标题"])  # TODO
        for item in items:
            writer.writerow([item.id, item.title])  # TODO
        return Response(
            content=output.getvalue(),
            media_type="text/csv",
            headers={"Content-Disposition": "attachment; filename=export.csv"},
        )


# 只读视图示例(如审计日志)
class AuditLogModelView(ModelView):
    """审计日志(只读)。"""
    # model = AuditLog  # TODO
    label = "审计日志"
    icon = "fa fa-history"

    # fields = [AuditLog.id, ...]  # TODO

    def can_create(self, request):
        return False

    def can_edit(self, request):
        return False

    def can_delete(self, request):
        return False

ファイル 4:app/admin/templates/dashboard.html(新規作成)
{% extends "layout.html" %}

{% block header %}
<div class="page-header">
    <div class="container-xl">
        <h2 class="page-title">仪表盘</h2>
    </div>
</div>
{% endblock %}

{% block content %}
<div class="page-body">
    <div class="container-xl">
        <div class="row row-cards">
            <div class="col-sm-6 col-lg-3">
                <div class="card card-sm">
                    <div class="card-body">
                        <div class="row align-items-center">
                            <div class="col-auto">
                                <span class="bg-blue text-white avatar">
                                    <i class="fa fa-users"></i>
                                </span>
                            </div>
                            <div class="col">
                                <div class="font-weight-medium">{{ stats.user_count }}</div>
                                <div class="text-muted">用户数</div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
            
        </div>
    </div>
</div>
{% endblock %}

ファイル 5:app/core/database.py(変更)

既存の async エンジンをベースに同期エンジンを追加します。変更箇所を明記してください:

# ============ 已有 async 引擎(保持不变) ============
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

engine = create_async_engine(settings.DATABASE_URL, ...)
async_session = async_sessionmaker(engine, class_=AsyncSession, ...)

# ============ 新增:starlette-admin 同步引擎 ============
# ⚠️ starlette-admin 需要同步数据库引擎,不能直接用 async 引擎
# 通过替换 dialect 驱动名来创建同步版本
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker as sync_sessionmaker

def get_sync_engine():
    """获取同步引擎,供 starlette-admin 使用。"""
    sync_url = settings.DATABASE_URL
    # 自动检测并转换异步驱动为同步驱动
    if "+aiomysql://" in sync_url:
        sync_url = sync_url.replace("+aiomysql://", "+pymysql://")
    elif "+asyncpg://" in sync_url:
        sync_url = sync_url.replace("+asyncpg://", "postgresql://")
    elif "+aiosqlite://" in sync_url:
        sync_url = sync_url.replace("+aiosqlite://", "")

    return create_engine(sync_url, pool_size=5, max_overflow=10, pool_recycle=1800)
# ============ 新增结束 ============

⚠️ 完全な同期プロジェクトの場合は同じエンジンをそのまま使用し、get_sync_engine()は不要です。


ファイル 6:app/main.py(変更)

SessionMiddleware は create_admin より前に追加する必要があります。また、FastAPI アプリに追加してください(admin.middlewares ではありません)。

from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware  # 新增

from app.core.config import settings
from app.api.xxx import router as xxx_router
from app.admin import create_admin  # 新增

app = FastAPI(title="your-app", debug=True)

# ============ 新增:Session 中间件(必须在 create_admin 之前) ============
# ⚠️ starlette-admin 的登录系统依赖 session,没有它会导致无限重定向
app.add_middleware(
    SessionMiddleware,
    secret_key=settings.JWT_SECRET_KEY,  # 复用 JWT secret 或单独配置
    session_cookie="admin_session",
    max_age=3600,  # 1 小时
)
# ============ 新增结束 ============

# 路由注册
app.include_router(xxx_router)

# ============ 新增:挂载管理面板 ============
# ⚠️ 必须在路由注册之后、app 运行之前调用
admin = create_admin(app)
# ============ 新增结束 ============

Phase 3: 検証

コードの生成後、以下の検証手順をユーザーに伝えてください:

  1. バックエンドを起動:uvicorn app.main:app --reload
  2. admin にアクセス:http://localhost:8000/admin
  3. ログイン:管理者のアカウントとパスワードを使用します
  4. 確認:
    • サイドバーメニューが正しく表示される
    • 言語切り替えボタンが右上(アバターの横)に表示される
    • 一覧ページに Export / Filter / Show/Hide columns ボタンがある
    • 行を選択すると batch actions のドロップダウンメニューが表示される
    • Dashboard に統計カードが表示される

トラブルシューティングの記録

この skill は実際のプロジェクトでの経験をもとにまとめています。以下は解決済みの問題です:

問題原因解決策
サイドバー/メニューが描画されないDashboard テンプレートで {% block body %} を使用し、layout.html のページ構造全体を上書きしていた{% block content %} と {% block header %} を使用します
Export/Filter ボタンが表示されないDataTables の i18n ファイル(dt/zh.json など)が 404 になり、JS の初期化に失敗していたdefault_localeに対応する i18n ファイルが存在することを確認するか、enを使用します
momentjs/zh.js が 404 になるdefault_locale="zh"だが、starlette-admin には簡体字中国語の言語パックがないenまたはzh_Hant(繁体字中国語)を使用します
Admin() が TypeError を出すAdmin コンストラクターの第 1 引数は app ではなく engineAdmin(engine, ...)
404 Not Foundbase_urlとapp.mountのパスが重複しているbase_url="/admin"、admin.mount_to(app)
ログインページに無限にリダイレクトされるSessionMiddleware が追加されていない、または追加位置が誤っているFastAPI アプリに、create_admin より前に SessionMiddleware を追加します
No module named 'itsdangerous'starlette のセッションミドルウェアの依存関係が不足しているpip install itsdangerous
AuthProvider の接続プールがリークする手動で作成した sync session が閉じられていないtry/finally: db.close()を使用して確実に閉じます
batch actions が表示されないactions はデフォルトで display: none になっており、行を選択した後に表示される正常な動作です。行を選択すると表示されます
カスタムテンプレートから layout.html が見つからないtemplates_dir にローカルディレクトリしか含まれず、starlette_admin パッケージのテンプレートが含まれていないtemplates_dirパラメーターを使用します。Jinja2 ChoiceLoader が自動的に統合します
i18n の翻訳が反映されないAdmin.init が Jinja2 の gettext を上書きするAdmin.init の後に patch_admin_templates()を呼び出して再インストールします(上級者向け)

認証の拡張

デフォルトのテンプレートでは、session + アカウントとパスワードによる認証を使用します。JWT 認証が必要な場合:

  1. AdminAuthProvider.login()を変更し、使用している JWT ログイン API を呼び出します
  2. is_authenticated()を変更し、Bearer token を検証します
  3. /adminパスに対して CORS を許可するようにフロントエンドを設定します

言語切り替え

I18nConfig(language_switcher=[...])を設定すると、トップナビゲーションバーの右側(ユーザーアバターの横)に言語のドロップダウンメニューが表示されます。starlette-admin に組み込まれている対応言語:

コード言語
en英語
deドイツ語
frフランス語
ptポルトガル語
ruロシア語
trトルコ語
zh_Hant繁体字中国語

切り替え後の設定は cookie(デフォルト名は language)に保存され、LocaleMiddlewareが自動的に読み取ります。

コメント