mirror of
https://github.com/fastapi-admin/fastapi-admin.git
synced 2025-08-14 18:58:13 +08:00
new project
This commit is contained in:
17
.github/workflows/deploy.yml
vendored
17
.github/workflows/deploy.yml
vendored
@ -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
|
16
.github/workflows/docs.yml
vendored
16
.github/workflows/docs.yml
vendored
@ -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 }}
|
@ -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
|
||||
|
35
Makefile
35
Makefile
@ -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
104
README.md
@ -1,112 +1,18 @@
|
||||
# FastAPI ADMIN
|
||||
|
||||
[](https://pypi.python.org/pypi/fastapi-admin)
|
||||
[](https://github.com/long2ice/fastapi-admin)
|
||||
[](https://github.com/long2ice/fastapi-admin/actions?query=workflow:gh-pages)
|
||||
[](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/)
|
||||
[](https://github.com/fastapi-admin/fastapi-admin)
|
||||
[](https://github.com/fastapi-admin/fastapi-admin/actions?query=workflow:gh-pages)
|
||||
[](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
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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
0
conftest.py
Normal file
377
docs/content.md
377
docs/content.md
@ -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.
|
@ -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,
|
||||
),
|
||||
)
|
||||
```
|
@ -0,0 +1 @@
|
||||
from . import resources, routes # noqa
|
||||
|
3
examples/constants.py
Normal file
3
examples/constants.py
Normal file
@ -0,0 +1,3 @@
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__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"
|
||||
|
@ -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;
|
@ -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"
|
144
examples/main.py
144
examples/main.py
@ -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
0
examples/middlewares.py
Normal 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
6
examples/providers.py
Normal 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
141
examples/resources.py
Normal 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
23
examples/routes.py
Normal 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
6
examples/settings.py
Normal file
@ -0,0 +1,6 @@
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
BIN
examples/static/uploads/avatar.jpeg
Normal file
BIN
examples/static/uploads/avatar.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
examples/static/uploads/s3_e6d5baeea33611eb9d70066d86dcb9a6.webp
Normal file
BIN
examples/static/uploads/s3_e6d5baeea33611eb9d70066d86dcb9a6.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.9 KiB |
@ -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 %}
|
||||
|
@ -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
80
fastapi_admin/app.py
Normal 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)
|
@ -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()
|
@ -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
|
7
fastapi_admin/constants.py
Normal file
7
fastapi_admin/constants.py
Normal 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"
|
@ -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)
|
||||
|
@ -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",
|
||||
}
|
@ -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
|
||||
"""
|
||||
|
@ -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)
|
@ -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)
|
126
fastapi_admin/locales/en_US/LC_MESSAGES/messages.po
Normal file
126
fastapi_admin/locales/en_US/LC_MESSAGES/messages.po
Normal 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"
|
126
fastapi_admin/locales/zh_CN/LC_MESSAGES/messages.po
Normal file
126
fastapi_admin/locales/zh_CN/LC_MESSAGES/messages.po
Normal 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 "返回首页"
|
22
fastapi_admin/middlewares.py
Normal file
22
fastapi_admin/middlewares.py
Normal 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
|
@ -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
|
0
fastapi_admin/providers/__init__.py
Normal file
0
fastapi_admin/providers/__init__.py
Normal file
42
fastapi_admin/providers/file_upload.py
Normal file
42
fastapi_admin/providers/file_upload.py
Normal 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)
|
103
fastapi_admin/providers/login.py
Normal file
103
fastapi_admin/providers/login.py
Normal 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
196
fastapi_admin/resources.py
Normal 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]]
|
@ -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
182
fastapi_admin/routes.py
Normal 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"))
|
@ -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")
|
@ -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}
|
@ -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
|
@ -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)
|
@ -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
|
@ -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
|
@ -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
75
fastapi_admin/template.py
Normal 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
|
23
fastapi_admin/templates/base.html
Normal file
23
fastapi_admin/templates/base.html
Normal 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>
|
37
fastapi_admin/templates/components/dropdown.html
Normal file
37
fastapi_admin/templates/components/dropdown.html
Normal 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>
|
12
fastapi_admin/templates/components/link.html
Normal file
12
fastapi_admin/templates/components/link.html
Normal 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>
|
12
fastapi_admin/templates/components/model.html
Normal file
12
fastapi_admin/templates/components/model.html
Normal 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>
|
24
fastapi_admin/templates/create.html
Normal file
24
fastapi_admin/templates/create.html
Normal 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 %}
|
24
fastapi_admin/templates/edit.html
Normal file
24
fastapi_admin/templates/edit.html
Normal 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 %}
|
123
fastapi_admin/templates/layout.html
Normal file
123
fastapi_admin/templates/layout.html
Normal 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 %}
|
120
fastapi_admin/templates/list.html
Normal file
120
fastapi_admin/templates/list.html
Normal 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 %}
|
58
fastapi_admin/templates/login.html
Normal file
58
fastapi_admin/templates/login.html
Normal 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 %}
|
5
fastapi_admin/templates/widgets/displays/boolean.html
Normal file
5
fastapi_admin/templates/widgets/displays/boolean.html
Normal file
@ -0,0 +1,5 @@
|
||||
{% if value %}
|
||||
<span class="badge bg-green">true</span>
|
||||
{% else %}
|
||||
<span class="badge bg-red">false</span>
|
||||
{% endif %}
|
1
fastapi_admin/templates/widgets/displays/image.html
Normal file
1
fastapi_admin/templates/widgets/displays/image.html
Normal file
@ -0,0 +1 @@
|
||||
<img src="{{ value }}" alt="" width="{{ width }}" height="{{ height }}">
|
4
fastapi_admin/templates/widgets/displays/json.html
Normal file
4
fastapi_admin/templates/widgets/displays/json.html
Normal 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>
|
47
fastapi_admin/templates/widgets/filters/datetime.html
Normal file
47
fastapi_admin/templates/widgets/filters/datetime.html
Normal 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>
|
6
fastapi_admin/templates/widgets/filters/search.html
Normal file
6
fastapi_admin/templates/widgets/filters/search.html
Normal 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>
|
12
fastapi_admin/templates/widgets/filters/select.html
Normal file
12
fastapi_admin/templates/widgets/filters/select.html
Normal 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>
|
5
fastapi_admin/templates/widgets/inputs/input.html
Normal file
5
fastapi_admin/templates/widgets/inputs/input.html
Normal 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>
|
32
fastapi_admin/templates/widgets/inputs/json.html
Normal file
32
fastapi_admin/templates/widgets/inputs/json.html
Normal 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>
|
11
fastapi_admin/templates/widgets/inputs/radio.html
Normal file
11
fastapi_admin/templates/widgets/inputs/radio.html
Normal 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>
|
10
fastapi_admin/templates/widgets/inputs/select.html
Normal file
10
fastapi_admin/templates/widgets/inputs/select.html
Normal 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>
|
10
fastapi_admin/templates/widgets/inputs/switch.html
Normal file
10
fastapi_admin/templates/widgets/inputs/switch.html
Normal 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>
|
7
fastapi_admin/templates/widgets/inputs/textarea.html
Normal file
7
fastapi_admin/templates/widgets/inputs/textarea.html
Normal 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>
|
24
fastapi_admin/widgets/__init__.py
Normal file
24
fastapi_admin/widgets/__init__.py
Normal 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)
|
54
fastapi_admin/widgets/displays.py
Normal file
54
fastapi_admin/widgets/displays.py
Normal 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),
|
||||
)
|
158
fastapi_admin/widgets/filters.py
Normal file
158
fastapi_admin/widgets/filters.py
Normal 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)
|
214
fastapi_admin/widgets/inputs.py
Normal file
214
fastapi_admin/widgets/inputs.py
Normal 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 |
BIN
images/list.png
BIN
images/list.png
Binary file not shown.
Before Width: | Height: | Size: 375 KiB |
BIN
images/login.png
BIN
images/login.png
Binary file not shown.
Before Width: | Height: | Size: 190 KiB |
BIN
images/view.png
BIN
images/view.png
Binary file not shown.
Before Width: | Height: | Size: 288 KiB |
10
mkdocs.yml
10
mkdocs.yml
@ -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
697
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"]
|
||||
|
Reference in New Issue
Block a user