From e8069484f5a8dda26dc77de2229f7ed3164079d4 Mon Sep 17 00:00:00 2001 From: long2ice Date: Mon, 6 Apr 2020 21:41:06 +0800 Subject: [PATCH] update README.rst add support to fk --- README.rst | 6 ++- examples/__init__.py | 9 ++++ examples/enums.py | 60 +++++++++++++++++++++ examples/main.py | 86 ++++++++++++++++++++++++------ examples/models.py | 99 +++++++++++++++++++++++++++++------ fastapi_admin/__init__.py | 2 +- fastapi_admin/factory.py | 46 +++++++++++----- fastapi_admin/routes/index.py | 22 +++++--- fastapi_admin/site.py | 1 + 9 files changed, 275 insertions(+), 56 deletions(-) create mode 100644 examples/enums.py diff --git a/README.rst b/README.rst index 89c4931..5057b5e 100644 --- a/README.rst +++ b/README.rst @@ -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/. 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 ----- diff --git a/examples/__init__.py b/examples/__init__.py index e69de29..a84ddc1 100644 --- a/examples/__init__.py +++ b/examples/__init__.py @@ -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 diff --git a/examples/enums.py b/examples/enums.py new file mode 100644 index 0000000..d734e05 --- /dev/null +++ b/examples/enums.py @@ -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', + } diff --git a/examples/main.py b/examples/main.py index d22c209..739cb62 100644 --- a/examples/main.py +++ b/examples/main.py @@ -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' ) ] ) diff --git a/examples/models.py b/examples/models.py index 9ee4f2b..d61f546 100644 --- a/examples/models.py +++ b/examples/models.py @@ -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'),) diff --git a/fastapi_admin/__init__.py b/fastapi_admin/__init__.py index 22010c3..504b6f8 100644 --- a/fastapi_admin/__init__.py +++ b/fastapi_admin/__init__.py @@ -1,3 +1,3 @@ from . import routes -__version__ = '0.1.1' +__version__ = '0.1.2' diff --git a/fastapi_admin/factory.py b/fastapi_admin/factory.py index 4e9d655..87ee15c 100644 --- a/fastapi_admin/factory.py +++ b/fastapi_admin/factory.py @@ -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) ) diff --git a/fastapi_admin/routes/index.py b/fastapi_admin/routes/index.py index a717000..bc2cf97 100644 --- a/fastapi_admin/routes/index.py +++ b/fastapi_admin/routes/index.py @@ -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() diff --git a/fastapi_admin/site.py b/fastapi_admin/site.py index 6988643..f8a2c9e 100644 --- a/fastapi_admin/site.py +++ b/fastapi_admin/site.py @@ -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):