mirror of
https://github.com/fastapi-admin/fastapi-admin.git
synced 2026-03-13 10:32:25 +08:00
update README.rst
add support to fk
This commit is contained in:
@@ -93,7 +93,7 @@ Only you should do is runing a fastapi app and mount admin app from fastapi-admi
|
||||
),
|
||||
Menu(
|
||||
name='User',
|
||||
url='/rest/TestUser', #important,TestUser is same of the Model class TestUser.
|
||||
url='/rest/TestUser', #important,TestUser is same of the Model class TestUser and must be /rest/<Model>.
|
||||
icon='fa fa-user',
|
||||
),
|
||||
Menu(
|
||||
@@ -156,6 +156,10 @@ Verbose Name
|
||||
~~~~~~~~~~~~
|
||||
FastAPI-admin will auto read ``description`` defined in tortoise-orm model ``Field`` and display in front.
|
||||
|
||||
ForeignKeyField Support
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
If ``ForeignKeyField`` not passed in ``menu.raw_id_fields``,FastAPI-admin will get all related objects and display ``select`` in front with ``Model.__str__``.
|
||||
|
||||
Front
|
||||
-----
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from tortoise import fields, Model
|
||||
|
||||
|
||||
class BaseModel(Model):
|
||||
create_at = fields.DatetimeField(auto_now_add=True, description='创建时间')
|
||||
update_at = fields.DatetimeField(auto_now=True, description='更新时间')
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
60
examples/enums.py
Normal file
60
examples/enums.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class EnumMixin:
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Status(EnumMixin, IntEnum):
|
||||
"""
|
||||
状态
|
||||
"""
|
||||
on = 1
|
||||
off = 0
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return {
|
||||
cls.on: '开启',
|
||||
cls.off: '关闭'
|
||||
}
|
||||
|
||||
|
||||
class BaiduAiCategory(EnumMixin, IntEnum):
|
||||
id_card = 1
|
||||
censor = 2
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return {
|
||||
cls.id_card: '身份证',
|
||||
cls.censor: '审核'
|
||||
}
|
||||
|
||||
|
||||
class AliYunSecretCategory(EnumMixin, IntEnum):
|
||||
sms = 1
|
||||
oss = 2
|
||||
alert = 3
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return {
|
||||
cls.sms: '短信',
|
||||
cls.oss: 'oss',
|
||||
cls.alert: '报警'
|
||||
}
|
||||
|
||||
|
||||
class DeviceSys(EnumMixin, IntEnum):
|
||||
android = 1
|
||||
ios = 2
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return {
|
||||
cls.android: '安卓',
|
||||
cls.ios: 'iOS',
|
||||
}
|
||||
@@ -8,7 +8,7 @@ from fastapi_admin.site import Site, Menu
|
||||
|
||||
TORTOISE_ORM = {
|
||||
'connections': {
|
||||
'default': 'mysql://root:123456@127.0.0.1:3306/test'
|
||||
'default': 'mysql://root:123456@127.0.0.1:3306/micro'
|
||||
},
|
||||
'apps': {
|
||||
'models': {
|
||||
@@ -27,34 +27,86 @@ def create_app():
|
||||
fast_app.mount('/admin', admin_app)
|
||||
|
||||
admin_app.init(
|
||||
user_model='TestUser',
|
||||
user_model='User',
|
||||
admin_secret='test',
|
||||
models='examples.models',
|
||||
site=Site(
|
||||
name='FastAPI-Admin',
|
||||
name='微服务管理后台',
|
||||
logo='https://github.com/long2ice/fastapi-admin/raw/master/front/static/img/logo.png',
|
||||
locale='en-US',
|
||||
locale_switcher=True,
|
||||
locale='zh-CN',
|
||||
locale_switcher=False,
|
||||
menu=[
|
||||
Menu(
|
||||
name='Home',
|
||||
url='/home',
|
||||
icon='fa fa-home',
|
||||
name='首页',
|
||||
url='/',
|
||||
icon='fa fa-home'
|
||||
),
|
||||
Menu(
|
||||
name='Information',
|
||||
title=True,
|
||||
icon='fa fa-user',
|
||||
name='配置',
|
||||
title=True
|
||||
),
|
||||
Menu(
|
||||
name='User',
|
||||
url='/rest/TestUser',
|
||||
icon='fa fa-user',
|
||||
name='应用',
|
||||
url='/rest/App',
|
||||
icon='fa fa-pencil'
|
||||
),
|
||||
Menu(
|
||||
name='Logout',
|
||||
url='/logout',
|
||||
icon='icon-lock',
|
||||
name='阿里云秘钥',
|
||||
url='/rest/AliYunSecret',
|
||||
icon='fa fa-user-secret'
|
||||
),
|
||||
Menu(
|
||||
name='阿里云OSS',
|
||||
url='/rest/AliYunOss',
|
||||
icon='fa fa-database'
|
||||
),
|
||||
Menu(
|
||||
name='App短信',
|
||||
url='/rest/AppSms',
|
||||
icon='fa fa-envelope-o'
|
||||
),
|
||||
Menu(
|
||||
name='百度AI',
|
||||
url='/rest/BaiduAi',
|
||||
icon='fa fa-desktop'
|
||||
),
|
||||
Menu(
|
||||
name='App百度AI',
|
||||
url='/rest/AppBaiduAi',
|
||||
icon='fa fa-laptop',
|
||||
),
|
||||
Menu(
|
||||
name='在线参数',
|
||||
url='/rest/Config',
|
||||
icon='fa fa-cog'
|
||||
),
|
||||
Menu(
|
||||
name='基本信息',
|
||||
title=True
|
||||
),
|
||||
Menu(
|
||||
name='请求日志',
|
||||
url='/rest/ApiLog',
|
||||
icon='fa fa-sticky-note'
|
||||
),
|
||||
Menu(
|
||||
name='App版本',
|
||||
url='/rest/AppVersion',
|
||||
icon='fa fa-mobile'
|
||||
),
|
||||
Menu(
|
||||
name='授权',
|
||||
title=True
|
||||
),
|
||||
Menu(
|
||||
name='用户',
|
||||
url='/rest/User',
|
||||
icon='fa fa-user'
|
||||
),
|
||||
Menu(
|
||||
name='注销',
|
||||
url='/login',
|
||||
icon='fa fa-lock'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,24 +1,89 @@
|
||||
from enum import IntEnum
|
||||
from fastapi_admin.enum import EnumMixin
|
||||
from tortoise import fields
|
||||
|
||||
from fastapi_admin.models import User
|
||||
from . import BaseModel
|
||||
from .enums import AliYunSecretCategory, BaiduAiCategory, Status, DeviceSys
|
||||
from fastapi_admin.models import User as AdminUser
|
||||
|
||||
|
||||
class Status(EnumMixin, IntEnum):
|
||||
on = 1
|
||||
off = 2
|
||||
class User(AdminUser, BaseModel):
|
||||
last_login = fields.DatetimeField(description='上次登录')
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return {
|
||||
cls.on: 'ON',
|
||||
cls.off: 'OFF'
|
||||
}
|
||||
def __str__(self):
|
||||
return f'{self.pk}#{self.username}'
|
||||
|
||||
|
||||
class TestUser(User):
|
||||
is_active = fields.BooleanField(default=False, description='Is Active')
|
||||
status = fields.IntEnumField(Status, description='User Status')
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
class AliYunSecret(BaseModel):
|
||||
app_id = fields.CharField(max_length=50)
|
||||
app_secret = fields.CharField(max_length=50)
|
||||
category = fields.IntEnumField(AliYunSecretCategory, description='类别', default=AliYunSecretCategory.sms)
|
||||
|
||||
|
||||
class AliYunOss(BaseModel):
|
||||
bucket = fields.CharField(max_length=50, unique=True, description='仓库')
|
||||
domain = fields.CharField(max_length=200, description='域名')
|
||||
endpoint = fields.CharField(max_length=200, description='端点')
|
||||
aliyun_secret = fields.ForeignKeyField('models.AliYunSecret', related_name='aliyunoss')
|
||||
|
||||
|
||||
class App(BaseModel):
|
||||
uaid = fields.IntField(unique=True)
|
||||
label = fields.CharField(max_length=20)
|
||||
secret = fields.CharField(max_length=16, description='通信密钥')
|
||||
work_secret = fields.CharField(max_length=100, description='企业微信密钥')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.uaid}#{self.label}'
|
||||
|
||||
|
||||
class AppSms(BaseModel):
|
||||
app = fields.ForeignKeyField('models.App', related_name='app_sms')
|
||||
scene = fields.CharField(max_length=50, description='使用场景')
|
||||
aliyun_secret = fields.ForeignKeyField('models.AliYunSecret', related_name='app_sms')
|
||||
sign_name = fields.CharField(max_length=20, description='签名')
|
||||
template_code = fields.CharField(max_length=20, description='模板编号')
|
||||
|
||||
class Meta:
|
||||
unique_together = (('app', 'scene'),)
|
||||
|
||||
|
||||
class BaiduAi(BaseModel):
|
||||
app_id = fields.CharField(max_length=50)
|
||||
api_key = fields.CharField(max_length=50)
|
||||
app_secret = fields.CharField(max_length=50)
|
||||
category = fields.IntEnumField(BaiduAiCategory, description='应用类别', default=BaiduAiCategory.id_card)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.pk}#{self.app_id}'
|
||||
|
||||
|
||||
class AppBaiduAi(BaseModel):
|
||||
app = fields.ForeignKeyField('models.App', related_name='app_baidu_ais')
|
||||
baidu_ai = fields.ForeignKeyField('models.BaiduAi', related_name='app_baidu_ais')
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
label = fields.CharField(max_length=200)
|
||||
key = fields.CharField(max_length=20)
|
||||
value = fields.JSONField()
|
||||
status: Status = fields.IntEnumField(Status, default=Status.on, description='状态')
|
||||
|
||||
|
||||
class ApiLog(BaseModel):
|
||||
app = fields.ForeignKeyField('models.App', related_name='api_logs')
|
||||
endpoint = fields.CharField(max_length=50)
|
||||
success = fields.BooleanField(default=True, description='是否成功')
|
||||
status_code = fields.SmallIntField(description='状态码')
|
||||
|
||||
|
||||
class AppVersion(BaseModel):
|
||||
app = fields.ForeignKeyField('models.App', related_name='app_versions')
|
||||
channel = fields.CharField(max_length=20, description='渠道')
|
||||
device_sys = fields.IntEnumField(DeviceSys, default=DeviceSys.android, description='操作系统')
|
||||
version_code = fields.CharField(max_length=20, description='版本号')
|
||||
describe = fields.CharField(max_length=200, description='描述')
|
||||
download_url = fields.CharField(max_length=200, description='下载链接')
|
||||
is_update = fields.BooleanField(default=False, description='是否更新')
|
||||
|
||||
class Meta:
|
||||
ordering = ['-id']
|
||||
indexes = (('app', 'device_sys', 'channel'),)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from . import routes
|
||||
|
||||
__version__ = '0.1.1'
|
||||
__version__ = '0.1.2'
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Type
|
||||
|
||||
from fastapi import FastAPI
|
||||
from tortoise import Model, Tortoise
|
||||
|
||||
from .site import Site, Resource, Field
|
||||
|
||||
|
||||
@@ -22,7 +23,13 @@ class AdminApp(FastAPI):
|
||||
'FloatField': 'number',
|
||||
'TextField': 'textarea',
|
||||
'SmallIntField': 'number',
|
||||
'ForeignKeyFieldInstance': 'select',
|
||||
}
|
||||
_model_menu_mapping = {}
|
||||
|
||||
def _get_model_menu_mapping(self):
|
||||
for menu in filter(lambda x: x.url, self.site.menus):
|
||||
self._model_menu_mapping[menu.url.split('?')[0].split('/')[-1]] = menu
|
||||
|
||||
def init(self, site: Site, user_model: str, admin_secret: str, models: str, ):
|
||||
"""
|
||||
@@ -38,6 +45,7 @@ class AdminApp(FastAPI):
|
||||
self.models = importlib.import_module(models)
|
||||
self.user_model = getattr(self.models, user_model)
|
||||
self._inited = True
|
||||
self._get_model_menu_mapping()
|
||||
|
||||
def _exclude_field(self, resource: str, field: str):
|
||||
"""
|
||||
@@ -46,20 +54,20 @@ class AdminApp(FastAPI):
|
||||
:param field:
|
||||
:return:
|
||||
"""
|
||||
for menu in filter(lambda x: x.url, self.site.menus):
|
||||
if resource == menu.url.split('?')[0].split('/')[-1]:
|
||||
if menu.include:
|
||||
if field not in menu.include:
|
||||
return True
|
||||
if menu.exclude:
|
||||
if field in menu.exclude:
|
||||
return True
|
||||
menu = self._model_menu_mapping[resource]
|
||||
if menu.include:
|
||||
if field not in menu.include:
|
||||
return True
|
||||
if menu.exclude:
|
||||
if field in menu.exclude:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_resource_from_model_describe(self, resource: str, model: Type[Model], model_describe: dict,
|
||||
exclude_readonly: bool):
|
||||
async def _build_resource_from_model_describe(self, resource: str, model: Type[Model], model_describe: dict,
|
||||
exclude_readonly: bool):
|
||||
data_fields = model_describe.get('data_fields')
|
||||
pk_field = model_describe.get('pk_field')
|
||||
fk_fields = model_describe.get('fk_fields')
|
||||
if exclude_readonly:
|
||||
ret = {}
|
||||
else:
|
||||
@@ -90,15 +98,29 @@ class AdminApp(FastAPI):
|
||||
type=type_,
|
||||
options=options
|
||||
)
|
||||
|
||||
menu = self._model_menu_mapping[resource]
|
||||
for fk_field in fk_fields:
|
||||
name = fk_field.get('name')
|
||||
if name not in menu.raw_id_fields:
|
||||
fk_model_class = model._meta.fields_map[name].model_class
|
||||
objs = await fk_model_class.all()
|
||||
ret[fk_field.get('raw_field')] = Field(
|
||||
label=fk_field.get('description') or name.title(),
|
||||
required=True,
|
||||
type='select',
|
||||
options=list(map(lambda x: {'text': str(x), 'value': x.pk}, objs))
|
||||
)
|
||||
|
||||
return ret
|
||||
|
||||
def get_resource(self, resource: str, exclude_readonly=False):
|
||||
async def get_resource(self, resource: str, exclude_readonly=False, ):
|
||||
assert self._inited, 'must call init() first!'
|
||||
model = getattr(self.models, resource) # type:Type[Model]
|
||||
model_describe = Tortoise.describe_model(model)
|
||||
return Resource(
|
||||
title=model_describe.get('description') or resource.title(),
|
||||
fields=self._build_resource_from_model_describe(resource, model, model_describe, exclude_readonly)
|
||||
fields=await self._build_resource_from_model_describe(resource, model, model_describe, exclude_readonly)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ async def get_resource(
|
||||
if query.where:
|
||||
qs = qs.filter(**query.where)
|
||||
result = await qs.limit(query.size).offset((query.page - 1) * query.size)
|
||||
creator = pydantic_model_creator(model, include=app.get_resource(resource).resource_fields.keys())
|
||||
resource = await app.get_resource(resource)
|
||||
creator = pydantic_model_creator(model, include=resource.resource_fields.keys())
|
||||
return GetManyOut(
|
||||
total=await qs.count(),
|
||||
data=list(map(lambda x: creator.from_orm(x).dict(), result))
|
||||
@@ -37,7 +38,8 @@ async def get_resource(
|
||||
async def form(
|
||||
resource: str,
|
||||
):
|
||||
return app.get_resource(resource, exclude_readonly=True).dict(by_alias=True, exclude_unset=True)
|
||||
resource = await app.get_resource(resource, exclude_readonly=True)
|
||||
return resource.dict(by_alias=True, exclude_unset=True)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -46,7 +48,8 @@ async def form(
|
||||
async def grid(
|
||||
resource: str,
|
||||
):
|
||||
return app.get_resource(resource).dict(by_alias=True, exclude_unset=True)
|
||||
resource = await app.get_resource(resource)
|
||||
return resource.dict(by_alias=True, exclude_unset=True)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -55,7 +58,8 @@ async def grid(
|
||||
async def view(
|
||||
resource: str,
|
||||
):
|
||||
return app.get_resource(resource)
|
||||
resource = await app.get_resource(resource)
|
||||
return resource.dict(by_alias=True, exclude_unset=True)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -85,8 +89,8 @@ async def update_one(
|
||||
message=f'Update Error,{e}'
|
||||
))
|
||||
user_ = await get_object_or_404(model, pk=id)
|
||||
creator = pydantic_model_creator(model,
|
||||
include=app.get_resource(resource, exclude_readonly=True).resource_fields.keys())
|
||||
resource = await app.get_resource(resource, exclude_readonly=True)
|
||||
creator = pydantic_model_creator(model, include=resource.resource_fields.keys())
|
||||
return creator.from_orm(user_).dict()
|
||||
|
||||
|
||||
@@ -98,7 +102,8 @@ async def create_one(
|
||||
body=Depends(get_body),
|
||||
model=Depends(get_model)
|
||||
):
|
||||
creator = pydantic_model_creator(model, include=app.get_resource(resource).resource_fields.keys())
|
||||
resource = await app.get_resource(resource)
|
||||
creator = pydantic_model_creator(model, include=resource.resource_fields.keys())
|
||||
try:
|
||||
obj = await model.create(**body)
|
||||
except IntegrityError as e:
|
||||
@@ -117,6 +122,7 @@ async def get_one(
|
||||
model=Depends(get_model)
|
||||
):
|
||||
obj = await get_object_or_404(model, pk=id)
|
||||
include = app.get_resource(resource, exclude_readonly=True).resource_fields.keys()
|
||||
resource = await app.get_resource(resource)
|
||||
include = resource.resource_fields.keys()
|
||||
creator = pydantic_model_creator(model, include=include)
|
||||
return creator.from_orm(obj).dict()
|
||||
|
||||
@@ -11,6 +11,7 @@ class Menu(BaseModel):
|
||||
include: Optional[Tuple[str]]
|
||||
exclude: Optional[Tuple[str]]
|
||||
external: Optional[bool] = False
|
||||
raw_id_fields: Optional[Tuple[str]] = ()
|
||||
|
||||
|
||||
class Site(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user