new project

This commit is contained in:
long2ice
2021-04-25 17:17:21 +08:00
parent 28d66950fe
commit 7f957661ec
83 changed files with 2721 additions and 2752 deletions

View File

@ -1,17 +0,0 @@
name: deploy
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Deploy
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.KEY }}
port: ${{ secrets.PORT }}
script: |
cd /root/fastapi-admin/
git pull
docker-compose up -d --build

View File

@ -1,16 +0,0 @@
name: Publish docs via GitHub Pages
on:
push:
branches:
- master
jobs:
build:
name: Deploy docs
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v1
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,5 +1,12 @@
# ChangeLog
## 1.0
### 1.0.0
**This is a completely different version and rewrite all code. If you are using old version, you can't upgrade directly,
but completely rewrite your code. So just consider try it in your new project.**
## 0.3
### 0.3.3

View File

@ -1,23 +1,13 @@
checkfiles = fastapi_admin/ examples/ tests/
checkfiles = fastapi_admin/ tests/ examples/ conftest.py
black_opts = -l 100 -t py38
py_warn = PYTHONDEVMODE=1
help:
@echo "FastAPI-Admin development makefile"
@echo
@echo "usage: make <target>"
@echo "Targets:"
@echo " deps Ensure dev/test dependencies are installed"
@echo " check Checks that build is sane"
@echo " lint Reports all linter violations"
@echo " test Runs all tests"
@echo " style Auto-formats the code"
locales = fastapi_admin/locales
up:
@poetry update
deps:
@poetry install --no-root
@poetry install
style: deps
isort -src $(checkfiles)
@ -26,7 +16,8 @@ style: deps
check: deps
black --check $(black_opts) $(checkfiles) || (echo "Please run 'make style' to auto-fix style issues" && false)
flake8 $(checkfiles)
bandit -r $(checkfiles)
mypy $(checkfiles)
pylint $(checkfiles)
test: deps
$(py_warn) py.test
@ -34,8 +25,16 @@ test: deps
build: deps
@poetry build
docs: deps
@mkdocs build
ci: check test
deploy-docs: docs
@mkdocs gh-deploy
# i18n
extract:
@pybabel extract -F babel.cfg -o $(locales)/messages.pot ./
update:
@pybabel update -d $(locales) -i $(locales)/messages.pot
compile:
@pybabel compile -d $(locales)
babel: extract update

104
README.md
View File

@ -1,112 +1,18 @@
# FastAPI ADMIN
[![image](https://img.shields.io/pypi/v/fastapi-admin.svg?style=flat)](https://pypi.python.org/pypi/fastapi-admin)
[![image](https://img.shields.io/github/license/long2ice/fastapi-admin)](https://github.com/long2ice/fastapi-admin)
[![image](https://github.com/long2ice/fastapi-admin/workflows/gh-pages/badge.svg)](https://github.com/long2ice/fastapi-admin/actions?query=workflow:gh-pages)
[![image](https://github.com/long2ice/fastapi-admin/workflows/pypi/badge.svg)](https://github.com/long2ice/fastapi-admin/actions?query=workflow:pypi)
[中文文档](https://blog.long2ice.cn/2020/05/fastapi-admin%E5%BF%AB%E9%80%9F%E6%90%AD%E5%BB%BA%E5%9F%BA%E4%BA%8Efastapi%E4%B8%8Etortoise-orm%E7%9A%84%E7%AE%A1%E7%90%86%E5%90%8E%E5%8F%B0/)
[![image](https://img.shields.io/github/license/fastapi-admin/fastapi-admin)](https://github.com/fastapi-admin/fastapi-admin)
[![image](https://github.com/fastapi-admin/fastapi-admin/workflows/gh-pages/badge.svg)](https://github.com/fastapi-admin/fastapi-admin/actions?query=workflow:gh-pages)
[![image](https://github.com/fastapi-admin/fastapi-admin/workflows/pypi/badge.svg)](https://github.com/fastapi-admin/fastapi-admin/actions?query=workflow:pypi)
## Introduction
FastAPI-Admin is a admin dashboard based on
[fastapi](https://github.com/tiangolo/fastapi) and
[tortoise-orm](https://github.com/tortoise/tortoise-orm).
FastAPI-Admin provide crud feature out-of-the-box with just a few config.
## Live Demo
Check a live Demo here
[https://fastapi-admin.long2ice.cn](https://fastapi-admin.long2ice.cn/).
- username: `admin`
- password: `123456`
Data in database will restore every day.
## Screenshots
![image](https://github.com/long2ice/fastapi-admin/raw/master/images/login.png)
![image](https://github.com/long2ice/fastapi-admin/raw/master/images/list.png)
![image](https://github.com/long2ice/fastapi-admin/raw/master/images/view.png)
![image](https://github.com/long2ice/fastapi-admin/raw/master/images/create.png)
## Requirements
- [FastAPI](https://github.com/tiangolo/fastapi) framework as your backend framework.
- [Tortoise-ORM](https://github.com/tortoise/tortoise-orm) as your orm framework, by the way, which is best asyncio orm
so far and I\'m one of the contributors😋.
## Quick Start
### Run Backend
Look full example at
[examples](https://github.com/long2ice/fastapi-admin/tree/dev/examples).
1. `git clone https://github.com/long2ice/fastapi-admin.git`.
2. `docker-compose up -d --build`.
3. `docker-compose exec -T mysql mysql -uroot -p123456 < examples/example.sql fastapi-admin`.
4. That's just all, api server is listen at [http://127.0.0.1:8000](http://127.0.0.1:8000) now.
### Run Front
See
[restful-admin](https://github.com/long2ice/restful-admin)
for reference.
## Backend Integration
```shell
> pip3 install fastapi-admin
```
```Python
from fastapi_admin.factory import app as admin_app
fast_app = FastAPI()
register_tortoise(fast_app, config=TORTOISE_ORM, generate_schemas=True)
fast_app.mount('/admin', admin_app)
@fast_app.on_event('startup')
async def startup():
await admin_app.init(
admin_secret="test",
permission=True,
site=Site(
name="FastAPI-Admin DEMO",
login_footer="FASTAPI ADMIN - FastAPI Admin Dashboard",
login_description="FastAPI Admin Dashboard",
locale="en-US",
locale_switcher=True,
theme_switcher=True,
),
)
```
## Documentation
See documentation at [https://long2ice.github.io/fastapi-admin](https://long2ice.github.io/fastapi-admin).
## Deployment
Deploy fastapi app by gunicorn+uvicorn or reference
<https://fastapi.tiangolo.com/deployment/>.
## Restful API Docs
See [restful api](https://fastapi-admin-api.long2ice.cn/admin/docs)
docs.
See documentation at [https://fastapi-admin.github.io/fastapi-admin](https://fastapi-admin.github.io/fastapi-admin).
## License
This project is licensed under the
[Apache-2.0](https://github.com/long2ice/fastapi-admin/blob/master/LICENSE)
[Apache-2.0](https://github.com/fastapi-admin/fastapi-admin/blob/master/LICENSE)
License.

0
conftest.py Normal file
View File

View File

@ -1,377 +0,0 @@
## Builtin Auth And Permissions Control
You should inherit `fastapi_admin.models.AbstractUser`,`fastapi_admin.models.AbstractPermission`,`fastapi_admin.models.AbstractRole` and add extra fields.
```python
from fastapi_admin.models import AbstractUser, AbstractPermission, AbstractRole
class AdminUser(AbstractUser):
is_active = fields.BooleanField(default=False, description='Is Active')
is_superuser = fields.BooleanField(default=False, description='Is Superuser')
status = fields.IntEnumField(Status, description='User Status')
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Permission(AbstractPermission):
"""
must inheritance AbstractPermission
"""
class Role(AbstractRole):
"""
must inheritance AbstractRole
"""
class AdminLog(AbstractAdminLog):
"""
must inheritance AbstractAdminLog
"""
```
And set `permission=True` to active it:
```python
await admin_app.init(
...
permission=True,
site=Site(
...
),
)
```
And createsuperuser:
```shell
> fastapi-admin -h
usage: fastapi-admin [-h] -c CONFIG [--version] {createsuperuser} ...
optional arguments:
-h, --help show this help message and exit
-c CONFIG, --config CONFIG
Tortoise-orm config dict import path,like settings.TORTOISE_ORM.
--version, -V show the version
subcommands:
{createsuperuser}
```
Before you use this command - remember that you have to define your own user model that inherits from fastapi-admin `AbstractUser`.
eg.
```python
from fastapi_admin.models import AbstractUser
from tortoise import fields
class User(AbstractUser):
id = fields.BigIntField(pk=True)
```
Here's an example of how createsuperuser command can look like:
```shell
fastapi-admin -c "db.DB_CONFIG" createsuperuser -u User
```
The code above assumes that in your module's dir you have an `db.py` file in which there's, or that you provide a correct path on your own.
`-c` flag proceeds the path to your database config dict. It can look something like this.
```python
# db.py file
import os
DB_CONFIG: dict = {
"connections": {
"default": {
"engine": "tortoise.backends.asyncpg",
"credentials": {
"host": os.environ.get("DB_HOST", "localhost"),
"port": os.environ.get("DB_PORT", 5432),
"user": os.environ.get("DB_USER", "user"),
"password": os.environ.get("DB_PASSWORD", "secret_pass"),
"database": os.environ.get("DB_DATABASE_NAME", "db"),
},
}
# alternatively, probably only for testing purposes
# "default": "sqlite://db.sqlite3",
},
"apps": {"models": {"models": ["app.db_models"]}},
}
```
[Read more about configs and initialization in tortoise orm](https://tortoise-orm.readthedocs.io/en/latest/setup.html?highlight=config#tortoise.Tortoise.init)
After `-u` you can tell fastapi-admin which model inherits from AdminUser.
## Custom Login
You can write your own login view logic:
```python
await admin_app.init(
...,
login_view="examples.routes.login"
)
```
And must return json like:
```json
{
"user": {
"username": "admin",
"is_superuser": false,
"avatar": "https://avatars2.githubusercontent.com/u/13377178?s=460&u=d150d522579f41a52a0b3dd8ea997e0161313b6e&v=4"
},
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyfQ.HSlcYkOEQewxyPuaqcVwCcw_wkbLB50Ws1-ZxfPoLAQ"
}
```
## Enum Support
When you define a enum field of tortoise-orm,like `IntEnumField`,you can
inherit `fastapi_admin.enums.EnumMixin` and impl `choices()` method,
FastAPI-Admin will auto read and display and render a `select` widget in
front.
```python
class Status(EnumMixin, IntEnum):
on = 1
off = 2
@classmethod
def choices(cls):
return {
cls.on: 'ON',
cls.off: 'OFF'
}
```
## Help Text
FastAPI-Admin will auto read `description` defined in tortoise-orm model
`Field` and display in front with form help text.
## ForeignKeyField Support
If `ForeignKeyField` is not passed in `menu.raw_id_fields`,FastAPI-Admin
will get all related objects and display `select` in front with
`Model.__str__`.
## ManyToManyField Support
FastAPI-Admin will render `ManyToManyField` with multiple `select` in
`form` edit with `Model.__str__`.
## JSONField Render
FastAPI-Admin will render `JSONField` with `jsoneditor` as beauty
interface.
## Search Fields
Defined `menu.search_fields` in `menu` will render a search form by
fields.
## Xlsx Export
FastAPI-Admin can export searched data to excel file when define
`export=True` in `menu`.
## Bulk Actions
Current FastAPI-Admin supports builtin bulk action `delete_all`,if you
want to write your own bulk actions:
1. pass `bulk_actions` in `Menu`,example:
```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:
```python
from fastapi_admin.schemas import BulkIn
from fastapi_admin.factory import app as admin_app
@admin_app.post(
'/rest/{resource}/bulk/delete' # `delete` is defined in Menu 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}
```
## Default Menus
Default, FastAPI-Admin provide default menus by your models, without
doing tedious works. Therefore you do not need to fill the optional argument `menus` in Site definition.
## Custom Menus
You can define a custom menu that'll be used by fastapi-admin. Here's an example of how that might look.
```python
menus = [
Menu(name="Home", url="/", icon="fa fa-home"),
Menu(
name="Content",
children=[
Menu(name="Category", url="/rest/Category", icon="fa fa-list", search_fields=("slug",)),
Menu(name="Config", url="/rest/Config", icon="fa fa-gear", import_=True, search_fields=("key",)),
Menu(name="Product", url="/rest/Product", icon="fa fa-table", search_fields=("name",)),
],
),
Menu(
name="External",
children=[
Menu(name="Github", url="https://github.com/long2ice/fastapi-admin", icon="fa fa-github", external=True),
],
),
Menu(
name="Auth",
children=[
Menu(name="User", url="/rest/User", icon="fa fa-user", search_fields=("username",),),
Menu(name="Role", url="/rest/Role", icon="fa fa-group", ),
Menu(name="Permission", url="/rest/Permission", icon="fa fa-user-plus", ),
Menu(name="Logout", url="/logout", icon="fa fa-lock", ),
Menu(
name="AdminLog",
url="/rest/AdminLog",
icon="fa fa-align-left",
search_fields=("action", "admin", "model"),
),
],
),
]
```
Each menu can either be a single element menu that'll only link to a given resource, or it can be a gathering of multiple links, that you add by using the `children` optional argument.
`children` should be a list of `Menu` objects.
Now that you have your menus you can use them during the app initialization.
```python
menus = ... # look at the code above. You can define it here or in separate file to make things neat
@app.on_event("startup")
async def start_up():
await admin_app.init( # nosec
admin_secret="test",
permission=True,
admin_log=True,
site=Site(
name="FastAPI-Admin DEMO",
login_footer="FASweTAPI ADMIN - FastAPI Admin Dashboard",
login_description="FastAPI Admin Dashboard",
locale="en-US",
locale_switcher=True,
theme_switcher=True,
menus=menus
),
)
```
## Table Variant
You can define `RowVariant` and `CellVariants` in `computed` of `tortoise-orm`, which will effect table rows and cells variant.
```python
class User(AbstractUser):
last_login = fields.DatetimeField(description="Last Login", default=datetime.datetime.now)
avatar = fields.CharField(max_length=200, default="")
intro = fields.TextField(default="")
created_at = fields.DatetimeField(auto_now_add=True)
def __str__(self):
return f"{self.pk}#{self.username}"
def rowVariant(self) -> str:
if not self.is_active:
return "warning"
return ""
def cellVariants(self) -> dict:
if self.is_active:
return {
"intro": "info",
}
return {}
class PydanticMeta:
computed = ("rowVariant", "cellVariants")
```
## Admin log
You can log each admin action like `delete`,`create` and `update`,just set `admin_log=True` in `admin_app.init()` and just create a model in your app that inherits from `fastapi_admin.models.AbstractAdminLog`.
## Import from excel
You can enable `import` by set `import_=True` in `Menu` definition, and data format must same as `Model` fields.
## Custom filters
There are two kinds of filters named `Filter` and `SearchFilter`.
`Filter` use to filter view list default, and `SearchFilter` add a custom search input in front.
To use `Filter` you should only inherit `fastapi_admin.filters.Filter` then implement `get_queryset`, for example:
```py
from fastapi_admin.filters import Filter
class CustomFilter(Filter):
@classmethod
def get_queryset(cls, queryset: QuerySet) -> QuerySet:
return queryset.filter(~Q(key="test"))
```
Then add it to `Menu.custom_filters`.
```py
Menu(
name="Config",
url="/rest/Config",
icon="fa fa-gear",
import_=True,
search_fields=("key",),
custom_filters=[CustomFilter],
)
```
And to use `SearchFilter`, like `Filter` but inherit `fastapi_admin.filters.SearchFilter`, note that you show register it by `register_filter`, for example:
```py
from fastapi_admin.filters import SearchFilter, register_filter
from fastapi_admin.site import Field
@register_filter
class LikeFilter(SearchFilter):
@classmethod
def get_queryset(cls, queryset: QuerySet, value: Any) -> QuerySet:
return queryset.filter(name__icontains=value)
@classmethod
async def get_field(cls) -> Field:
return Field(label="NameLike", type="text")
@classmethod
def get_name(cls) -> str:
return "filter"
```
`get_name` must return an unque `name` for all `SearchFilter` and `get_field` should return a `Field` instance.

View File

@ -1,44 +0,0 @@
## Import app
First of all suppose you have a fastapi+tortoise-orm project and running normally, then first you should is import admin app from `fastapi-admin` and mount in root fastapi app.
```python hl_lines="6"
from fastapi_admin.factory import app as admin_app
def create_app():
fast_app = FastAPI(debug=False)
register_tortoise(fast_app, config=TORTOISE_ORM)
fast_app.mount("/admin", admin_app)
return fast_app
app = create_app()
if __name__ == "__main__":
uvicorn.run("main:app", port=8000, debug=False, reload=False, lifespan="on")
```
Now you can visit `http://127.0.0.1:8000/admin/docs` see all restful api comes from fastapi-admin.
## Init App
After mount admin app, you should init app now. Pay attention to that you should init admin app in fastapi `startup` event instead of run it directly.
```python
@app.on_event("startup")
async def start_up():
await admin_app.init( # nosec
admin_secret="test",
permission=True,
admin_log=True,
site=Site(
name="FastAPI-Admin DEMO",
login_footer="FASweTAPI ADMIN - FastAPI Admin Dashboard",
login_description="FastAPI Admin Dashboard",
locale="en-US",
locale_switcher=True,
theme_switcher=True,
),
)
```

View File

@ -0,0 +1 @@
from . import resources, routes # noqa

3
examples/constants.py Normal file
View File

@ -0,0 +1,3 @@
import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__))

View File

@ -1,21 +1,17 @@
from enum import IntEnum
from fastapi_admin.enums import EnumMixin
from enum import Enum, IntEnum
class ProductType(EnumMixin, IntEnum):
class ProductType(IntEnum):
article = 1
page = 2
@classmethod
def choices(cls):
return {cls.article: "Article", cls.page: "Page"}
class Status(EnumMixin, IntEnum):
class Status(IntEnum):
on = 1
off = 0
@classmethod
def choices(cls):
return {cls.on: "On", cls.off: "Off"}
class Action(str, Enum):
create = "create"
delete = "delete"
edit = "edit"

View File

@ -1,394 +0,0 @@
/*
Navicat Premium Data Transfer
Source Server : localhost
Source Server Type : MySQL
Source Server Version : 80019
Source Host : localhost:3306
Source Schema : fastapi-admin
Target Server Type : MySQL
Target Server Version : 80019
File Encoding : 65001
Date: 27/04/2020 23:26:07
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for category
-- ----------------------------
DROP TABLE IF EXISTS `category`;
CREATE TABLE `category`
(
`id` int NOT NULL AUTO_INCREMENT,
`slug` varchar(200) NOT NULL,
`name` varchar(200) NOT NULL,
`created_at` datetime(6) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 10
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- ----------------------------
-- Records of category
-- ----------------------------
BEGIN;
INSERT INTO `category`
VALUES (1, 'test', 'test', '2020-04-13 15:16:25.000000');
INSERT INTO `category`
VALUES (2, 'test', 'test', '2020-04-13 15:16:25.000000');
INSERT INTO `category`
VALUES (3, 'test', 'test', '2020-04-13 15:16:25.000000');
INSERT INTO `category`
VALUES (4, 'test', 'test', '2020-04-13 15:16:25.000000');
INSERT INTO `category`
VALUES (5, 'test', 'test', '2020-04-13 15:16:25.000000');
INSERT INTO `category`
VALUES (6, 'test', 'test', '2020-04-13 15:16:25.000000');
INSERT INTO `category`
VALUES (7, 'test', 'test', '2020-04-13 15:16:25.000000');
INSERT INTO `category`
VALUES (8, 'test', 'test', '2020-04-13 15:16:25.000000');
INSERT INTO `category`
VALUES (9, 'test', 'test', '2020-04-13 15:16:25.000000');
COMMIT;
-- ----------------------------
-- Table structure for config
-- ----------------------------
DROP TABLE IF EXISTS `config`;
CREATE TABLE `config`
(
`id` int NOT NULL AUTO_INCREMENT,
`label` varchar(20) NOT NULL,
`key` varchar(50) NOT NULL,
`value` longtext NOT NULL,
`status` tinyint(1) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `key` (`key`)
) ENGINE = InnoDB
AUTO_INCREMENT = 8
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- ----------------------------
-- Records of config
-- ----------------------------
BEGIN;
INSERT INTO `config`
VALUES (1, 'test', 'test',
'{\"status\":200,\"error\":\"\",\"data\":[{\"news_id\":51184,\"title\":\"iPhone X Review: Innovative future with real black technology\",\"source\":\"Netease phone\"},{\"news_id\":51183,\"title\":\"Traffic paradise: How to design streets for people and unmanned vehicles in the future?\",\"source\":\"Netease smart\"},{\"news_id\":51182,\"title\":\"Teslamask\'s American Business Relations: The government does not pay billions to build factories\",\"source\":\"AI Finance\",\"members\":[\"Daniel\",\"Mike\",\"John\"]}]}',
1);
COMMIT;
-- ----------------------------
-- Table structure for permission
-- ----------------------------
DROP TABLE IF EXISTS `permission`;
CREATE TABLE `permission`
(
`id` int NOT NULL AUTO_INCREMENT,
`label` varchar(50) NOT NULL,
`model` varchar(50) NOT NULL,
`action` smallint NOT NULL COMMENT 'create: 1\ndelete: 2\nupdate: 3\nread: 4',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 46
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- ----------------------------
-- Records of permission
-- ----------------------------
BEGIN;
INSERT INTO `permission`
VALUES (22, 'Delete Category', 'Category', 2);
INSERT INTO `permission`
VALUES (23, 'Update Category', 'Category', 3);
INSERT INTO `permission`
VALUES (24, 'Read Category', 'Category', 4);
INSERT INTO `permission`
VALUES (25, 'Create Product', 'Product', 1);
INSERT INTO `permission`
VALUES (26, 'Delete Product', 'Product', 2);
INSERT INTO `permission`
VALUES (27, 'Update Product', 'Product', 3);
INSERT INTO `permission`
VALUES (28, 'Read Product', 'Product', 4);
INSERT INTO `permission`
VALUES (29, 'Create User', 'User', 1);
INSERT INTO `permission`
VALUES (30, 'Delete User', 'User', 2);
INSERT INTO `permission`
VALUES (31, 'Update User', 'User', 3);
INSERT INTO `permission`
VALUES (32, 'Read User', 'User', 4);
INSERT INTO `permission`
VALUES (33, 'Create Permission', 'Permission', 1);
INSERT INTO `permission`
VALUES (34, 'Delete Permission', 'Permission', 2);
INSERT INTO `permission`
VALUES (35, 'Update Permission', 'Permission', 3);
INSERT INTO `permission`
VALUES (36, 'Read Permission', 'Permission', 4);
INSERT INTO `permission`
VALUES (37, 'Create Role', 'Role', 1);
INSERT INTO `permission`
VALUES (38, 'Delete Role', 'Role', 2);
INSERT INTO `permission`
VALUES (39, 'Update Role', 'Role', 3);
INSERT INTO `permission`
VALUES (40, 'Read Role', 'Role', 4);
INSERT INTO `permission`
VALUES (41, 'Create Category', 'Category', 1);
INSERT INTO `permission`
VALUES (42, 'Create Config', 'Config', 1);
INSERT INTO `permission`
VALUES (43, 'Delete Config', 'Config', 2);
INSERT INTO `permission`
VALUES (44, 'Update Config', 'Config', 3);
INSERT INTO `permission`
VALUES (45, 'Read Config', 'Config', 4);
INSERT INTO `permission`
VALUES (46, 'Create AdminLog', 'AdminLog', 1);
INSERT INTO `permission`
VALUES (47, 'Delete AdminLog', 'AdminLog', 2);
INSERT INTO `permission`
VALUES (48, 'Update AdminLog', 'AdminLog', 3);
INSERT INTO `permission`
VALUES (49, 'Read AdminLog', 'AdminLog', 4);
-- ----------------------------
-- Table structure for product
-- ----------------------------
DROP TABLE IF EXISTS `product`;
CREATE TABLE `product`
(
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`view_num` int NOT NULL,
`sort` int NOT NULL,
`is_reviewed` tinyint(1) NOT NULL,
`type` smallint NOT NULL COMMENT 'article: 1\npage: 2',
`image` varchar(200) NOT NULL,
`body` longtext NOT NULL,
`created_at` datetime(6) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 10
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- ----------------------------
-- Records of product
-- ----------------------------
BEGIN;
INSERT INTO `product`
VALUES (1, 'Phone', 10, 1, 1, 1, 'https://github.com/long2ice/fastapi-admin', 'test', '2020-04-13 15:16:56.000000');
INSERT INTO `product`
VALUES (2, 'Phone', 10, 1, 1, 1, 'https://github.com/long2ice/fastapi-admin', 'test', '2020-04-13 15:16:56.000000');
INSERT INTO `product`
VALUES (3, 'Phone', 10, 1, 1, 1, 'https://github.com/long2ice/fastapi-admin', 'test', '2020-04-13 15:16:56.000000');
INSERT INTO `product`
VALUES (4, 'Phone', 10, 1, 1, 1, 'https://github.com/long2ice/fastapi-admin', 'test', '2020-04-13 15:16:56.000000');
INSERT INTO `product`
VALUES (5, 'Phone', 10, 1, 1, 1, 'https://github.com/long2ice/fastapi-admin', 'test', '2020-04-13 15:16:56.000000');
INSERT INTO `product`
VALUES (6, 'Phone', 10, 1, 1, 1, 'https://github.com/long2ice/fastapi-admin', 'test', '2020-04-13 15:16:56.000000');
INSERT INTO `product`
VALUES (7, 'Phone', 10, 1, 1, 1, 'https://github.com/long2ice/fastapi-admin', 'test', '2020-04-13 15:16:56.000000');
INSERT INTO `product`
VALUES (8, 'Phone', 10, 1, 1, 1, 'https://github.com/long2ice/fastapi-admin', 'test', '2020-04-13 15:16:56.000000');
INSERT INTO `product`
VALUES (9, 'Phone', 10, 1, 1, 1, 'https://github.com/long2ice/fastapi-admin', 'test', '2020-04-13 15:16:56.000000');
COMMIT;
-- ----------------------------
-- Table structure for product_category
-- ----------------------------
DROP TABLE IF EXISTS `product_category`;
CREATE TABLE `product_category`
(
`product_id` int NOT NULL,
`category_id` int NOT NULL,
KEY `product_id` (`product_id`),
KEY `category_id` (`category_id`),
CONSTRAINT `product_category_ibfk_1` FOREIGN KEY (`product_id`) REFERENCES `product` (`id`) ON DELETE CASCADE,
CONSTRAINT `product_category_ibfk_2` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- ----------------------------
-- Records of product_category
-- ----------------------------
BEGIN;
INSERT INTO `product_category`
VALUES (1, 1);
COMMIT;
-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role`
(
`id` int NOT NULL AUTO_INCREMENT,
`label` varchar(50) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 2
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- ----------------------------
-- Records of role
-- ----------------------------
BEGIN;
INSERT INTO `role`
VALUES (1, 'user');
COMMIT;
-- ----------------------------
-- Table structure for role_permission
-- ----------------------------
DROP TABLE IF EXISTS `role_permission`;
CREATE TABLE `role_permission`
(
`role_id` int NOT NULL,
`permission_id` int NOT NULL,
KEY `role_id` (`role_id`),
KEY `permission_id` (`permission_id`),
CONSTRAINT `role_permission_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE,
CONSTRAINT `role_permission_ibfk_2` FOREIGN KEY (`permission_id`) REFERENCES `permission` (`id`) ON DELETE CASCADE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- ----------------------------
-- Records of role_permission
-- ----------------------------
BEGIN;
INSERT INTO `role_permission`
VALUES (1, 22);
INSERT INTO `role_permission`
VALUES (1, 23);
INSERT INTO `role_permission`
VALUES (1, 24);
INSERT INTO `role_permission`
VALUES (1, 25);
INSERT INTO `role_permission`
VALUES (1, 26);
INSERT INTO `role_permission`
VALUES (1, 27);
INSERT INTO `role_permission`
VALUES (1, 28);
INSERT INTO `role_permission`
VALUES (1, 32);
INSERT INTO `role_permission`
VALUES (1, 36);
INSERT INTO `role_permission`
VALUES (1, 40);
INSERT INTO `role_permission`
VALUES (1, 41);
INSERT INTO `role_permission`
VALUES (1, 42);
INSERT INTO `role_permission`
VALUES (1, 43);
INSERT INTO `role_permission`
VALUES (1, 44);
INSERT INTO `role_permission`
VALUES (1, 45);
INSERT INTO `role_permission`
VALUES (1, 49);
INSERT INTO `role_permission`
VALUES (1, 53);
COMMIT;
-- ----------------------------
-- Table structure for role_user
-- ----------------------------
DROP TABLE IF EXISTS `role_user`;
CREATE TABLE `role_user`
(
`role_id` int NOT NULL,
`user_id` int NOT NULL,
KEY `role_id` (`role_id`),
KEY `user_id` (`user_id`),
CONSTRAINT `role_user_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE,
CONSTRAINT `role_user_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- ----------------------------
-- Records of role_user
-- ----------------------------
BEGIN;
INSERT INTO `role_user`
VALUES (1, 2);
COMMIT;
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`
(
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(20) NOT NULL,
`password` varchar(200) NOT NULL,
`last_login` datetime(6) NOT NULL COMMENT 'Last Login',
`is_active` tinyint(1) NOT NULL COMMENT 'Is Active',
`avatar` varchar(200) NOT NULL,
`intro` longtext NOT NULL,
`created_at` datetime(6) NOT NULL,
`is_superuser` tinyint(1) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE = InnoDB
AUTO_INCREMENT = 8
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
-- ----------------------------
-- Records of user
-- ----------------------------
BEGIN;
INSERT INTO `user`
VALUES (1, 'long2ice', '$2b$12$CD5ImAgBr7TZpJABxuXASOXz/cAFMIhXsmnZCU.cvo/c.kOOpSkXq', '2020-04-13 12:44:06.000000', 1,
'https://avatars2.githubusercontent.com/u/13377178?s=460&u=d150d522579f41a52a0b3dd8ea997e0161313b6e&v=4',
'test', '2020-04-13 12:44:14.000000', 1);
INSERT INTO `user`
VALUES (2, 'admin', '$2b$12$mrRdNt8n5V8Lsmdh8OGCEOh3.xkUzJRbTo0Ew8IcdyNHjRTfJ0ptG', '2020-04-14 16:54:40.510165', 1,
'https://avatars2.githubusercontent.com/u/13377178?s=460&u=d150d522579f41a52a0b3dd8ea997e0161313b6e&v=4',
'test', '2020-04-14 16:54:40.510555', 0);
INSERT INTO `user`
VALUES (3, 'test', '$2b$12$mrRdNt8n5V8Lsmdh8OGCEOh3.xkUzJRbTo0Ew8IcdyNHjRTfJ0ptG', '2020-04-14 16:54:40.510165', 0,
'https://avatars2.githubusercontent.com/u/13377178?s=460&u=d150d522579f41a52a0b3dd8ea997e0161313b6e&v=4',
'test', '2020-04-14 16:54:40.510555', 0);
COMMIT;
-- ----------------------------
-- Table structure for adminlog
-- ----------------------------
DROP TABLE IF EXISTS `adminlog`;
CREATE TABLE `adminlog`
(
`admin_log_id` int NOT NULL AUTO_INCREMENT,
`action` varchar(20) NOT NULL,
`model` varchar(50) NOT NULL,
`content` text NOT NULL,
`admin_id` int NOT NULL,
PRIMARY KEY (`admin_log_id`),
KEY `fk_adminlog_user_50bc034f` (`admin_id`),
CONSTRAINT `fk_adminlog_user_50bc034f` FOREIGN KEY (`admin_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
SET FOREIGN_KEY_CHECKS = 1;

View File

@ -1,28 +0,0 @@
from typing import Any
from tortoise.query_utils import Q
from tortoise.queryset import QuerySet
from fastapi_admin.filters import Filter, SearchFilter, register_filter
from fastapi_admin.site import Field
class CustomFilter(Filter):
@classmethod
def get_queryset(cls, queryset: QuerySet) -> QuerySet:
return queryset.filter(~Q(key="test"))
@register_filter
class LikeFilter(SearchFilter):
@classmethod
def get_queryset(cls, queryset: QuerySet, value: Any) -> QuerySet:
return queryset.filter(name__icontains=value)
@classmethod
async def get_field(cls) -> Field:
return Field(label="NameLike", type="text")
@classmethod
def get_name(cls) -> str:
return "filter"

View File

@ -1,133 +1,49 @@
import os
import uvicorn
from fastapi import Depends, FastAPI
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from starlette.templating import Jinja2Templates
from starlette.staticfiles import StaticFiles
from tortoise.contrib.fastapi import register_tortoise
from tortoise.contrib.pydantic import pydantic_queryset_creator
from examples.filters import CustomFilter, LikeFilter
from fastapi_admin.depends import get_model
from fastapi_admin.factory import app as admin_app
from fastapi_admin.schemas import BulkIn
from fastapi_admin.site import Menu, Site
TORTOISE_ORM = {
"connections": {"default": os.getenv("DATABASE_URL")},
"apps": {"models": {"models": ["examples.models"], "default_connection": "default"}},
}
templates = Jinja2Templates(directory="examples/templates")
@admin_app.post("/rest/{resource}/bulk/test_bulk")
async def test_bulk(bulk_in: BulkIn, model=Depends(get_model)):
qs = model.filter(pk__in=bulk_in.pk_list)
pydantic = pydantic_queryset_creator(model)
ret = await pydantic.from_queryset(qs)
return ret.dict()
@admin_app.get("/home",)
async def home():
return {"html": templates.get_template("home.html").render()}
from examples import providers, settings
from examples.constants import BASE_DIR
from fastapi_admin.app import app as admin_app
def create_app():
fast_app = FastAPI(debug=False)
register_tortoise(fast_app, config=TORTOISE_ORM)
fast_app.mount("/admin", admin_app)
fast_app.add_middleware(
app = FastAPI()
app.mount(
"/static",
StaticFiles(directory=os.path.join(BASE_DIR, "static")),
name="static",
)
admin_app.configure(
logo_url="https://preview.tabler.io/static/logo-white.svg",
template_folders=[os.path.join(BASE_DIR, "templates")],
login_provider=providers.Login,
)
app.mount("/admin", admin_app)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
return fast_app
app = create_app()
@app.on_event("startup")
async def start_up():
await admin_app.init( # nosec
admin_secret="test",
permission=True,
admin_log=True,
site=Site(
name="FastAPI-Admin DEMO",
login_footer="FASweTAPI ADMIN - FastAPI Admin Dashboard",
login_description="FastAPI Admin Dashboard",
locale="en-US",
locale_switcher=True,
theme_switcher=True,
menus=[
Menu(name="Home", url="/", icon="fa fa-home"),
Menu(
name="Content",
children=[
Menu(
name="Category",
url="/rest/Category",
icon="fa fa-list",
search_fields=("slug", LikeFilter),
),
Menu(
name="Config",
url="/rest/Config",
icon="fa fa-gear",
import_=True,
search_fields=("key",),
custom_filters=[CustomFilter],
),
Menu(
name="Product",
url="/rest/Product",
icon="fa fa-table",
search_fields=("name",),
),
],
),
Menu(
name="External",
children=[
Menu(
name="Github",
url="https://github.com/long2ice/fastapi-admin",
icon="fa fa-github",
external=True,
),
],
),
Menu(
name="Auth",
children=[
Menu(
name="User",
url="/rest/User",
icon="fa fa-user",
search_fields=("username",),
),
Menu(name="Role", url="/rest/Role", icon="fa fa-group",),
Menu(name="Permission", url="/rest/Permission", icon="fa fa-user-plus",),
Menu(
name="AdminLog",
url="/rest/AdminLog",
icon="fa fa-align-left",
search_fields=("action", "admin", "model"),
),
Menu(name="Logout", url="/logout", icon="fa fa-lock",),
],
),
],
),
register_tortoise(
app,
config={
"connections": {"default": settings.DATABASE_URL},
"apps": {"models": {"models": ["examples.models"], "default_connection": "default"}},
},
generate_schemas=True,
)
return app
app_ = create_app()
if __name__ == "__main__":
uvicorn.run("main:app", port=8000, debug=False, reload=False, lifespan="on")
uvicorn.run("main:app_", debug=True, reload=True)

0
examples/middlewares.py Normal file
View File

View File

@ -2,12 +2,15 @@ import datetime
from tortoise import Model, fields
from fastapi_admin.models import AbstractAdminLog, AbstractPermission, AbstractRole, AbstractUser
from .enums import ProductType, Status
from examples.enums import Action, ProductType, Status
from fastapi_admin.providers.login import UserMixin
class User(AbstractUser):
class User(UserMixin):
is_active = fields.BooleanField(
default=True,
)
is_superuser = fields.BooleanField(default=False)
last_login = fields.DatetimeField(description="Last Login", default=datetime.datetime.now)
avatar = fields.CharField(max_length=200, default="")
intro = fields.TextField(default="")
@ -16,48 +19,12 @@ class User(AbstractUser):
def __str__(self):
return f"{self.pk}#{self.username}"
def rowVariant(self) -> str:
if not self.is_active:
return "warning"
return ""
def cellVariants(self) -> dict:
if self.is_active:
return {
"intro": "info",
}
return {}
class PydanticMeta:
computed = ("rowVariant", "cellVariants")
class Permission(AbstractPermission):
"""
must inheritance AbstractPermission
"""
class Role(AbstractRole):
"""
must inheritance AbstractRole
"""
class AdminLog(AbstractAdminLog):
"""
must inheritance AbstractAdminLog
"""
class Category(Model):
slug = fields.CharField(max_length=200)
name = fields.CharField(max_length=200)
created_at = fields.DatetimeField(auto_now_add=True)
def __str__(self):
return f"{self.pk}#{self.name}"
class Product(Model):
categories = fields.ManyToManyField("models.Category")
@ -70,15 +37,20 @@ class Product(Model):
body = fields.TextField()
created_at = fields.DatetimeField(auto_now_add=True)
def __str__(self):
return f"{self.pk}#{self.name}"
class Config(Model):
label = fields.CharField(max_length=200)
key = fields.CharField(max_length=20)
key = fields.CharField(max_length=20, unique=True, description="Unique key for config")
value = fields.JSONField()
status: Status = fields.IntEnumField(Status, default=Status.on)
def __str__(self):
return f"{self.pk}#{self.label}"
class Log(Model):
user = fields.ForeignKeyField("models.User")
content = fields.TextField()
resource = fields.CharField(max_length=50)
action = fields.CharEnumField(Action, default=Action.create)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
ordering = ["-id"]

6
examples/providers.py Normal file
View File

@ -0,0 +1,6 @@
from examples.models import User
from fastapi_admin.providers.login import UsernamePasswordProvider
class Login(UsernamePasswordProvider):
model = User

141
examples/resources.py Normal file
View File

@ -0,0 +1,141 @@
import os
from examples import enums
from examples.constants import BASE_DIR
from examples.models import Category, Config, Log, Product, User
from fastapi_admin.app import app
from fastapi_admin.providers.file_upload import FileUploadProvider
from fastapi_admin.resources import Dropdown, Field, Link, Model
from fastapi_admin.widgets import displays, filters, inputs
upload_provider = FileUploadProvider(uploads_dir=os.path.join(BASE_DIR, "static", "uploads"))
@app.register
class Home(Link):
label = "Home"
icon = "ti ti-home"
url = "/admin"
@app.register
class UserResource(Model):
label = "User"
model = User
icon = "ti ti-user"
page_pre_title = "user list"
page_title = "user model"
filters = [
filters.Search(
name="username", label="Name", search_mode="contains", placeholder="Search for username"
),
filters.Date(name="created_at", label="CreatedAt"),
]
fields = [
"id",
"username",
Field(
name="password",
label="Password",
display=displays.InputOnly(),
input_=inputs.Password(),
),
Field(name="email", label="Email", input_=inputs.Email()),
Field(
name="avatar",
label="Avatar",
display=displays.Image(width="40"),
input_=inputs.Image(null=True, upload_provider=upload_provider),
),
"is_superuser",
"is_active",
"created_at",
]
@app.register
class Content(Dropdown):
class CategoryResource(Model):
label = "Category"
model = Category
fields = ["id", "name", "slug", "created_at"]
class ProductResource(Model):
label = "Product"
model = Product
filters = [
filters.Enum(enum=enums.ProductType, name="type", label="ProductType"),
filters.Datetime(name="created_at", label="CreatedAt"),
]
fields = [
"id",
"name",
"view_num",
"sort",
"is_reviewed",
"type",
Field(name="image", label="Image", display=displays.Image(width="40")),
"body",
"created_at",
]
label = "Content"
icon = "ti ti-package"
resources = [ProductResource, CategoryResource]
@app.register
class ConfigResource(Model):
label = "Config"
model = Config
icon = "ti ti-settings"
filters = [
filters.Enum(enum=enums.Status, name="status", label="Status"),
filters.Search(name="key", label="Key", search_mode="equal"),
]
fields = [
"id",
"label",
"key",
"value",
Field(
name="status",
label="Status",
input_=inputs.RadioEnum(enums.Status, default=enums.Status.on),
),
]
@app.register
class LogResource(Model):
label = "Log"
model = Log
icon = "ti ti-file-report"
fields = [
"id",
"user",
"resource",
"content",
"action",
"created_at",
]
filters = [
filters.ForeignKey(name="user_id", label="User", model=User),
filters.Date(name="created_at", label="CreatedAt"),
]
@app.register
class GithubLink(Link):
label = "Github"
url = "https://github.com/long2ice"
icon = "ti ti-brand-github"
target = "_blank"
@app.register
class DocumentationLink(Link):
label = "Documentation"
url = "https://long2ice.github.io/fastadmin"
icon = "ti ti-file-text"
target = "_blank"

23
examples/routes.py Normal file
View File

@ -0,0 +1,23 @@
from fastapi import Depends
from starlette.requests import Request
from fastapi_admin.app import app
from fastapi_admin.depends import get_resources
from fastapi_admin.template import templates
@app.get("/")
async def home(
request: Request,
resources=Depends(get_resources),
):
return templates.TemplateResponse(
"home.html",
context={
"request": request,
"resources": resources,
"resource_label": "Home",
"page_pre_title": "home",
"page_title": "Home page",
},
)

6
examples/settings.py Normal file
View File

@ -0,0 +1,6 @@
import os
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -1,9 +1,4 @@
<div class="animated fadeIn">
<div class="row"></div>
<div class="jumbotron mt-3"><h1 class="display-4">Welcome to FastAPI ADMIN</h1>
<div class="lead">
</div>
</div>
</div>
<h2>Stargazers over time</h2>
<div><img src="https://starchart.cc/long2ice/fastapi-admin.svg" alt="Stargazers over time"></div>
{% extends "layout.html" %}
{% block page_body %}
<div>This is home page</div>
{% endblock %}

View File

@ -1,8 +1 @@
from . import routes # noqa: F401
def version():
# with open("pyproject.toml") as f:
# ret = re.findall(r'version = "(\d+\.\d+\.\d+)"', f.read())
# return ret[0]
return "0.3.3"
VERSION = "0.1.0"

80
fastapi_admin/app.py Normal file
View File

@ -0,0 +1,80 @@
from typing import Dict, List, Optional, Type
from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware
from tortoise import Model
from . import middlewares, template
from .providers.login import LoginProvider
from .resources import Dropdown
from .resources import Model as ModelResource
from .resources import Resource
from .routes import router
class FastAdmin(FastAPI):
logo_url: str
admin_path: str
resources: List[Type[Resource]] = []
model_resources: Dict[Type[Model], Type[Resource]] = {}
login_provider: Optional[Type[LoginProvider]] = LoginProvider
def configure(
self,
logo_url: str = None,
default_locale: str = "en_US",
admin_path: str = "/admin",
template_folders: Optional[List[str]] = None,
login_provider: Optional[Type[LoginProvider]] = LoginProvider,
):
"""
Config FastAdmin
:param maintenance: If set True, all request will redirect to maintenance page
:param logo_url:
:param default_locale:
:param admin_path:
:param template_folders:
:param login_provider:
:return:
"""
template.set_locale(default_locale)
self.admin_path = admin_path
self.logo_url = logo_url
if template_folders:
template.add_template_folder(*template_folders)
self.login_provider = login_provider
self._register_providers()
def _register_providers(self):
if self.login_provider:
login_path = self.login_provider.login_path
app.get(login_path)(self.login_provider.get)
app.post(login_path)(self.login_provider.post)
app.get(self.login_provider.logout_path)(self.login_provider.logout)
app.add_middleware(BaseHTTPMiddleware, dispatch=self.login_provider.authenticate)
def register_resources(self, *resource: Type[Resource]):
for r in resource:
self.register(r)
def _set_model_resource(self, resource: Type[Resource]):
if issubclass(resource, ModelResource):
self.model_resources[resource.model] = resource
elif issubclass(resource, Dropdown):
for r in resource.resources:
self._set_model_resource(r)
def register(self, resource: Type[Resource]):
self._set_model_resource(resource)
self.resources.append(resource)
def get_model_resource(self, model: Type[Model]):
return self.model_resources[model]
app = FastAdmin(
title="FastAdmin",
description="A fast admin dashboard based on fastapi and tortoise-orm with tabler ui.",
)
app.add_middleware(BaseHTTPMiddleware, dispatch=middlewares.language_processor)
app.include_router(router)

View File

@ -1,83 +0,0 @@
import argparse
import sys
from colorama import Fore, init
from prompt_toolkit import PromptSession
from tortoise import Tortoise, run_async
from fastapi_admin import version
from fastapi_admin.common import import_obj, pwd_context
init(autoreset=True)
class Logger:
@classmethod
def success(cls, text):
print(Fore.GREEN + text)
@classmethod
def waring(cls, text):
print(Fore.YELLOW + text)
@classmethod
def error(cls, text):
print(Fore.RED + text)
async def init_tortoise(args):
await Tortoise.init(config=import_obj(args.config))
async def createsuperuser(args):
await init_tortoise(args)
user_model = Tortoise.apps.get("models").get(args.user)
prompt = PromptSession()
while True:
try:
username = await prompt.prompt_async("Username: ")
password = await prompt.prompt_async("Password: ", is_password=True)
try:
await user_model.create(
username=username, password=pwd_context.hash(password), is_superuser=True
)
Logger.success(f"Create superuser {username} success.")
return
except Exception as e:
Logger.error(f"Create superuser {username} error,{e}")
except (EOFError, KeyboardInterrupt):
Logger.success("Exit success!")
return
def cli():
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(title="subcommands")
parser.add_argument(
"-c",
"--config",
required=True,
help="Tortoise-orm config dict import path,like settings.TORTOISE_ORM.",
)
parser.add_argument(
"--version",
"-V",
action="version",
version=f"fastapi-admin version, {version()}",
help="show the version",
)
parser_createsuperuser = subparsers.add_parser("createsuperuser")
parser_createsuperuser.add_argument(
"-u", "--user", required=True, help="User model name, like User or Admin."
)
parser_createsuperuser.set_defaults(func=createsuperuser)
parse_args = parser.parse_args()
run_async(parse_args.func(parse_args))
def main():
sys.path.insert(0, ".")
cli()

View File

@ -1,87 +0,0 @@
import importlib
from copy import deepcopy
from fastapi import HTTPException
from passlib.context import CryptContext
from tortoise import Tortoise
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
async def handle_m2m_fields_create_or_update(
body, m2m_fields, model, user_model, create=True, pk=None
):
"""
handle m2m update or create
:param user_model:
:param body:
:param m2m_fields:
:param model:
:param create:
:param pk:
:return:
"""
copy_body = deepcopy(body)
m2m_body = {}
for k, v in body.items():
if k in m2m_fields:
m2m_body[k] = copy_body.pop(k)
if model == user_model:
password = copy_body.get("password")
if not create:
user = await user_model.get(pk=pk)
if user.password != password:
copy_body["password"] = pwd_context.hash(password)
else:
copy_body["password"] = pwd_context.hash(password)
if create:
obj = await model.create(**copy_body)
else:
await model.filter(pk=pk).update(**copy_body)
obj = await model.get(pk=pk)
for k, v in m2m_body.items():
m2m_related = getattr(obj, k)
if not create:
await m2m_related.clear()
m2m_model = m2m_related.remote_model
m2m_objs = await m2m_model.filter(pk__in=v)
await m2m_related.add(*m2m_objs)
return obj
def import_obj(path: str):
"""
import obj from module path
:param path:
:return:
"""
splits = path.split(".")
module = ".".join(splits[:-1])
class_name = splits[-1]
return getattr(importlib.import_module(module), class_name)
def get_all_models():
"""
get all tortoise models
:return:
"""
for tortoise_app, models in Tortoise.apps.items():
for model_item in models.items():
yield model_item
async def check_has_permission(user, model: str):
"""
check user has permission for model
:param user:
:param model:
:return:
"""
has_permission = False
for role in user.roles:
permission = await role.permissions.filter(model=model).exists()
if permission:
has_permission = True
break
return has_permission

View File

@ -0,0 +1,7 @@
import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
DATE_FORMAT = "%Y-%m-%d"
DATETIME_FORMAT_MONENT = "YYYY-MM-DD HH:mm:ss"
DATE_FORMAT_MONENT = "YYYY-MM-DD"

View File

@ -1,149 +1,50 @@
import json
from typing import List, Optional, Type
import jwt
from fastapi import Depends, HTTPException, Path, Query
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.security.utils import get_authorization_scheme_param
from pydantic import BaseModel
from fastapi import Depends
from fastapi.params import Path
from starlette.requests import Request
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND
from tortoise import Tortoise
from . import enums
from .factory import app
from .models import AbstractUser
auth_schema = HTTPBearer()
from fastapi_admin.exceptions import InvalidResource
from fastapi_admin.resources import Dropdown, Link, Model, Resource
async def jwt_required(
request: Request, token: HTTPAuthorizationCredentials = Depends(auth_schema)
):
credentials_exception = HTTPException(HTTP_401_UNAUTHORIZED)
try:
payload = jwt.decode(token.credentials, app.admin_secret, algorithms=["HS256"])
user_id = payload.get("user_id")
if user_id is None:
raise credentials_exception
except jwt.PyJWTError:
raise credentials_exception
request.scope["user_id"] = user_id
return user_id
async def jwt_optional(request: Request):
authorization: str = request.headers.get("Authorization")
scheme, credentials = get_authorization_scheme_param(authorization)
if credentials:
try:
payload = jwt.decode(credentials, app.admin_secret, algorithms=["HS256"])
user_id = payload.get("user_id")
request.scope["user_id"] = user_id
return user_id
except jwt.PyJWTError:
pass
def get_model(resource: Optional[str] = Path(...)):
if not resource:
return
class QueryItem(BaseModel):
page: int = 1
sort: dict
where: dict = {}
with_: dict = {}
size: int = 10
sort: dict = {}
class Config:
fields = {"with_": "with"}
def get_query(query=Query(...)):
query = json.loads(query)
return QueryItem.parse_obj(query)
def get_model(resource: str = Path(...)):
model = app.models.get(resource)
for app, models in Tortoise.apps.items():
model = models.get(resource.title())
if model:
return model
async def parse_body(request: Request, resource: str = Path(...)):
body = await request.json()
resource = await app.get_resource(resource, exclude_pk=True, exclude_m2m_field=False)
resource_fields = resource.resource_fields.keys()
ret = {}
for key in resource_fields:
v = body.get(key)
if v is not None:
ret[key] = v
return ret, resource_fields
def get_model_resource(request: Request, model=Depends(get_model)):
return request.app.get_model_resource(model)
async def get_current_user(user_id=Depends(jwt_required)):
user = await app.user_model.get_or_none(pk=user_id)
if not user:
raise HTTPException(HTTP_404_NOT_FOUND)
return user
def _get_resources(resources: List[Type[Resource]]):
ret = []
for resource in resources:
item = {
"icon": resource.icon,
"label": resource.label,
}
if issubclass(resource, Link):
item["type"] = "link"
item["url"] = resource.url
item["target"] = resource.target
elif issubclass(resource, Model):
item["type"] = "model"
item["model"] = resource.model.__name__.lower()
elif issubclass(resource, Dropdown):
item["type"] = "dropdown"
item["resources"] = _get_resources(resource.resources)
else:
raise InvalidResource("Should be subclass of Resource")
ret.append(item)
return ret
class PermissionsChecker:
def __init__(self, action: enums.PermissionAction):
self.action = action
async def __call__(self, resource: str = Path(...), user=Depends(get_current_user)):
if not app.permission or user.is_superuser:
return
if not user.is_active:
raise HTTPException(status_code=HTTP_403_FORBIDDEN)
has_permission = False
await user.fetch_related("roles")
for role in user.roles:
if await role.permissions.filter(model=resource, action=self.action):
has_permission = True
break
if not has_permission:
raise HTTPException(status_code=HTTP_403_FORBIDDEN)
read_checker = PermissionsChecker(action=enums.PermissionAction.read)
create_checker = PermissionsChecker(action=enums.PermissionAction.create)
update_checker = PermissionsChecker(action=enums.PermissionAction.update)
delete_checker = PermissionsChecker(action=enums.PermissionAction.delete)
class AdminLog:
def __init__(self, action: str):
self.action = action
async def __call__(
self, request: Request, resource: str = Path(...), admin_id=Depends(jwt_required),
):
if app.admin_log:
content = None
if request.method in ["POST", "PUT"]:
content = await request.json()
elif request.method == "DELETE":
content = {"pk": request.path_params.get("id")}
if content:
await app.admin_log_model.create(
admin_id=admin_id, action=self.action, model=resource, content=content
)
admin_log_create = AdminLog(action="create")
admin_log_update = AdminLog(action="update")
admin_log_delete = AdminLog(action="delete")
class HasPermission:
def __init__(self, action: enums.PermissionAction):
self.action = action
async def has_resource_permission(
action: enums.PermissionAction, resource: str, user: AbstractUser
) -> bool:
try:
await PermissionsChecker(action=action)(resource, user)
return True
except HTTPException:
return False
def get_resources(request: Request) -> List[dict]:
resources = request.app.resources
return _get_resources(resources)

View File

@ -1,25 +0,0 @@
import abc
from enum import IntEnum
class EnumMixin:
@classmethod
@abc.abstractmethod
def choices(cls):
pass
class PermissionAction(EnumMixin, IntEnum):
create = 1
delete = 2
update = 3
read = 4
@classmethod
def choices(cls):
return {
cls.create: "Create",
cls.delete: "Delete",
cls.update: "Update",
cls.read: "Read",
}

View File

@ -1,7 +1,33 @@
from fastapi.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import JSONResponse
from fastapi import HTTPException
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
async def exception_handler(request: Request, exc: HTTPException):
return JSONResponse(status_code=exc.status_code, content={"msg": exc.detail},)
class ServerHTTPException(HTTPException):
def __init__(self, error: str = None):
super(ServerHTTPException, self).__init__(
status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail=error
)
class InvalidResource(ServerHTTPException):
"""
raise when has invalid resource
"""
class NoSuchFieldFound(ServerHTTPException):
"""
raise when no such field for the given
"""
class FileMaxSizeLimit(ServerHTTPException):
"""
raise when the upload file exceeds the max size
"""
class FileExtNotAllowed(ServerHTTPException):
"""
raise when the upload file ext not allowed
"""

View File

@ -1,363 +0,0 @@
from typing import Dict, List, Optional, Type
import jwt
from fastapi import FastAPI, HTTPException
from starlette.status import HTTP_403_FORBIDDEN
from tortoise import Model
from . import enums
from .common import get_all_models, import_obj, pwd_context
from .exceptions import exception_handler
from .filters import SearchFilter
from .models import AbstractAdminLog, AbstractPermission, AbstractRole, AbstractUser
from .schemas import LoginIn
from .shortcuts import get_object_or_404
from .site import Field, Menu, Resource, Site
async def login(login_in: LoginIn):
user_model = app.user_model
user = await get_object_or_404(user_model, username=login_in.username)
if not user.is_active:
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="User is not Active!")
if not pwd_context.verify(login_in.password, user.password):
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Incorrect Password!")
ret = {
"user": {
"username": user.username,
"is_superuser": user.is_superuser,
"avatar": user.avatar if hasattr(user, "avatar") else None,
},
"token": jwt.encode({"user_id": user.pk}, app.admin_secret, algorithm="HS256"),
}
return ret
class AdminApp(FastAPI):
models: Dict[str, Type[Model]] = {}
admin_secret: str
user_model: Type[Model]
permission_model: Type[Model]
role_model: Type[Model]
admin_log_model: Type[Model]
site: Site
permission: bool
admin_log: bool
_inited: bool = False
_field_type_mapping = {
"IntField": "number",
"BooleanField": "checkbox",
"DatetimeField": "datetime",
"DateField": "date",
"IntEnumFieldInstance": "select",
"CharEnumFieldInstance": "select",
"DecimalField": "number",
"FloatField": "number",
"TextField": "textarea",
"SmallIntField": "number",
"JSONField": "json",
}
model_menu_mapping: Dict[str, Menu] = {}
def _get_model_menu_mapping(self, menus: List[Menu]):
for menu in filter(lambda x: (x.url and "rest" in x.url) or x.children, menus):
if menu.children:
self._get_model_menu_mapping(menu.children)
else:
self.model_menu_mapping[menu.url.split("?")[0].split("/")[-1]] = menu
def _get_model_fields_type(self, model: Type[Model]) -> Dict:
model_describe = model.describe()
ret = {}
data_fields = model_describe.get("data_fields")
pk_field = model_describe.get("pk_field")
fk_fields = model_describe.get("fk_fields")
m2m_fields = model_describe.get("m2m_fields")
fields = [pk_field] + data_fields + fk_fields + m2m_fields
for field in fields:
ret[field.get("name")] = self._get_field_type(
field.get("name"), field.get("field_type")
)
return ret
def _build_content_menus(self) -> List[Menu]:
menus = []
for model_name, model in get_all_models():
if issubclass(model, (AbstractUser, AbstractPermission, AbstractRole)):
continue
menu = Menu(
name=model._meta.table_description or model_name,
url=f"/rest/{model_name}",
fields_type=self._get_model_fields_type(model),
icon="icon-list",
bulk_actions=[{"value": "delete", "text": "delete_all"}],
)
menus.append(menu)
return menus
def _build_default_menus(self, permission=True):
"""
build default menus when menus config not set
:return:
"""
menus = [
Menu(name="Home", url="/", icon="fa fa-home"),
Menu(name="Content", children=self._build_content_menus()),
Menu(
name="External",
children=[
Menu(
name="Github",
url="https://github.com/long2ice/fastapi-admin",
icon="fa fa-github",
external=True,
),
],
),
]
if permission:
permission_menus = [
Menu(
name="Auth",
children=[
Menu(
name="User",
url="/rest/User",
icon="fa fa-user",
search_fields=("username",),
),
Menu(name="Role", url="/rest/Role", icon="fa fa-group",),
Menu(name="Permission", url="/rest/Permission", icon="fa fa-user-plus",),
Menu(name="Logout", url="/logout", icon="fa fa-lock",),
],
),
]
menus += permission_menus
return menus
async def init(
self,
site: Site,
admin_secret: str,
permission: bool = False,
admin_log: bool = False,
login_view: Optional[str] = None,
):
"""
init admin site
:param admin_log:
:param login_view:
:param permission: active builtin permission
:param site:
:param admin_secret: admin jwt secret.
:return:
"""
self.site = site
self.permission = permission
self.admin_secret = admin_secret
self.admin_log = admin_log
for model_name, model in get_all_models():
if issubclass(model, AbstractUser):
self.user_model = model
elif issubclass(model, AbstractAdminLog):
self.admin_log_model = model
self.models[model_name] = model
self._inited = True
if not site.menus:
site.menus = self._build_default_menus(permission)
if permission:
await self._register_permissions()
self._get_model_menu_mapping(site.menus)
if login_view:
self.add_api_route("/login", import_obj(login_view), methods=["POST"])
else:
self.add_api_route("/login", login, methods=["POST"])
async def _register_permissions(self):
permission_model = None
for model_name, model in get_all_models():
if issubclass(model, AbstractPermission):
permission_model = model
break
if not permission_model:
raise Exception("No Permission Model Founded.")
for model, _ in get_all_models():
for action in enums.PermissionAction.choices():
label = f"{enums.PermissionAction.choices().get(action)} {model}"
defaults = dict(label=label, model=model, action=action,)
await permission_model.get_or_create(**defaults,)
def _exclude_field(self, resource: str, field: str):
"""
exclude field by menu include and exclude
:param resource:
:param field:
:return:
"""
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 _get_field_type(self, name: str, field_type: str, menu: Optional[Menu] = None) -> str:
"""
get field display type
:param menu:
:param field_type:
:return:
"""
field_type = self._field_type_mapping.get(field_type) or "text"
if menu:
field_type = menu.fields_type.get(name) or field_type
return field_type
async def _build_resource_from_model_describe(
self,
resource: str,
model: Type[Model],
model_describe: dict,
exclude_pk: bool,
exclude_m2m_field=True,
exclude_actions=False,
):
"""
build resource
:param resource:
:param model:
:param model_describe:
:param exclude_pk:
:param exclude_m2m_field:
:return:
"""
data_fields = model_describe.get("data_fields")
pk_field = model_describe.get("pk_field")
fk_fields = model_describe.get("fk_fields")
m2m_fields = model_describe.get("m2m_fields")
menu = self.model_menu_mapping[resource]
search_fields_ret = {}
search_fields = menu.search_fields
sort_fields = menu.sort_fields
fields = {}
pk = name = pk_field.get("name")
# CustomSearchFilters
for search_filter in filter(
lambda x: type(x).__name__ == "type" and issubclass(x, SearchFilter), search_fields
):
search_fields_ret[search_filter.get_name()] = await search_filter.get_field()
if not exclude_pk and not self._exclude_field(resource, name):
field = Field(
label=pk_field.get("name").title(),
required=True,
type=self._get_field_type(name, pk_field.get("field_type").__name__, menu),
sortable=name in sort_fields,
description=pk_field.get("description"),
)
field = field.copy(update=menu.attrs.get(name) or {})
fields = {name: field}
if not exclude_actions and menu.actions:
fields["_actions"] = menu.actions
for data_field in data_fields:
readonly = data_field.get("constraints").get("readOnly")
field_type = data_field.get("field_type").__name__
name = data_field.get("name")
if self._exclude_field(resource, name):
continue
type_ = self._get_field_type(name, field_type, menu)
options = []
if type_ == "select" or type_ == "radiolist":
for k, v in model._meta.fields_map[name].enum_type.choices().items():
options.append({"text": v, "value": k})
label = data_field.get("name").title()
field = Field(
label=label,
required=not data_field.get("nullable"),
type=type_,
options=options,
sortable=name in sort_fields,
disabled=readonly,
description=data_field.get("description"),
)
if field_type == "DecimalField" or field_type == "FloatField":
field.step = "any"
field = field.copy(update=menu.attrs.get(name) or {})
fields[name] = field
if name in search_fields:
search_fields_ret[name] = field.copy(update=dict(required=False))
for fk_field in fk_fields:
name = fk_field.get("name")
if not self._exclude_field(resource, name):
if name not in menu.raw_id_fields:
fk_model_class = fk_field.get("python_type")
objs = await fk_model_class.all()
raw_field = fk_field.get("raw_field")
label = name.title()
options = list(map(lambda x: {"text": str(x), "value": x.pk}, objs))
field = Field(
label=label,
required=not fk_field.get("nullable"),
type="select",
options=options,
sortable=name in sort_fields,
description=fk_field.get("description"),
)
field = field.copy(update=menu.attrs.get(name) or {})
fields[raw_field] = field
if name in search_fields:
search_fields_ret[raw_field] = field.copy(update=dict(required=False))
if not exclude_m2m_field:
for m2m_field in m2m_fields:
name = m2m_field.get("name")
if not self._exclude_field(resource, name):
label = name.title()
m2m_model_class = m2m_field.get("python_type")
objs = await m2m_model_class.all()
options = list(map(lambda x: {"text": str(x), "value": x.pk}, objs))
fields[name] = Field(
label=label,
type="tree",
options=options,
multiple=True,
description=m2m_field.get("description"),
**menu.attrs.get(name) or {},
)
return pk, fields, search_fields_ret
async def get_resource(
self, resource: str, exclude_pk=False, exclude_m2m_field=True, exclude_actions=False
) -> Resource:
if not self._inited:
raise Exception("must call init() first!")
model = self.models.get(resource) # type:Type[Model]
model_describe = model.describe(serializable=False)
pk, fields, search_fields = await self._build_resource_from_model_describe(
resource, model, model_describe, exclude_pk, exclude_m2m_field, exclude_actions
)
menu = self.model_menu_mapping[resource]
return Resource(
title=model_describe.get("description") or resource.title(),
fields=fields,
searchFields=search_fields,
pk=pk,
bulk_actions=menu.bulk_actions,
export=menu.export,
import_=menu.import_,
)
app = AdminApp(
debug=False,
title="FastAPI-Admin",
root_path="/admin",
description="FastAPI Admin Dashboard based on FastAPI and Tortoise ORM.",
)
app.add_exception_handler(HTTPException, exception_handler)

View File

@ -1,36 +0,0 @@
from typing import Any
from tortoise.queryset import QuerySet
from fastapi_admin.site import Field
_search_filters = {}
class Filter:
@classmethod
def get_queryset(cls, queryset: QuerySet) -> QuerySet:
raise NotImplementedError
class SearchFilter:
@classmethod
def get_queryset(cls, queryset: QuerySet, option: Any) -> QuerySet:
raise NotImplementedError
@classmethod
async def get_field(cls) -> Field:
raise NotImplementedError
@classmethod
def get_name(cls) -> str:
raise NotImplementedError
def register_filter(cls: SearchFilter):
_search_filters[cls.get_name()] = cls
return cls
def get_filter_by_name(name: str):
return _search_filters.get(name)

View File

@ -0,0 +1,126 @@
# English (United States) translations for PROJECT.
# Copyright (C) 2021 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2021.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-04-23 22:20+0800\n"
"PO-Revision-Date: 2021-04-16 22:46+0800\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en_US\n"
"Language-Team: en_US <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.0\n"
#: fastadmin/providers/login.py:98
msgid "no_such_user"
msgstr ""
#: fastadmin/providers/login.py:102
#, fuzzy
msgid "password_error"
msgstr "Password"
#: fastadmin/templates/create.html:14 fastadmin/templates/edit.html:14
msgid "save"
msgstr "Save"
#: fastadmin/templates/create.html:16
msgid "save_and_add_another"
msgstr "Save and add another"
#: fastadmin/templates/create.html:18 fastadmin/templates/edit.html:18
msgid "return"
msgstr "Return"
#: fastadmin/templates/edit.html:16
msgid "save_and_return"
msgstr "Save and return"
#: fastadmin/templates/layout.html:47
msgid "language_switch"
msgstr "Language"
#: fastadmin/templates/layout.html:61
msgid "logout"
msgstr "Logout"
#: fastadmin/templates/list.html:9
msgid "create"
msgstr "Create"
#: fastadmin/templates/list.html:16
msgid "show"
msgstr "Show"
#: fastadmin/templates/list.html:22
msgid "entries"
msgstr ""
#: fastadmin/templates/list.html:31
msgid "submit"
msgstr "Submit"
#: fastadmin/templates/list.html:63
msgid "actions"
msgstr "Actions"
#: fastadmin/templates/list.html:69
msgid "edit"
msgstr "Edit"
#: fastadmin/templates/list.html:76
msgid "delete"
msgstr "Delete"
#: fastadmin/templates/list.html:89
#, python-format
msgid "Showing %(from)s to %(to)s of %(total)s entries"
msgstr ""
#: fastadmin/templates/list.html:97
msgid "prev_page"
msgstr "Prev"
#: fastadmin/templates/list.html:111
msgid "next_page"
msgstr "Next"
#: fastadmin/templates/login.html:24
msgid "login_title"
msgstr "Login to your account"
#: fastadmin/templates/login.html:26
msgid "username"
msgstr "Username"
#: fastadmin/templates/login.html:27
msgid "login_username_placeholder"
msgstr "Enter username"
#: fastadmin/templates/login.html:31
msgid "password"
msgstr "Password"
#: fastadmin/templates/login.html:36
msgid "login_password_placeholder"
msgstr "Enter password"
#: fastadmin/templates/login.html:44
msgid "remember_me"
msgstr "Remember me"
#: fastadmin/templates/login.html:48
msgid "sign_in"
msgstr "Sign in"
#: fastadmin/templates/errors/404.html:21
#: fastadmin/templates/errors/500.html:21
msgid "return_home"
msgstr "Take me home"

View File

@ -0,0 +1,126 @@
# Chinese (Simplified, China) translations for PROJECT.
# Copyright (C) 2021 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2021.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-04-23 22:20+0800\n"
"PO-Revision-Date: 2021-04-16 22:46+0800\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: zh_Hans_CN\n"
"Language-Team: zh_Hans_CN <LL@li.org>\n"
"Plural-Forms: nplurals=1; plural=0\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.0\n"
#: fastadmin/providers/login.py:98
msgid "no_such_user"
msgstr "未找到该用户"
#: fastadmin/providers/login.py:102
#, fuzzy
msgid "password_error"
msgstr "密码错误"
#: fastadmin/templates/create.html:14 fastadmin/templates/edit.html:14
msgid "save"
msgstr "保存"
#: fastadmin/templates/create.html:16
msgid "save_and_add_another"
msgstr "保存并新增"
#: fastadmin/templates/create.html:18 fastadmin/templates/edit.html:18
msgid "return"
msgstr "返回"
#: fastadmin/templates/edit.html:16
msgid "save_and_return"
msgstr "保存并返回"
#: fastadmin/templates/layout.html:47
msgid "language_switch"
msgstr "语言"
#: fastadmin/templates/layout.html:61
msgid "logout"
msgstr "退出登录"
#: fastadmin/templates/list.html:9
msgid "create"
msgstr "创建"
#: fastadmin/templates/list.html:16
msgid "show"
msgstr "显示"
#: fastadmin/templates/list.html:22
msgid "entries"
msgstr "项"
#: fastadmin/templates/list.html:31
msgid "submit"
msgstr "提交"
#: fastadmin/templates/list.html:63
msgid "actions"
msgstr "动作"
#: fastadmin/templates/list.html:69
msgid "edit"
msgstr "编辑"
#: fastadmin/templates/list.html:76
msgid "delete"
msgstr "删除"
#: fastadmin/templates/list.html:89
#, python-format
msgid "Showing %(from)s to %(to)s of %(total)s entries"
msgstr "显示 %(from)s 到 %(to)s 共 %(total)s 项"
#: fastadmin/templates/list.html:97
msgid "prev_page"
msgstr "上一页"
#: fastadmin/templates/list.html:111
msgid "next_page"
msgstr "下一页"
#: fastadmin/templates/login.html:24
msgid "login_title"
msgstr "登录管理台"
#: fastadmin/templates/login.html:26
msgid "username"
msgstr "用户名"
#: fastadmin/templates/login.html:27
msgid "login_username_placeholder"
msgstr "请输入用户名"
#: fastadmin/templates/login.html:31
msgid "password"
msgstr "密码"
#: fastadmin/templates/login.html:36
msgid "login_password_placeholder"
msgstr "请输入密码"
#: fastadmin/templates/login.html:44
msgid "remember_me"
msgstr "记住我"
#: fastadmin/templates/login.html:48
msgid "sign_in"
msgstr "登录"
#: fastadmin/templates/errors/404.html:21
#: fastadmin/templates/errors/500.html:21
msgid "return_home"
msgstr "返回首页"

View File

@ -0,0 +1,22 @@
from typing import Callable
from starlette.requests import Request
from fastapi_admin import template
async def language_processor(request: Request, call_next: Callable):
locale = request.query_params.get("language")
if not locale:
locale = request.cookies.get("language")
if not locale:
accept_language = request.headers.get("Accept-Language")
if accept_language:
locale = accept_language.split(",")[0].replace("-", "_")
else:
locale = None
template.set_locale(locale)
response = await call_next(request)
if locale:
response.set_cookie(key="language", value=locale)
return response

View File

@ -1,53 +0,0 @@
from tortoise import Model, fields
from fastapi_admin import enums
class AbstractUser(Model):
username = fields.CharField(max_length=20, unique=True)
password = fields.CharField(
max_length=200, description="Will auto hash with raw password when change"
)
is_active = fields.BooleanField(default=True,)
is_superuser = fields.BooleanField(default=False)
class Meta:
abstract = True
class AbstractPermission(Model):
label = fields.CharField(max_length=50)
model = fields.CharField(max_length=50)
action: enums.PermissionAction = fields.IntEnumField(
enums.PermissionAction, default=enums.PermissionAction.read
)
def __str__(self):
return self.label
class Meta:
abstract = True
class AbstractRole(Model):
label = fields.CharField(max_length=50)
users = fields.ManyToManyField("models.User")
permissions = fields.ManyToManyField("models.Permission")
def __str__(self):
return self.label
class Meta:
abstract = True
class AbstractAdminLog(Model):
admin_log_id = fields.IntField(pk=True)
admin = fields.ForeignKeyField("models.User")
action = fields.CharField(max_length=20)
model = fields.CharField(max_length=50)
content = fields.JSONField()
class Meta:
abstract = True

View File

View File

@ -0,0 +1,42 @@
import os
from typing import List, Optional
import aiofiles
from starlette.datastructures import UploadFile
from fastapi_admin.exceptions import FileExtNotAllowed, FileMaxSizeLimit
class FileUploadProvider:
def __init__(
self,
uploads_dir: str,
all_extensions: Optional[List[str]] = None,
prefix: str = "/static/uploads",
max_size: int = 1024 ** 3,
):
self.max_size = max_size
self.all_extensions = all_extensions
self.uploads_dir = uploads_dir
self.prefix = prefix
def get_file_name(self, file: UploadFile):
return file.filename
async def upload(self, file: UploadFile):
filename = self.get_file_name(file)
if not filename:
return
content = await file.read()
file_size = len(content)
if file_size > self.max_size:
raise FileMaxSizeLimit(f"File size {file_size} exceeds max size {self.max_size}")
if self.all_extensions:
for ext in self.all_extensions:
if filename.endswith(ext):
raise FileExtNotAllowed(
f"File ext {ext} is not allowed of {self.all_extensions}"
)
async with aiofiles.open(os.path.join(self.uploads_dir, filename), "wb") as f:
await f.write(content)
return os.path.join(self.prefix, filename)

View File

@ -0,0 +1,103 @@
from gettext import gettext as _
from typing import Callable, Type
import bcrypt
from pydantic import EmailStr
from starlette.requests import Request
from starlette.responses import RedirectResponse
from starlette.status import HTTP_303_SEE_OTHER
from tortoise import Model, fields
from fastapi_admin.template import templates
class LoginProvider:
login_path = "/login"
logout_path = "/logout"
template = "login.html"
@classmethod
async def get(
cls,
request: Request,
):
return templates.TemplateResponse(cls.template, context={"request": request})
@classmethod
async def post(
cls,
request: Request,
):
"""
Post login
:param request:
:return:
"""
@classmethod
async def authenticate(
cls,
request: Request,
call_next: Callable,
):
response = await call_next(request)
return response
@classmethod
async def logout(cls, request: Request):
return RedirectResponse(
url=request.app.admin_path + cls.login_path, status_code=HTTP_303_SEE_OTHER
)
class UserMixin(Model):
username = fields.CharField(max_length=50, unique=True)
email = fields.CharField(max_length=50, unique=True)
password = fields.CharField(max_length=200)
class Meta:
abstract = True
class UsernamePasswordProvider(LoginProvider):
model: Type[UserMixin]
@classmethod
async def post(
cls,
request: Request,
):
form = await request.form()
username = form.get("username")
password = form.get("password")
user = await cls.model.get_or_none(username=username)
if not user:
return templates.TemplateResponse(
cls.template, context={"request": request, "error": _("no_such_user")}
)
if not cls.check_password(user, password):
return templates.TemplateResponse(
cls.template, context={"request": request, "error": _("password_error")}
)
return RedirectResponse(url=request.app.admin_path, status_code=HTTP_303_SEE_OTHER)
@classmethod
def check_password(cls, user: UserMixin, password: str):
return bcrypt.checkpw(password.encode(), user.password.encode())
@classmethod
def hash_password(cls, password: str):
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
@classmethod
async def create_user(cls, username: str, password: str, email: EmailStr):
return await cls.model.create(
username=username,
password=cls.hash_password(password),
email=email,
)
@classmethod
async def update_password(cls, user: UserMixin, password: str):
user.password = cls.hash_password(password)
await user.save(update_fields=["password"])

196
fastapi_admin/resources.py Normal file
View File

@ -0,0 +1,196 @@
from typing import List, Optional, Type, Union
from tortoise import ForeignKeyFieldInstance
from tortoise import Model as TortoiseModel
from tortoise.fields import BooleanField, DateField, DatetimeField, JSONField
from tortoise.fields.data import CharEnumFieldInstance, IntEnumFieldInstance, IntField, TextField
from fastapi_admin.exceptions import NoSuchFieldFound
from fastapi_admin.widgets import Widget, displays, inputs
from fastapi_admin.widgets.filters import Filter
class Resource:
"""
Base Resource
"""
label: str
icon: str = ""
class Link(Resource):
url: str
target: str = "_self"
page_pre_title: Optional[str] = None
page_title: Optional[str] = None
class Field:
name: str
label: str
display: displays.Display
input: inputs.Input
def __init__(
self,
name: str,
label: str,
display: Optional[displays.Display] = None,
input_: Optional[Widget] = None,
):
self.name = name
self.label = label
if not display:
display = displays.Display()
display.context.update(label=label)
self.display = display
if not input_:
input_ = inputs.Input()
input_.context.update(label=label, name=name)
self.input = input_
class Model(Resource):
model: Type[TortoiseModel]
fields: List[Union[str, Field]] = []
page_size: int = 10
page_pre_title: Optional[str] = None
page_title: Optional[str] = None
filters: Optional[List[Union[str, Filter]]] = []
can_edit: bool = True
can_delete: bool = True
can_create: bool = True
enctype = "application/x-www-form-urlencoded"
@classmethod
async def get_inputs(cls, obj: Optional[TortoiseModel] = None):
ret = []
for field in cls.get_fields(is_display=False):
input_ = field.input
if isinstance(input_, inputs.DisplayOnly):
continue
if isinstance(input_, inputs.File):
cls.enctype = "multipart/form-data"
name = input_.context.get("name")
ret.append(await input_.render(getattr(obj, name, None)))
return ret
@classmethod
async def resolve_query_params(cls, values: dict):
ret = {}
for f in cls.filters:
name = f.context.get("name")
v = values.get(name)
if v is not None and v != "":
ret[name] = await f.parse_value(v)
return ret
@classmethod
async def resolve_data(cls, data: dict):
ret = {}
for field in cls.get_fields(is_display=False):
input_ = field.input
if input_.context.get("disabled") or isinstance(input_, inputs.DisplayOnly):
continue
name = input_.context.get("name")
v = data.get(name)
ret[name] = await input_.parse_value(v)
return ret
@classmethod
async def get_filters(cls, values: Optional[dict] = None):
if not values:
values = {}
ret = []
for f in cls.filters:
name = f.context.get("name")
value = values.get(name)
ret.append(await f.render(value))
return ret
@classmethod
def _get_fields_attr(cls, attr: str, display: bool = True):
ret = []
for field in cls.get_fields():
if display and isinstance(field.display, displays.InputOnly):
continue
ret.append(getattr(field, attr))
return ret or cls.model._meta.db_fields
@classmethod
def get_fields_name(cls, display: bool = True):
return cls._get_fields_attr("name", display)
@classmethod
def _get_display_input_field(cls, field_name: str) -> Field:
fields_map = cls.model._meta.fields_map
field = fields_map.get(field_name)
if not field:
raise NoSuchFieldFound(f"Can't found field '{field}' in model {cls.model}")
label = field_name
null = field.null
placeholder = field.description or ""
display, input_ = displays.Display(), inputs.Input(
placeholder=placeholder, null=null, default=field.default
)
if field.pk or field.generated:
display, input_ = displays.Display(), inputs.DisplayOnly()
elif isinstance(field, BooleanField):
display, input_ = displays.Boolean(), inputs.Switch(null=null, default=field.default)
elif isinstance(field, DatetimeField):
if field.auto_now or field.auto_now_add:
input_ = inputs.DisplayOnly()
else:
input_ = inputs.DateTime(null=null, default=field.default)
display, input_ = displays.DatetimeDisplay(), input_
elif isinstance(field, DateField):
display, input_ = displays.DateDisplay(), inputs.Date(null=null, default=field.default)
elif isinstance(field, IntEnumFieldInstance):
display, input_ = displays.Display(), inputs.Enum(
field.enum_type, null=null, default=field.default
)
elif isinstance(field, CharEnumFieldInstance):
display, input_ = displays.Display(), inputs.Enum(
field.enum_type, enum_type=str, null=null, default=field.default
)
elif isinstance(field, JSONField):
display, input_ = displays.Json(), inputs.Json(null=null)
elif isinstance(field, TextField):
display, input_ = displays.Display(), inputs.TextArea(
placeholder=placeholder, null=null, default=field.default
)
elif isinstance(field, IntField):
display, input_ = displays.Display(), inputs.Number(
placeholder=placeholder, null=null, default=field.default
)
elif isinstance(field, ForeignKeyFieldInstance):
display, input_ = displays.Display(), inputs.ForeignKey(
field.related_model, null=null, default=field.default
)
field_name = field.source_field
return Field(name=field_name, label=label.title(), display=display, input_=input_)
@classmethod
def get_fields(cls, is_display: bool = True):
ret = []
for field in cls.fields or cls.model._meta.db_fields:
if isinstance(field, str):
field = cls._get_display_input_field(field)
ret.append(field)
else:
if (is_display and isinstance(field.display, displays.InputOnly)) or (
not is_display and isinstance(field.input, inputs.DisplayOnly)
):
continue
ret.append(field)
return ret
@classmethod
def get_fields_label(cls, display: bool = True):
return cls._get_fields_attr("label", display)
class Dropdown(Resource):
resources: List[Type[Resource]]

View File

@ -1,8 +1,10 @@
from typing import Dict, Sequence
from pydantic import BaseModel
from starlette.requests import Request
from starlette.responses import RedirectResponse
from starlette.status import HTTP_303_SEE_OTHER
class GetManyOut(BaseModel):
total: int
data: Sequence[Dict]
def redirect(request: Request, view: str, **params):
return RedirectResponse(
url=request.app.admin_path + request.app.url_path_for(view, **params),
status_code=HTTP_303_SEE_OTHER,
)

182
fastapi_admin/routes.py Normal file
View File

@ -0,0 +1,182 @@
from typing import Optional
from fastapi import APIRouter, Depends, Path
from starlette.requests import Request
from starlette.responses import RedirectResponse
from tortoise import Model
from fastapi_admin.depends import get_model, get_model_resource, get_resources
from fastapi_admin.resources import Model as ModelResource
from fastapi_admin.responses import redirect
from fastapi_admin.template import render_values, templates
router = APIRouter()
@router.get("/maintenance")
async def maintenance(request: Request):
return templates.TemplateResponse("errors/maintenance.html", context={"request": request})
@router.get("/list/{resource}")
async def list_view(
request: Request,
model: Model = Depends(get_model),
resources=Depends(get_resources),
model_resource: ModelResource = Depends(get_model_resource),
resource: str = Path(...),
page_size: Optional[int] = None,
page_num: int = 1,
):
fields_name = model_resource.get_fields_name()
fields_label = model_resource.get_fields_label()
fields = model_resource.get_fields()
params = await model_resource.resolve_query_params(dict(request.query_params))
filters = await model_resource.get_filters(params)
qs = model.filter(**params)
total = await qs.count()
if page_size:
qs = qs.limit(page_size)
else:
page_size = model_resource.page_size
qs = qs.offset((page_num - 1) * page_size)
values = await qs.values_list(*fields_name)
values = await render_values(fields, values)
return templates.TemplateResponse(
"list.html",
context={
"request": request,
"resources": resources,
"fields_label": fields_label,
"fields": fields,
"values": values,
"filters": filters,
"resource": resource,
"model_resource": model_resource,
"resource_label": model_resource.label,
"page_size": page_size,
"page_num": page_num,
"total": total,
"from": page_size * (page_num - 1) + 1,
"to": page_size * page_num,
"page_title": model_resource.page_title,
"page_pre_title": model_resource.page_pre_title,
},
)
@router.post("/edit/{resource}/{pk}")
async def edit(
request: Request,
resource: str = Path(...),
pk: int = Path(...),
model_resource: ModelResource = Depends(get_model_resource),
resources=Depends(get_resources),
model=Depends(get_model),
):
form = await request.form()
data = await model_resource.resolve_data(dict(form))
obj = await model.get(pk=pk)
await obj.update_from_dict(data).save()
inputs = await model_resource.get_inputs(obj)
if "save" in form.keys():
return templates.TemplateResponse(
"edit.html",
context={
"request": request,
"resources": resources,
"resource_label": model_resource.label,
"resource": resource,
"model_resource": model_resource,
"inputs": inputs,
"pk": pk,
"page_title": model_resource.page_title,
"page_pre_title": model_resource.page_pre_title,
},
)
return redirect(request, "list_view", resource=resource)
@router.get("/edit/{resource}/{pk}")
async def edit_view(
request: Request,
resource: str = Path(...),
pk: int = Path(...),
model_resource: ModelResource = Depends(get_model_resource),
resources=Depends(get_resources),
model=Depends(get_model),
):
obj = await model.get(pk=pk)
inputs = await model_resource.get_inputs(obj)
return templates.TemplateResponse(
"edit.html",
context={
"request": request,
"resources": resources,
"resource_label": model_resource.label,
"resource": resource,
"inputs": inputs,
"pk": pk,
"model_resource": model_resource,
"page_title": model_resource.page_title,
"page_pre_title": model_resource.page_pre_title,
},
)
@router.get("/create/{resource}")
async def create_view(
request: Request,
resource: str = Path(...),
resources=Depends(get_resources),
model_resource: ModelResource = Depends(get_model_resource),
):
inputs = await model_resource.get_inputs()
return templates.TemplateResponse(
"create.html",
context={
"request": request,
"resources": resources,
"resource_label": model_resource.label,
"resource": resource,
"inputs": inputs,
"model_resource": model_resource,
"page_title": model_resource.page_title,
"page_pre_title": model_resource.page_pre_title,
},
)
@router.post("/create/{resource}")
async def create(
request: Request,
resource: str = Path(...),
resources=Depends(get_resources),
model_resource: ModelResource = Depends(get_model_resource),
model=Depends(get_model),
):
inputs = await model_resource.get_inputs()
form = await request.form()
data = await model_resource.resolve_data(dict(form))
await model.create(**data)
if "save" in form.keys():
return redirect(request, "list_view", resource=resource)
return templates.TemplateResponse(
"create.html",
context={
"request": request,
"resources": resources,
"resource_label": model_resource.label,
"resource": resource,
"inputs": inputs,
"model_resource": model_resource,
"page_title": model_resource.page_title,
"page_pre_title": model_resource.page_pre_title,
},
)
@router.get("/delete/{resource}/{pk}")
async def delete_view(request: Request, pk: int, model: Model = Depends(get_model)):
await model.filter(pk=pk).delete()
return RedirectResponse(url=request.headers.get("referer"))

View File

@ -1,9 +0,0 @@
from fastapi import Depends
from ..depends import jwt_required
from ..factory import app
from . import other, rest, site
app.include_router(site.router)
app.include_router(other.router, dependencies=[Depends(jwt_required)])
app.include_router(rest.router, dependencies=[Depends(jwt_required)], prefix="/rest")

View File

@ -1,19 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from starlette.status import HTTP_403_FORBIDDEN, HTTP_409_CONFLICT
from fastapi_admin.common import pwd_context
from fastapi_admin.depends import get_current_user
from fastapi_admin.schemas import UpdatePasswordIn
router = APIRouter()
@router.put("/password")
async def update_password(update_password_in: UpdatePasswordIn, user=Depends(get_current_user)):
if update_password_in.new_password != update_password_in.confirm_password:
raise HTTPException(HTTP_409_CONFLICT, detail="Incorrect Confirm Password!")
if not pwd_context.verify(update_password_in.old_password, user.password):
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Incorrect Password!")
user.password = pwd_context.hash(update_password_in.new_password)
await user.save(update_fields=["password"])
return {"success": True}

View File

@ -1,204 +0,0 @@
import io
from typing import Type
import xlsxwriter
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from starlette.requests import Request
from starlette.responses import StreamingResponse
from starlette.status import HTTP_409_CONFLICT
from tortoise import Model
from tortoise.contrib.pydantic import pydantic_model_creator
from tortoise.exceptions import IntegrityError
from tortoise.fields import ManyToManyRelation
from .. import enums
from ..common import handle_m2m_fields_create_or_update
from ..depends import (
QueryItem,
admin_log_create,
admin_log_delete,
admin_log_update,
create_checker,
delete_checker,
get_current_user,
get_model,
get_query,
has_resource_permission,
parse_body,
read_checker,
update_checker,
)
from ..factory import app
from ..filters import get_filter_by_name
from ..responses import GetManyOut
from ..schemas import BulkIn
from ..shortcuts import get_object_or_404
router = APIRouter()
@router.get("/{resource}/export")
async def export(resource: str, query: QueryItem = Depends(get_query), model=Depends(get_model)):
qs = model.all()
if query.where:
qs = qs.filter(**query.where)
resource = await app.get_resource(resource)
result = await qs
creator = pydantic_model_creator(
model, include=resource.resource_fields.keys(), exclude=model._meta.m2m_fields
)
data = map(lambda x: creator.from_orm(x).dict(), result)
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output)
worksheet = workbook.add_worksheet()
for row, item in enumerate(data):
col = 0
for k, v in item.items():
if row == 0:
worksheet.write(row, col, k)
worksheet.write(row + 1, col, v)
col += 1
workbook.close()
output.seek(0)
return StreamingResponse(output)
@router.post("/{resource}/import")
async def import_data(request: Request, model: Type[Model] = Depends(get_model)):
items = await request.json()
objs = []
for item in items:
obj = model(**item)
objs.append(obj)
try:
await model.bulk_create(objs)
return {"success": True, "data": len(objs)}
except IntegrityError as e:
return JSONResponse(status_code=HTTP_409_CONFLICT, content=dict(msg=f"Import Error,{e}"))
@router.get("/{resource}", dependencies=[Depends(read_checker)])
async def get_resource(
resource: str, query: QueryItem = Depends(get_query), model=Depends(get_model)
):
menu = app.model_menu_mapping[resource]
qs = model.all()
for filter_ in menu.custom_filters:
qs = filter_.get_queryset(qs)
if query.where:
for name, value in query.where.items():
filter_cls = get_filter_by_name(name)
if filter_cls:
qs = filter_cls.get_queryset(qs, value)
else:
qs = qs.filter(**{name: value})
sort = query.sort
for k, v in sort.items():
if k in menu.sort_fields:
if v == -1:
qs = qs.order_by(f"-{k}")
elif v == 1:
qs = qs.order_by(k)
resource = await app.get_resource(resource)
result = await qs.limit(query.size).offset((query.page - 1) * query.size)
creator = pydantic_model_creator(
model, include=resource.resource_fields.keys(), exclude=model._meta.m2m_fields
)
data = []
for item in result:
item_dict = creator.from_orm(item).dict()
item_dict["_rowVariant"] = item_dict.pop("rowVariant", None)
item_dict["_cellVariants"] = item_dict.pop("cellVariants", None)
data.append(item_dict)
return GetManyOut(total=await qs.count(), data=data)
@router.get("/{resource}/form", dependencies=[Depends(read_checker)])
async def form(resource: str,):
resource = await app.get_resource(
resource, exclude_pk=True, exclude_m2m_field=False, exclude_actions=True
)
return resource.dict(by_alias=True, exclude_unset=True)
@router.get("/{resource}/grid", dependencies=[Depends(read_checker)])
async def grid(resource: str, user=Depends(get_current_user)):
fetched_resource = await app.get_resource(resource)
resource_response = fetched_resource.dict(by_alias=True, exclude_unset=True)
resource_response["fields"]["_actions"] = {
"delete": await has_resource_permission(enums.PermissionAction.delete, resource, user),
"edit": await has_resource_permission(enums.PermissionAction.update, resource, user),
"toolbar": {
"create": await has_resource_permission(enums.PermissionAction.create, resource, user)
},
}
return resource_response
@router.get("/{resource}/view", dependencies=[Depends(read_checker)])
async def view(resource: str,):
resource = await app.get_resource(resource)
return resource.dict(by_alias=True, exclude_unset=True)
@router.post(
"/{resource}/bulk/delete", dependencies=[Depends(delete_checker), Depends(admin_log_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}", dependencies=[Depends(delete_checker), Depends(admin_log_delete)]
)
async def delete_one(id: int, model=Depends(get_model)):
await model.filter(pk=id).delete()
return {"success": True}
@router.put("/{resource}/{id}", dependencies=[Depends(update_checker), Depends(admin_log_update)])
async def update_one(id: int, parsed=Depends(parse_body), model=Depends(get_model)):
body, resource_fields = parsed
m2m_fields = model._meta.m2m_fields
try:
obj = await handle_m2m_fields_create_or_update(
body, m2m_fields, model, app.user_model, False, id
)
except IntegrityError as e:
return JSONResponse(status_code=HTTP_409_CONFLICT, content=dict(msg=f"Update Error,{e}"))
creator = pydantic_model_creator(model, include=resource_fields, exclude=m2m_fields)
return creator.from_orm(obj).dict()
@router.post("/{resource}", dependencies=[Depends(create_checker), Depends(admin_log_create)])
async def create_one(parsed=Depends(parse_body), model=Depends(get_model)):
body, resource_fields = parsed
m2m_fields = model._meta.m2m_fields
creator = pydantic_model_creator(model, include=resource_fields, exclude=m2m_fields)
try:
obj = await handle_m2m_fields_create_or_update(body, m2m_fields, model, app.user_model)
except IntegrityError as e:
return JSONResponse(status_code=HTTP_409_CONFLICT, content=dict(msg=f"Create Error,{e}"))
return creator.from_orm(obj).dict()
@router.get("/{resource}/{id}", dependencies=[Depends(read_checker)])
async def get_one(id: int, resource: str, model=Depends(get_model)):
obj = await get_object_or_404(model, pk=id) # type:Model
m2m_fields = model._meta.m2m_fields
resource = await app.get_resource(resource, exclude_m2m_field=False)
include = resource.resource_fields.keys()
creator = pydantic_model_creator(model, include=include, exclude=m2m_fields)
ret = creator.from_orm(obj).dict()
for m2m_field in m2m_fields:
if m2m_field in include:
relate_model = getattr(obj, m2m_field) # type:ManyToManyRelation
ids = await relate_model.all().values_list(relate_model.remote_model._meta.pk_attr)
ret[m2m_field] = list(map(lambda x: x[0], ids))
ret["__str__"] = str(obj)
return ret

View File

@ -1,34 +0,0 @@
from copy import deepcopy
from fastapi import APIRouter, Depends
from ..common import check_has_permission
from ..depends import jwt_optional
from ..factory import app
from ..shortcuts import get_object_or_404
router = APIRouter()
@router.get("/site",)
async def site(user_id=Depends(jwt_optional)):
site_ = app.site
user = None
if user_id:
user = await get_object_or_404(app.user_model, pk=user_id)
site_copy = deepcopy(site_)
if user and app.permission and not user.is_superuser:
await user.fetch_related("roles")
for index, menu in enumerate(site_.menus):
if menu.url and ("/rest" in menu.url or "/page" in menu.url):
model = menu.url.split("/")[-1]
if not await check_has_permission(user, model):
site_copy.menus.remove(menu)
elif menu.children:
for index_child, child in enumerate(menu.children):
if child.url and ("/rest" in child.url or "/page" in child.url):
model = child.url.split("/")[-1]
if not await check_has_permission(user, model):
site_copy.menus[index].children.remove(child)
site_copy.menus = list(filter(lambda x: x.children != [] or x.url, site_copy.menus))
return site_copy.dict(by_alias=True, exclude_unset=True)

View File

@ -1,19 +0,0 @@
from typing import List
from fastapi import Body
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])
class UpdatePasswordIn(BaseModel):
old_password: str
new_password: str
confirm_password: str

View File

@ -1,18 +0,0 @@
from typing import Generic
from fastapi import HTTPException
from starlette.status import HTTP_404_NOT_FOUND
from tortoise.models import MODEL
async def get_object_or_404(model: Generic[MODEL], **kwargs):
"""
get_object_or_404
:param model:
:param kwargs:
:return:
"""
obj = await model.filter(**kwargs).first() # type:model
if not obj:
raise HTTPException(HTTP_404_NOT_FOUND, "Not Found")
return obj

View File

@ -1,90 +0,0 @@
from typing import Dict, List, Optional, Tuple, Union
from pydantic import BaseModel, HttpUrl
class Menu(BaseModel):
name: str
# must be format with /rest/<Model> if it's a model resource.
url: Optional[str]
icon: Optional[str]
# children menu
children: Optional[List["Menu"]] = []
# include fields
include: Optional[Tuple[str, ...]] = tuple()
# exclude fields
exclude: Optional[Tuple[str, ...]] = tuple()
# external link
external: Optional[bool] = False
# raw id fields
raw_id_fields: Optional[Tuple[str, ...]] = tuple()
# searchable fields
search_fields = tuple()
# sortable fields
sort_fields: Optional[Tuple[str, ...]] = tuple()
# define field type,like select,radiolist,text,date
fields_type: Dict = {}
# define field attr,like cols which in bootstrap table
attrs: Dict[str, Dict] = {"created_at": {"label": "CreatedAt"}}
# active table export
export: bool = True
import_: bool = False
actions: Optional[Dict]
bulk_actions: List[Dict] = [{"value": "delete", "text": "delete_all"}]
custom_filters: List = []
Menu.update_forward_refs()
class Site(BaseModel):
name: str
logo: Optional[HttpUrl]
login_logo: Optional[HttpUrl]
login_footer: Optional[str]
login_description: Optional[str]
locale: str
locale_switcher: bool = False
theme_switcher: bool = False
theme: Optional[str]
url: Optional[HttpUrl]
# custom css
css: Optional[List[HttpUrl]]
# menu define
menus: Optional[List[Menu]]
# custom footer with html
footer: Optional[str]
# custom header - require html beginning with a <div> due to being rendered in a <custom-component>
header: Optional[str]
page_header: Optional[str]
class Field(BaseModel):
label: str
cols: Optional[int]
input_cols: Optional[int]
group: Optional[str]
type: str
required: bool = True
options: Optional[List[Dict]]
sortable: Optional[bool]
multiple: bool = False
ref: Optional[str]
description: Optional[str]
disabled: Optional[bool] = False
step: str = "any"
class Resource(BaseModel):
title: str
pk: str
resource_fields: Dict[str, Union[Field, Dict]]
searchFields: Optional[Dict[str, Field]]
bulk_actions: Optional[List[Dict]]
export: bool
import_: bool
class Config:
fields = {
"resource_fields": "fields",
}

75
fastapi_admin/template.py Normal file
View File

@ -0,0 +1,75 @@
import os
import typing
from datetime import date
from typing import Any, List, Tuple
from urllib.parse import urlencode
from babel.support import Translations
from jinja2 import contextfilter
from starlette.requests import Request
from starlette.templating import Jinja2Templates
from fastapi_admin import VERSION
from fastapi_admin.constants import BASE_DIR
if typing.TYPE_CHECKING:
from fastapi_admin.resources import Field
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
templates.env.globals["VERSION"] = VERSION
templates.env.globals["NOW_YEAR"] = date.today().year
templates.env.add_extension("jinja2.ext.i18n")
templates.env.add_extension("jinja2.ext.autoescape")
templates.env.add_extension("jinja2.ext.with_")
templates.env.add_extension("jinja2.ext.do")
TRANSLATIONS = {
"zh_CN": Translations.load(os.path.join(BASE_DIR, "locales"), locales=["zh_CN"]),
"en_US": Translations.load(os.path.join(BASE_DIR, "locales"), locales=["en_US"]),
}
@contextfilter
def current_page_with_params(context: dict, params: dict):
request = context.get("request") # type:Request
full_path = request.scope["raw_path"].decode()
query_params = dict(request.query_params)
for k, v in params.items():
query_params[k] = v
return full_path + "?" + urlencode(query_params)
templates.env.filters["current_page_with_params"] = current_page_with_params
def set_locale(locale: str):
translations = TRANSLATIONS.get(locale) or TRANSLATIONS.get("en_US")
templates.env.install_gettext_translations(translations)
translations.install(locale)
def add_template_folder(*folders: str):
for folder in folders:
templates.env.loader.searchpath.append(folder)
async def render_values(
fields: List["Field"], values: List[Tuple[Any]], display: bool = True
) -> List[List[Any]]:
"""
render values with template render
:param fields:
:param values:
:param display:
:return:
"""
ret = []
for value in values:
item = []
for i, v in enumerate(value):
if display:
item.append(await fields[i].display.render(v))
else:
item.append(await fields[i].input.render(v))
ret.append(item)
return ret

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler.min.css">
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons@latest/iconfont/tabler-icons.min.css">
<script src="https://unpkg.com/@tabler/core@latest/dist/js/tabler.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/jquery/latest/jquery.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/momentjs/latest/moment.min.js"></script>
{% block head %}
{% endblock %}
<title>{{ title }}</title>
</head>
{% block outer_body %}
<body>
{% block body %}
{% endblock %}
{% block footer %}
{% endblock %}
</body>
{% endblock %}
</html>

View File

@ -0,0 +1,37 @@
<li class="nav-item dropdown {% if resource_label in resource.resources|map(attribute='label') %}
active
{% endif %}">
<a class="nav-link dropdown-toggle" href="#navbar-base" data-bs-toggle="dropdown"
role="button" aria-expanded="false">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<i class="ti ti-package"></i>
</span>
<span class="nav-link-title">
{{ resource.label }}
</span>
</a>
<div class="dropdown-menu {% if resource_label in resource.resources|map(attribute='label') %}
show
{% endif %}">
<div class="dropdown-menu-columns">
<div class="dropdown-menu-column">
{% for item in resource.resources %}
{% if item.type == 'link' %}
<a href="{{ item.url }}" class="dropdown-item {% if resource_label == item.label %}
active
{% endif %}" target="{{ item.target }}">{{ item.label }}</a>
{% elif item.type == 'model' %}
<a href="{{ request.app.admin_path }}/list/{{ item.model }}"
class="dropdown-item {% if resource_label == item.label %}
active
{% endif %}">{{ item.label }}</a>
{% elif item.type == 'dropdown' %}
{% with resource=item %}
{% include 'components/dropdown.html' %}
{% endwith %}
{% endif %}
{% endfor %}
</div>
</div>
</div>
</li>

View File

@ -0,0 +1,12 @@
<li class="nav-item {% if resource_label == resource.label %}
active
{% endif %} ">
<a class="nav-link" href="{{ resource.url }}" target="{{ resource.target }}">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<i class="{{ resource.icon }}"></i>
</span>
<span class="nav-link-title">
{{ resource.label }}
</span>
</a>
</li>

View File

@ -0,0 +1,12 @@
<li class="nav-item {% if resource_label == resource.label %}
active
{% endif %}">
<a class="nav-link" href="{{ request.app.admin_path }}/list/{{ resource.model }}">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<i class="{{ resource.icon }}"></i>
</span>
<span class="nav-link-title">
{{ resource.label }}
</span>
</a>
</li>

View File

@ -0,0 +1,24 @@
{% extends "layout.html" %}
{% block page_body %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">{{ resource_label }}</h3>
</div>
<div class="card-body border-bottom py-3">
<form method="post" action="{{ request.app.admin_path }}/create/{{ resource }}" enctype="{{ model_resource.enctype }}">
{% for input in inputs %}
{{ input|safe }}
{% endfor %}
<div class="form-footer">
<button type="submit" name="save" class="btn btn-primary">{{ _('save') }}</button>
<button type="submit" name="save_and_add_another"
class="btn btn-info mx-2">{{ _('save_and_add_another') }}</button>
<a type="button" class="btn btn-secondary"
href="{{ request.app.admin_path }}/list/{{ resource }}">{{ _('return') }}</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends "layout.html" %}
{% block page_body %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">{{ resource_label }}</h3>
</div>
<div class="card-body border-bottom py-3">
<form method="post" action="{{ request.app.admin_path }}/edit/{{ resource }}/{{ pk }}" enctype="{{ model_resource.enctype }}">
{% for input in inputs %}
{{ input|safe }}
{% endfor %}
<div class="form-footer">
<button type="submit" name="save" class="btn btn-primary">{{ _('save') }}</button>
<button type="submit" name="save_and_return"
class="btn btn-info mx-2">{{ _('save_and_return') }}</button>
<a type="button" class="btn btn-secondary"
href="{{ request.app.admin_path }}/list/{{ resource }}">{{ _('return') }}</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block body %}
<div class="wrapper min-vh-100">
<aside class="navbar navbar-vertical navbar-expand-lg navbar-dark">
<div class="container-fluid">
<h1 class="navbar-brand navbar-brand-autodark">
<a href="{{ request.app.admin_path }}">
<img src="{{ request.app.logo_url }}" width="110" height="32" alt="Tabler"
class="navbar-brand-image">
</a>
</h1>
<div class="collapse navbar-collapse" id="navbar-menu">
<ul class="navbar-nav pt-lg-3">
{% for resource in resources %}
{% if resource.type == 'link' %}
{% include 'components/link.html' %}
{% elif resource.type == 'model' %}
{% include 'components/model.html' %}
{% elif resource.type == 'dropdown' %}
{% include 'components/dropdown.html' %}
{% endif %}
{% endfor %}
</ul>
</div>
</div>
</aside>
<div class="page-wrapper min-vh-100">
<div class="container-fluid">
<div class="page-header d-print-none">
{% block page_header %}
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">
{{ page_pre_title or '' }}
</div>
<h2 class="page-title">
{{ page_title or '' }}
</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<div class="btn-list">
<span class="d-none d-sm-inline">
<span class="dropdown">
<button class="btn dropdown-toggle align-text-top"
data-bs-boundary="viewport"
data-bs-toggle="dropdown"
aria-expanded="false">{{ _('language_switch') }}</button>
<div class="dropdown-menu dropdown-menu-end" style="">
<a class="dropdown-item"
href="{{ {'language':'zh_CN'}|current_page_with_params }}">
<span class="me-2">🇨🇳</span>简体中文
</a>
<a class="dropdown-item"
href="{{ {'language':'en_US'}|current_page_with_params }}">
<span class="me-2">🇺🇸</span>English
</a>
</div>
</span>
</span>
<a href="{{ request.app.admin_path }}{{ request.app.login_provider.logout_path }}" class="btn btn-primary">
{{ _('logout') }}
</a>
</div>
</div>
</div>
{% endblock %}
</div>
</div>
<div class="page-body flex-grow-1">
<div class="container-fluid">
<div class="row row-deck row-cards">
{% block page_body %}
{% endblock %}
</div>
</div>
</div>
<footer class="footer footer-transparent d-print-none">
<div class="container">
<div class="row text-center align-items-center flex-row-reverse">
<div class="col-lg-auto ms-lg-auto">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item"><a href="#" class="link-secondary">Documentation</a>
</li>
<li class="list-inline-item"><a href="#" class="link-secondary">License</a>
</li>
<li class="list-inline-item"><a href="https://github.com/long2ice/fastadmin"
target="_blank"
class="link-secondary" rel="noopener">Source code</a>
</li>
<li class="list-inline-item">
<a href="https://sponsor.long2ice.cn" target="_blank"
class="link-secondary" rel="noopener">
<svg xmlns="http://www.w3.org/2000/svg"
class="icon text-pink icon-filled icon-inline" width="24" height="24"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M19.5 13.572l-7.5 7.428l-7.5 -7.428m0 0a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572"></path>
</svg>
Sponsor
</a>
</li>
</ul>
</div>
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item">
Copyright © {{ 2021 }} - {{ NOW_YEAR }}
<a href="." class="link-secondary">FastAdmin</a>.
All rights reserved.
</li>
<li class="list-inline-item">
<a href="#" class="link-secondary" rel="noopener">v{{ VERSION }}</a>
</li>
</ul>
</div>
</div>
</div>
</footer>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,120 @@
{% extends "layout.html" %}
{% block page_body %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">{{ resource_label }}</h3>
{% if model_resource.can_create %}
<a class="btn btn-dark ms-auto"
href="{{ request.app.admin_path }}/create/{{ resource }}">{{ _('create') }}</a>
{% endif %}
</div>
<div class="card-body border-bottom py-3">
<form action="{{ request.app.admin_path }}/list/{{ resource }}" method="get">
<div class="d-flex">
<div class="text-muted">
{{ _('show') }}
<div class="mx-2 d-inline-block">
<input type="text" class="form-control" value="{{ page_size }}" size="1"
name="page_size"
aria-label="entries count">
</div>
{{ _('entries') }}
</div>
<div class="d-flex ms-auto">
{% for filter in filters %}
<div class="mx-2">
{{ filter|safe }}
</div>
{% endfor %}
<div class="ms-2">
<button type="submit" class="btn btn-primary">{{ _('submit') }}</button>
</div>
</div>
</div>
</form>
</div>
<div class="table-responsive">
<table class="table card-table table-vcenter text-nowrap datatable">
<thead>
<tr>
<th class="w-1"><input class="form-check-input m-0 align-middle" type="checkbox"
aria-label="Select all entities"></th>
{% for label in fields_label %}
<th>{{ label }}</th>
{% endfor %}
<th></th>
</tr>
</thead>
<tbody>
{% for value in values %}
<tr>
<td>
<input class="form-check-input m-0 align-middle" type="checkbox"
aria-label="Select invoice">
</td>
{% for v in value %}
<td>{{ v|safe }}</td>
{% endfor %}
{% if model_resource.can_edit or model_resource.can_delete %}
<td class="text-end">
<span class="dropdown">
<button class="btn dropdown-toggle align-text-top" data-bs-boundary="viewport"
data-bs-toggle="dropdown">{{ _('actions') }}</button>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
{% if model_resource.can_edit %}
<a class="dropdown-item"
href="{{ request.app.admin_path }}/edit/{{ resource }}/{{ value[0] }}">
<i class="ti ti-edit me-2"></i>
{{ _('edit') }}
</a>
{% endif %}
{% if model_resource.can_delete %}
<a class="dropdown-item"
href="{{ request.app.admin_path }}/delete/{{ resource }}/{{ value[0] }}">
<i class="ti ti-trash me-2"></i>
{{ _('delete') }}
</a>
{% endif %}
</div>
</span>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-footer d-flex align-items-center">
<p class="m-0 text-muted">{{ _('Showing %(from)s to %(to)s of %(total)s entries')|format(from=from,to=to,total=total) }}</p>
<ul class="pagination m-0 ms-auto">
<li class="page-item {% if page_num <= 1 %}
disabled
{% endif %} ">
<a class="page-link" href="{{ {'page_num':page_num - 1}|current_page_with_params }}" tabindex="-1"
aria-disabled="true">
<i class="ti ti-chevron-left"></i>
{{ _('prev_page') }}
</a>
</li>
{% with total_page = (total/page_size)|round(0,'ceil')|int,start_page = (1 if page_num <=3 else page_num - 2 ) %}
{% for i in range(start_page,[start_page + 5,total_page + 1]|min) %}
<li class="page-item {% if i == (page_num or 1) %}
active
{% endif %} "><a class="page-link"
href="{{ {'page_num':i}|current_page_with_params }}">{{ i }}</a></li>
{% endfor %}
<li class="page-item {% if page_num >= total_page %}
disabled
{% endif %} ">
<a class="page-link" href="{{ {'page_num':page_num + 1}|current_page_with_params }}">
{{ _('next_page') }}
<i class="ti ti-chevron-right"></i>
</a>
</li>
{% endwith %}
</ul>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block outer_body %}
<body class="border-primary d-flex flex-column">
<div class="page page-center">
<div class="container-tight py-4">
<div class="text-center mb-4">
<a href="."><img src="https://preview.tabler.io/static/logo.svg" height="36" alt=""></a>
</div>
{% if error %}
<div class="alert alert-important alert-danger alert-dismissible" role="alert">
<div class="d-flex">
<div>
<i class="ti ti-alert-circle"></i>
</div>
<div>
{{ error }}
</div>
</div>
<a class="btn-close btn-close-white" data-bs-dismiss="alert" aria-label="close"></a>
</div>
{% endif %}
<form class="card card-md" action="{{ request.app.admin_path }}{{ request.app.login_provider.login_path }}"
method="post" autocomplete="off">
<div class="card-body">
<h2 class="card-title text-center mb-4">{{ _('login_title') }}</h2>
<div class="mb-3">
<label class="form-label">{{ _('username') }}</label>
<input name="username" type="text" class="form-control"
placeholder="{{ _('login_username_placeholder') }}">
</div>
<div class="mb-2">
<label class="form-label">
{{ _('password') }}
<span class="form-label-description">
</span>
</label>
<div class="input-group input-group-flat">
<input placeholder="{{ _('login_password_placeholder') }}" name="password" type="password"
class="form-control" autocomplete="off">
<span class="input-group-text">
</span>
</div>
</div>
<div class="mb-2">
<label class="form-check">
<input type="checkbox" class="form-check-input"/>
<span class="form-check-label">{{ _('remember_me') }}</span>
</label>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">{{ _('sign_in') }}</button>
</div>
</div>
</form>
</div>
</div>
</body>
{% endblock %}

View File

@ -0,0 +1,5 @@
{% if value %}
<span class="badge bg-green">true</span>
{% else %}
<span class="badge bg-red">false</span>
{% endif %}

View File

@ -0,0 +1 @@
<img src="{{ value }}" alt="" width="{{ width }}" height="{{ height }}">

View File

@ -0,0 +1,4 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.7.2/build/styles/default.min.css">
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.7.2/build/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<pre><code class="json">{{ value }}</code></pre>

View File

@ -0,0 +1,47 @@
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/daterangepicker/daterangepicker.min.js"></script>
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/daterangepicker/daterangepicker.css"/>
<div class="text-muted">
{{ label }}:
<div class="d-inline-block">
<input class="form-control" type="text" id="{{ name }}" name="{{ name }}" value="{{ value }}">
</div>
</div>
<script>
$(function () {
let value = "{{value}}";
let start, end;
if (value !== '') {
let s = value.split(' - ')
start = moment(s[0]);
end = moment(s[1]);
} else {
start = moment().subtract(7, 'days');
end = moment().subtract(-1, 'days');
}
let format = '{{ format }}';
function cb(start, end) {
$('#{{name}} span').html(start.format(format) + ' - ' + end.format(format));
}
$('#{{name}}').daterangepicker({
startDate: start,
endDate: end,
timePicker: true,
timePicker24Hour: true,
locale: {
format: format
},
ranges: {
'Today': [moment(), moment()],
'Yesterday': [moment().subtract(1, 'days'), moment().subtract(1, 'days')],
'Last 7 Days': [moment().subtract(6, 'days'), moment()],
'Last 30 Days': [moment().subtract(29, 'days'), moment()],
'This Month': [moment().startOf('month'), moment().endOf('month')],
'Last Month': [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')]
}
}, cb);
cb(start, end);
});
</script>

View File

@ -0,0 +1,6 @@
<div class="text-muted">
{{ label }}:
<div class="d-inline-block">
<input class="form-control" type="text" name="{{ name }}" value="{{ value}}" placeholder="{{ placeholder }}">
</div>
</div>

View File

@ -0,0 +1,12 @@
<div class="text-muted">
{{ label }}:
<div class="mx-2 d-inline-block">
<select class="form-select" name="{{ name }}">
{% for option in options %}
<option value="{{ option[1] }}" {% if option[1] == value %}
selected
{% endif %} >{{ option[0] }}</option>
{% endfor %}
</select>
</div>
</div>

View File

@ -0,0 +1,5 @@
<div class="form-group mb-3">
<label class="form-label">{{ label }}</label>
<input {% if not null %}required{% endif %} type="{{ input_type }}" class="form-control" name="{{ name }}"
placeholder="{{ placeholder }}" {% if disabled %}disabled{% endif %} value="{{ value }}">
</div>

View File

@ -0,0 +1,32 @@
<script src="https://cdn.jsdelivr.net/npm/jsoneditor@9.4.0/dist/jsoneditor.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsoneditor@9.4.0/dist/jsoneditor.min.css">
<div class="form-label">{{ label }}</div>
<div id="{{ name }}" class="form-group mb-3"></div>
<input {% if not null %}required{% endif %} type="text" name="{{ name }}" value='{{ value|safe }}' hidden>
<style>
.jsoneditor {
border: 1px solid #dadcde;
border-radius: 4px;
}
.jsoneditor-menu {
background-color: rgba(35, 46, 60, .7);
border-bottom: #dadcde;
}
</style>
<script>
const container = document.getElementById("{{name}}")
let options = {{options|safe}};
if (Object.keys(options).length === 0) {
options = {
modes: ['tree', 'view', 'form', 'code', 'text', 'preview']
}
}
options.onChangeText = function (json) {
$('input[name={{ name }}]').val(json);
}
const editor = new JSONEditor(container, options)
editor.set({{ value|safe }})
</script>

View File

@ -0,0 +1,11 @@
<div class="form-group mb-3">
<div class="form-label">{{ label }}</div>
{% for option in options %}
<label class="form-check form-check-inline">
<input type="radio" class="form-check-input" name="{{ name }}" {% if disabled %}disabled{% endif %}
value="{{ option[1] }}"
{% if option[1] == value %}checked{% endif %}>
<span class="form-check-label">{{ option[0] }}</span>
</label>
{% endfor %}
</div>

View File

@ -0,0 +1,10 @@
<div class="form-group mb-3">
<div class="form-label">{{ label }}</div>
<select class="form-select" name="{{ name }}">
{% for option in options %}
<option value="{{ option[1] }}" {% if option[1] == value %}
selected
{% endif %} >{{ option[0] }}</option>
{% endfor %}
</select>
</div>

View File

@ -0,0 +1,10 @@
<div class="form-group mb-3">
<div class="form-label">{{ label }}</div>
<label class="form-check form-switch">
<input name="{{ name }}" class="form-check-input" type="checkbox" {% if value %}
checked
{% endif %} {% if disabled %}
disabled
{% endif %} >
</label>
</div>

View File

@ -0,0 +1,7 @@
<div class="form-group mb-3">
<label class="form-label">{{ label }}</label>
<textarea {% if not null %}required{% endif %} class="form-control" name="{{ name }}" placeholder="{{ placeholder }}"
{% if disabled %}disabled{% endif %}>
{{ value }}
</textarea>
</div>

View File

@ -0,0 +1,24 @@
from typing import Any
from starlette.templating import Jinja2Templates
from fastapi_admin.template import templates as t
class Widget:
templates: Jinja2Templates = t
template: str = ""
def __init__(self, **context):
"""
All context will pass to template render if template is not empty.
:param context:
"""
self.context = context
async def render(self, value: Any):
if value is None:
value = ""
if not self.template:
return value
return self.templates.get_template(self.template).render(value=value, **self.context)

View File

@ -0,0 +1,54 @@
import json
from datetime import datetime
from typing import Optional
from fastapi_admin import constants
from fastapi_admin.widgets import Widget
class Display(Widget):
"""
Parent class for all display widgets
"""
class DatetimeDisplay(Display):
def __init__(self, format_: str = constants.DATETIME_FORMAT):
super().__init__()
self.format_ = format_
async def render(self, value: datetime):
if value:
value = value.strftime(self.format_)
return await super(DatetimeDisplay, self).render(value)
class DateDisplay(DatetimeDisplay):
def __init__(self, format_: str = constants.DATE_FORMAT):
super().__init__(format_)
class InputOnly(Display):
"""
Only input without showing in display
"""
class Boolean(Display):
template = "widgets/displays/boolean.html"
class Image(Display):
template = "widgets/displays/image.html"
def __init__(self, width: Optional[str] = None, height: Optional[str] = None):
super().__init__(width=width, height=height)
class Json(Display):
template = "widgets/displays/json.html"
async def render(self, value: dict):
return self.templates.get_template(self.template).render(
value=json.dumps(value, indent=4, sort_keys=True),
)

View File

@ -0,0 +1,158 @@
import abc
from enum import Enum as EnumCLS
from typing import Any, Optional, Type
from tortoise import Model
from fastapi_admin import constants
from fastapi_admin.widgets.inputs import Input
class Filter(Input):
def __init__(self, name: str, label: str, placeholder: str = ""):
"""
Parent class for all filters
:param name: model field name
:param label:
"""
super().__init__(name=name, label=label, placeholder=placeholder)
class Search(Filter):
template = "widgets/filters/search.html"
def __init__(
self,
name: str,
label: str,
search_mode: str = "equal",
placeholder: str = "",
):
"""
Search for keyword
:param name:
:param label:
:param search_mode: equal,contains,icontains,startswith,istartswith,endswith,iendswith,iexact,search
"""
if search_mode == "equal":
super().__init__(name, label, placeholder)
else:
super().__init__(name + "__" + search_mode, label, placeholder)
self.context.update(search_mode=search_mode)
class Datetime(Filter):
template = "widgets/filters/datetime.html"
def __init__(
self,
name: str,
label: str,
format_: str = constants.DATE_FORMAT_MONENT,
):
"""
Datetime filter
:param name:
:param label:
:param format_: the format of moment.js
"""
super().__init__(
name + "__range",
label,
)
self.context.update(format=format_)
async def parse_value(self, value: Optional[str]):
return value.split(" - ")
async def render(self, value: Any):
if value is not None:
value = " - ".join(value)
return await super().render(value)
class Date(Datetime):
def __init__(
self,
name: str,
label: str,
format_: str = constants.DATE_FORMAT_MONENT,
):
super().__init__(
name,
label,
format_,
)
class Select(Filter):
template = "widgets/filters/select.html"
def __init__(self, name: str, label: str, null: bool = False):
super().__init__(name, label)
self.null = null
@abc.abstractmethod
async def get_options(self):
"""
return list of tuple with display and value
[("on",1),("off",2)]
:return: list of tuple with display and value
"""
async def render(self, value: Any):
options = await self.get_options()
self.context.update(options=options)
return await super(Select, self).render(value)
class Enum(Select):
def __init__(
self, enum: Type[EnumCLS], name: str, label: str, enum_type: Type = int, null: bool = False
):
super().__init__(name, label, null)
self.enum = enum
self.enum_type = enum_type
async def parse_value(self, value: Any):
return self.enum(self.enum_type(value))
async def get_options(self):
options = [(v.name, v.value) for v in self.enum]
if self.null:
options = [("", "")] + options
return options
class ForeignKey(Select):
def __init__(
self,
model: Type[Model],
name: str,
label: str,
):
super().__init__(name=name, label=label)
self.model = model
async def get_options(self):
ret = await self.get_queryset()
options = [
(
str(x),
x.pk,
)
for x in ret
]
if self.context.get("null"):
options = [("", "")] + options
return options
async def get_queryset(self):
return await self.model.all()
async def render(self, value: Any):
if value is not None:
value = int(value)
return await super().render(value)

View File

@ -0,0 +1,214 @@
import abc
import json
from enum import Enum as EnumCLS
from typing import Any, List, Optional, Tuple, Type
from starlette.datastructures import UploadFile
from tortoise import Model
from fastapi_admin.providers.file_upload import FileUploadProvider
from fastapi_admin.widgets import Widget
class Input(Widget):
template = "widgets/inputs/input.html"
def __init__(self, default: Any = None, null: bool = False, **context):
super().__init__(null=null, **context)
self.default = default
async def parse_value(self, value: Any):
"""
Parse value from frontend
:param value:
:return:
"""
return value
async def render(self, value: Any):
if value is None:
value = self.default
return await super(Input, self).render(value)
class DisplayOnly(Input):
"""
Only display without input in edit or create
"""
class Text(Input):
input_type: Optional[str] = "text"
def __init__(
self, default: Any = None, null: bool = False, placeholder: str = "", disabled: bool = False
):
super().__init__(
null=null,
default=default,
input_type=self.input_type,
placeholder=placeholder,
disabled=disabled,
)
class Select(Input):
template = "widgets/inputs/select.html"
def __init__(self, default: Any = None, null: bool = False, disabled: bool = False):
super().__init__(null=null, default=default, disabled=disabled)
@abc.abstractmethod
async def get_options(self):
"""
return list of tuple with display and value
[("on",1),("off",2)]
:return: list of tuple with display and value
"""
async def render(self, value: Any):
options = await self.get_options()
self.context.update(options=options)
return await super(Select, self).render(value)
class ForeignKey(Select):
def __init__(
self,
model: Type[Model],
default: Any = None,
null: bool = False,
disabled: bool = False,
):
super().__init__(default=default, null=null, disabled=disabled)
self.model = model
async def get_options(self):
ret = await self.get_queryset()
options = [(str(x), x.pk) for x in ret]
if self.context.get("null"):
options = [("", "")] + options
return options
async def get_queryset(self):
return await self.model.all()
class Enum(Select):
def __init__(
self,
enum: Type[EnumCLS],
default: Any = None,
enum_type: Type = int,
null: bool = False,
disabled: bool = False,
):
super().__init__(default=default, null=null, disabled=disabled)
self.enum = enum
self.enum_type = enum_type
async def parse_value(self, value: Any):
return self.enum(self.enum_type(value))
async def get_options(self):
options = [(v.name, v.value) for v in self.enum]
if self.context.get("null"):
options = [("", "")] + options
return options
class Email(Text):
input_type = "email"
class Json(Input):
template = "widgets/inputs/json.html"
def __init__(self, null: bool = False, options: Optional[dict] = None):
"""
options config to jsoneditor, see https://github.com/josdejong/jsoneditor
:param options:
"""
super().__init__(null=null)
if not options:
options = {}
self.context.update(options=options)
async def render(self, value: Any):
if value:
value = json.dumps(value)
return await super().render(value)
class TextArea(Text):
template = "widgets/inputs/textarea.html"
input_type = "textarea"
class DateTime(Text):
input_type = "datetime"
class Date(Text):
input_type = "date"
class File(Input):
input_type = "file"
def __init__(
self,
upload_provider: FileUploadProvider,
default: Any = None,
null: bool = False,
disabled: bool = False,
):
super().__init__(
null=null,
default=default,
input_type=self.input_type,
disabled=disabled,
)
self.upload_provider = upload_provider
async def parse_value(self, value: Optional[UploadFile]):
if value:
return await self.upload_provider.upload(value)
class Image(File):
input_type = "file"
class Radio(Select):
template = "widgets/inputs/radio.html"
def __init__(self, options: List[Tuple[str, Any]], default: Any = None, disabled: bool = False):
super().__init__(default=default, disabled=disabled)
self.options = options
async def get_options(self):
return self.options
class RadioEnum(Enum):
template = "widgets/inputs/radio.html"
class Switch(Input):
template = "widgets/inputs/switch.html"
async def parse_value(self, value: str):
if value == "on":
return True
return False
class Password(Text):
input_type = "password"
class Number(Text):
input_type = "number"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

View File

@ -1,8 +1,8 @@
site_name: FastAPI Admin
site_url: https://github.com/long2ice/fastapi-admin
repo_url: https://github.com/long2ice/fastapi-admin
site_description: Fast Admin Dashboard based on fastapi and tortoise-orm
repo_name: long2ice/fastapi-admin
site_url: https://github.com/fastapi-admin/fastapi-admin
repo_url: https://github.com/fastapi-admin/fastapi-admin.git
site_description: A fast admin dashboard based on FastAPI and TortoiseORM with tabler ui, inspired by Django admin.
repo_name: fastapi-admin/fastapi-admin
site_author: long2ice
theme:
name: material
@ -14,5 +14,3 @@ markdown_extensions:
- pymdownx.superfences
nav:
- index.md
- tutorial.md
- content.md

697
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,13 @@
[tool.poetry]
name = "fastapi-admin"
version = "0.3.3"
description = "Fast Admin Dashboard based on fastapi and tortoise-orm."
version = "1.0.0"
description = "A fast admin dashboard based on FastAPI and TortoiseORM with tabler ui, inspired by Django admin."
authors = ["long2ice <long2ice@gmail.com>"]
license = "Apache-2.0"
readme = "README.md"
homepage = "https://github.com/long2ice/fastapi-admin"
repository = "https://github.com/long2ice/fastapi-admin.git"
documentation = "https://github.com/long2ice/fastapi-admin"
homepage = "https://github.com/fastapi-admin/fastapi-admin"
repository = "https://github.com/fastapi-admin/fastapi-admin.git"
documentation = "https://github.com/fastapi-admin/fastapi-admin"
keywords = ["fastapi", "admin", "dashboard"]
packages = [
{ include = "fastapi_admin" }
@ -16,37 +16,38 @@ include = ["LICENSE", "README.md", "CHANGELOG.md"]
[tool.poetry.dependencies]
python = "^3.7"
tortoise-orm = "^0.17.0"
asyncmy = "*"
python-dotenv = "*"
uvloop = { version = "*", optional = true }
uvicorn = { version = "*", extras = ["standard"] }
python-rapidjson = "*"
tortoise-orm = "*"
fastapi = "*"
aiosqlite = "*"
passlib = "*"
bcrypt = "*"
pyjwt = "*"
xlsxwriter = "*"
colorama = "*"
prompt_toolkit = "*"
uvicorn = "*"
aiofiles = "*"
jinja2 = "*"
Babel = "*"
python-multipart = "*"
bcrypt = "*"
[tool.poetry.dev-dependencies]
# test
pytest = "*"
pytest-xdist = "*"
pytest-asyncio = "*"
pytest-mock = "*"
# lint
flake8 = "*"
isort = "*"
black = "^19.10b0"
pytest = "*"
bandit = "*"
black = "^20.8b1"
mypy = "*"
pylint = "*"
# example
python-dotenv = "*"
asyncmy = "*"
# docs
mkdocs = "*"
mkdocs-material = "*"
mike = "*"
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"
[tool.poetry.scripts]
fastapi-admin = "fastapi_admin.cli:main"
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.extras]
uvloop = ["uvloop"]
accel = ["uvloop"]