diff --git a/README.rst b/README.rst index 29070ac..cfb1275 100644 --- a/README.rst +++ b/README.rst @@ -173,10 +173,43 @@ Search Fields ~~~~~~~~~~~~~ Defined ``menu.search_fields`` in ``menu`` will render a search form by fields. -Excel Export -~~~~~~~~~~~~ +Xlsx Export +~~~~~~~~~~~ FastAPI-admin can export searched data to excel file when define ``{export : True}`` in ``menu.actions``. +Bulk Actions +~~~~~~~~~~~~ +Current FastAPI-admin support builtin bulk action ``delete_all``,if you want write your own bulk actions: + +1. pass ``bulk_actions`` in ``Menu``,example: + +.. code-block:: python + + Menu( + ... + bulk_actions=[{ + 'value': 'delete', # this is fastapi router path param. + 'text': 'delete_all', # this will show in front. + }] + ) + +2. write fastapi route,example: + +.. code-block:: python + + from fastapi_admin.schemas import BulkIn + from fastapi_admin.factory import app as admin_app + + @admin_app.post( + '/{resource}/bulk/delete' # delete is defined before. + ) + async def bulk_delete( + bulk_in: BulkIn, + model=Depends(get_model) + ): + await model.filter(pk__in=bulk_in.pk_list).delete() + return {'success': True} + Deployment ========== 1. Deploy fastapi app by gunicorn+uvicorn or reference https://fastapi.tiangolo.com/deployment/. diff --git a/examples/main.py b/examples/main.py index 4dfb427..19bb3af 100644 --- a/examples/main.py +++ b/examples/main.py @@ -50,7 +50,7 @@ def create_app(): url='/rest/App', icon='fa fa-pencil', sort_fields=('uaid',), - search_fields=('uaid',) + search_fields=('uaid',), ), Menu( name='多对多测试', @@ -97,7 +97,8 @@ def create_app(): Menu( name='请求日志', url='/rest/ApiLog', - icon='fa fa-sticky-note' + icon='fa fa-sticky-note', + search_fields=('app',), ), Menu( name='App版本', diff --git a/fastapi_admin/__init__.py b/fastapi_admin/__init__.py index 7b2765b..8c4fc73 100644 --- a/fastapi_admin/__init__.py +++ b/fastapi_admin/__init__.py @@ -1,3 +1,3 @@ from . import routes -__version__ = '0.1.7' +__version__ = '0.1.8' diff --git a/fastapi_admin/depends.py b/fastapi_admin/depends.py index 9eb2d99..78807fc 100644 --- a/fastapi_admin/depends.py +++ b/fastapi_admin/depends.py @@ -27,7 +27,7 @@ class QueryItem(BaseModel): page: int = 1 sort: dict where: dict - with_: dict + with_: dict = {} size: int = 10 sort: dict = {} diff --git a/fastapi_admin/factory.py b/fastapi_admin/factory.py index 7300680..7a839bd 100644 --- a/fastapi_admin/factory.py +++ b/fastapi_admin/factory.py @@ -1,5 +1,5 @@ import importlib -from typing import Type +from typing import Type, List, Dict from fastapi import FastAPI from tortoise import Model, Tortoise @@ -26,7 +26,7 @@ class AdminApp(FastAPI): 'SmallIntField': 'number', 'JSONField': 'json', } - model_menu_mapping = {} + model_menu_mapping: Dict[str, Menu] = {} def _get_model_menu_mapping(self): for menu in filter(lambda x: x.url, self.site.menus): @@ -179,7 +179,8 @@ class AdminApp(FastAPI): title=model_describe.get('description') or resource.title(), fields=fields, searchFields=search_fields, - pk=pk + pk=pk, + bulk_actions=self.model_menu_mapping[resource].bulk_actions, ) diff --git a/fastapi_admin/routes/index.py b/fastapi_admin/routes/index.py index abdbcd9..5a2e519 100644 --- a/fastapi_admin/routes/index.py +++ b/fastapi_admin/routes/index.py @@ -10,6 +10,7 @@ from ..common import handle_m2m_fields_create_or_update from ..depends import QueryItem, get_query, parse_body, get_model from ..factory import app from ..responses import GetManyOut +from ..schemas import BulkIn from ..shortcuts import get_object_or_404 router = APIRouter() @@ -73,6 +74,17 @@ async def view( return resource.dict(by_alias=True, exclude_unset=True) +@router.post( + '/{resource}/bulk/delete' +) +async def bulk_delete( + bulk_in: BulkIn, + model=Depends(get_model) +): + await model.filter(pk__in=bulk_in.pk_list).delete() + return {'success': True} + + @router.delete( '/{resource}/{id}' ) diff --git a/fastapi_admin/schemas.py b/fastapi_admin/schemas.py index 5fb5c2f..7618803 100644 --- a/fastapi_admin/schemas.py +++ b/fastapi_admin/schemas.py @@ -1,3 +1,5 @@ +from typing import List + from fastapi import Body from pydantic import BaseModel @@ -5,3 +7,7 @@ from pydantic import BaseModel class LoginIn(BaseModel): username: str = Body(..., example='long2ice') password: str = Body(..., example='123456') + + +class BulkIn(BaseModel): + pk_list: List = Body(..., example=[1, 2, 3]) diff --git a/fastapi_admin/site.py b/fastapi_admin/site.py index 66c266d..7153a17 100644 --- a/fastapi_admin/site.py +++ b/fastapi_admin/site.py @@ -23,11 +23,12 @@ class Menu(BaseModel): # define field type,like select,radiolist,text,date fields_type: Dict = {} actions: Dict = { - 'toolbar': { - 'delete_all': True - }, 'export': True } + bulk_actions: List[Dict] = [{ + 'value': 'delete', + 'text': 'delete_all', + }] class Site(BaseModel): @@ -72,6 +73,7 @@ class Resource(BaseModel): pk: str resource_fields: Dict[str, Union[Field, Dict]] searchFields: Optional[Dict[str, Field]] + bulk_actions: Optional[List[Dict]] class Config: fields = { diff --git a/front/src/i18n/en-US.json b/front/src/i18n/en-US.json index d01b44c..6c0dec5 100644 --- a/front/src/i18n/en-US.json +++ b/front/src/i18n/en-US.json @@ -1,6 +1,8 @@ { "actions": { - + "select_all": "Select all", + "clear_selected": "Clear selected", + "delete_all": "Delete all" }, "messages": { "paginate": "Total: {total}", @@ -9,10 +11,10 @@ "deleted_all": "All deleted", "confirm_delete": "Are you sure you want to delete?", "image_size": "Size limit: {width}x{height}", - "confirm_delete_all": "Are you sure you want to delete all data?", - "no_options_text": "No Options Here!" + "confirm_bulk_action": "Are you sure you want to do bulk action?", + "no_options_text": "No Options Here!", + "bulk_success": "Bulk action success!" }, "errors": { - } -} \ No newline at end of file +} diff --git a/front/src/i18n/zh-CN.json b/front/src/i18n/zh-CN.json index e567b1e..cfc1ce9 100644 --- a/front/src/i18n/zh-CN.json +++ b/front/src/i18n/zh-CN.json @@ -16,10 +16,11 @@ "change": "更换", "choose": "选择", "login": "登录", - "edit": "编辑" + "edit": "编辑", + "select_all": "选择全部", + "clear_selected": "清除选择" }, "texts": { - }, "fields": { "username": "用户名", @@ -38,8 +39,9 @@ "deleted_all": "全部删除成功", "confirm_delete": "确定要删除吗?", "image_size": "尺寸要求:{width}x{height}", - "confirm_delete_all": "此次操作不可恢复,确定要全部删除吗?", - "no_options_text": "暂无可选项" + "confirm_bulk_action": "确定执行批量操作?", + "no_options_text": "暂无可选项", + "bulk_success": "批量操作成功!" }, "errors": { "too_large": "请上传小于{limit}KB的文件", diff --git a/front/src/scss/skin.scss b/front/src/scss/skin.scss index 16bd66f..5c6f779 100644 --- a/front/src/scss/skin.scss +++ b/front/src/scss/skin.scss @@ -1,62 +1,71 @@ /** * See https://bootswatch.com/ * Change `lumen` to any other words blow: - * + * * cerulean darkly litera materia sandstone slate superhero * cosmo flatly lumen minty simplex solar united * cyborg journal lux pulse sketchy spacelab yeti */ - + // @import "node_modules/bootswatch/dist/lumen/bootstrap"; - .app-footer{ - border-top: none; - color: #666; - } - .card { +.app-footer { + border-top: none; + color: #666; +} + +.card { border: none; box-shadow: 0 0 5px #ccc; } -.card-header{ - border:none; + +.card-header { + border: none; font-weight: bold; } -.card-footer{ + +.card-footer { border: none; } + .nav-tabs .nav-link { - border-color:transparent !important; + border-color: transparent !important; border-radius: 4px; margin: 0 0.2em; padding: 0.3em 1em !important; font-weight: bold; - -} -.data-form .tabs .nav-tabs { - margin: 0 1em 1em 1em; - border:none; -} -.nav-tabs .nav-link.active{ - border-color:#ccc !important; - border-radius: 2em; - -} -.tab-content{ - border-color:transparent; + } -.btn{ +.data-form .tabs .nav-tabs { + margin: 0 1em 1em 1em; + border: none; +} + +.nav-tabs .nav-link.active { + border-color: #ccc !important; + border-radius: 2em; + +} + +.tab-content { + border-color: transparent; +} + +.btn { padding: 0.5em 1.2em; - // border-radius: 2em; + //border-radius: 2em; } .table th, .table td { border-top: 1px solid #dee2e65c; } + .table thead th { - vertical-align: bottom; - border-bottom: 2px solid #dee2e6; + vertical-align: bottom; + border-bottom: 2px solid #dee2e6; } + .table thead th { border-bottom: 1px solid #dee2e6; } diff --git a/front/src/views/CustomTable.vue b/front/src/views/CustomTable.vue index 3b98796..2e4a19c 100644 --- a/front/src/views/CustomTable.vue +++ b/front/src/views/CustomTable.vue @@ -30,15 +30,6 @@ v-bind="button" >{{button.label}} - - - {{$t('actions.delete_all')}} -
@@ -88,6 +79,9 @@ ref="table" :items="fetchItems" :fields="columns" + selectable + @row-selected="onRowSelected" + :select-mode="selectModel" :current-page="currentPage" :sort-by.sync="sortBy" :sort-desc.sync="sortDesc" @@ -140,7 +134,12 @@ - +
+ {{ $t("actions.select_all") }} + {{ $t("actions.clear_selected")}} + + {{ $t("actions.submit")}} +
{ + this.$snotify.success(this.$t("messages.bulk_success")); + this.fetch(); + }); + } + }, + selectAllRows() { + this.$refs.table.selectAllRows() + }, + onRowSelected(items) { + this.selected_pk_list = _.map(items, item => { + return item[this.pk]; + }) + }, + clearSelected() { + this.$refs.table.clearSelected() + }, doSearch(params) { this.where = _.omitBy(params, v => v === null); this.$refs.table.refresh(); - // console.log(params); }, searchAndExport() { const query = JSON.stringify({ @@ -300,6 +324,12 @@ fetch() { this.init = false; this.$http.get(this.uri + "/grid").then(res => { + _.mapValues(res.data.bulk_actions, action => { + action.text = this.$t(`actions.${action.text}`) + }); + + this.bulkActions = res.data.bulk_actions; + _.mapValues(res.data.fields, field => { field.thClass = "bg-light"; }); diff --git a/images/create.png b/images/create.png index 9b79d8b..84b4214 100644 Binary files a/images/create.png and b/images/create.png differ diff --git a/images/list.png b/images/list.png index 0749434..9629102 100644 Binary files a/images/list.png and b/images/list.png differ diff --git a/images/login.png b/images/login.png index 67afa2b..ae642de 100644 Binary files a/images/login.png and b/images/login.png differ diff --git a/images/view.png b/images/view.png index 9929d92..4cd3227 100644 Binary files a/images/view.png and b/images/view.png differ