diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 2730989..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 560c7be..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2714f82..4a3ac3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index abade88..755a7c5 100644 --- a/Makefile +++ b/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 " - @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 diff --git a/README.md b/README.md index a57e979..6c42941 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,18 @@ # FastAPI ADMIN [![image](https://img.shields.io/pypi/v/fastapi-admin.svg?style=flat)](https://pypi.python.org/pypi/fastapi-admin) -[![image](https://img.shields.io/github/license/long2ice/fastapi-admin)](https://github.com/long2ice/fastapi-admin) -[![image](https://github.com/long2ice/fastapi-admin/workflows/gh-pages/badge.svg)](https://github.com/long2ice/fastapi-admin/actions?query=workflow:gh-pages) -[![image](https://github.com/long2ice/fastapi-admin/workflows/pypi/badge.svg)](https://github.com/long2ice/fastapi-admin/actions?query=workflow:pypi) - -[中文文档](https://blog.long2ice.cn/2020/05/fastapi-admin%E5%BF%AB%E9%80%9F%E6%90%AD%E5%BB%BA%E5%9F%BA%E4%BA%8Efastapi%E4%B8%8Etortoise-orm%E7%9A%84%E7%AE%A1%E7%90%86%E5%90%8E%E5%8F%B0/) +[![image](https://img.shields.io/github/license/fastapi-admin/fastapi-admin)](https://github.com/fastapi-admin/fastapi-admin) +[![image](https://github.com/fastapi-admin/fastapi-admin/workflows/gh-pages/badge.svg)](https://github.com/fastapi-admin/fastapi-admin/actions?query=workflow:gh-pages) +[![image](https://github.com/fastapi-admin/fastapi-admin/workflows/pypi/badge.svg)](https://github.com/fastapi-admin/fastapi-admin/actions?query=workflow:pypi) ## Introduction -FastAPI-Admin is a admin dashboard based on -[fastapi](https://github.com/tiangolo/fastapi) and -[tortoise-orm](https://github.com/tortoise/tortoise-orm). - -FastAPI-Admin provide crud feature out-of-the-box with just a few config. - -## Live Demo - -Check a live Demo here -[https://fastapi-admin.long2ice.cn](https://fastapi-admin.long2ice.cn/). - -- username: `admin` -- password: `123456` - -Data in database will restore every day. - -## Screenshots - -![image](https://github.com/long2ice/fastapi-admin/raw/master/images/login.png) - -![image](https://github.com/long2ice/fastapi-admin/raw/master/images/list.png) - -![image](https://github.com/long2ice/fastapi-admin/raw/master/images/view.png) - -![image](https://github.com/long2ice/fastapi-admin/raw/master/images/create.png) - -## Requirements - -- [FastAPI](https://github.com/tiangolo/fastapi) framework as your backend framework. -- [Tortoise-ORM](https://github.com/tortoise/tortoise-orm) as your orm framework, by the way, which is best asyncio orm - so far and I\'m one of the contributors😋. - -## Quick Start - -### Run Backend - -Look full example at -[examples](https://github.com/long2ice/fastapi-admin/tree/dev/examples). - -1. `git clone https://github.com/long2ice/fastapi-admin.git`. -2. `docker-compose up -d --build`. -3. `docker-compose exec -T mysql mysql -uroot -p123456 < examples/example.sql fastapi-admin`. -4. That's just all, api server is listen at [http://127.0.0.1:8000](http://127.0.0.1:8000) now. - -### Run Front - -See -[restful-admin](https://github.com/long2ice/restful-admin) -for reference. - -## Backend Integration - -```shell -> pip3 install fastapi-admin -``` - -```Python -from fastapi_admin.factory import app as admin_app - -fast_app = FastAPI() - -register_tortoise(fast_app, config=TORTOISE_ORM, generate_schemas=True) - -fast_app.mount('/admin', admin_app) - - -@fast_app.on_event('startup') -async def startup(): - await admin_app.init( - admin_secret="test", - permission=True, - site=Site( - name="FastAPI-Admin DEMO", - login_footer="FASTAPI ADMIN - FastAPI Admin Dashboard", - login_description="FastAPI Admin Dashboard", - locale="en-US", - locale_switcher=True, - theme_switcher=True, - ), - ) -``` - ## Documentation -See documentation at [https://long2ice.github.io/fastapi-admin](https://long2ice.github.io/fastapi-admin). - -## Deployment - -Deploy fastapi app by gunicorn+uvicorn or reference -. - -## 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. diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/content.md b/docs/content.md deleted file mode 100644 index ea99480..0000000 --- a/docs/content.md +++ /dev/null @@ -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. diff --git a/docs/tutorial.md b/docs/tutorial.md deleted file mode 100644 index 0a9619b..0000000 --- a/docs/tutorial.md +++ /dev/null @@ -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, - ), - ) -``` diff --git a/examples/__init__.py b/examples/__init__.py index e69de29..d3611a3 100644 --- a/examples/__init__.py +++ b/examples/__init__.py @@ -0,0 +1 @@ +from . import resources, routes # noqa diff --git a/examples/constants.py b/examples/constants.py new file mode 100644 index 0000000..4198d3c --- /dev/null +++ b/examples/constants.py @@ -0,0 +1,3 @@ +import os + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/examples/enums.py b/examples/enums.py index 2f7b0cd..5d65832 100644 --- a/examples/enums.py +++ b/examples/enums.py @@ -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" diff --git a/examples/example.sql b/examples/example.sql deleted file mode 100644 index 3cc4413..0000000 --- a/examples/example.sql +++ /dev/null @@ -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; diff --git a/examples/filters.py b/examples/filters.py deleted file mode 100644 index 24a47c2..0000000 --- a/examples/filters.py +++ /dev/null @@ -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" diff --git a/examples/main.py b/examples/main.py index e068ae9..2449e2b 100644 --- a/examples/main.py +++ b/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) diff --git a/examples/middlewares.py b/examples/middlewares.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/models.py b/examples/models.py index 256cdec..3415efe 100644 --- a/examples/models.py +++ b/examples/models.py @@ -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"] diff --git a/examples/providers.py b/examples/providers.py new file mode 100644 index 0000000..22963ba --- /dev/null +++ b/examples/providers.py @@ -0,0 +1,6 @@ +from examples.models import User +from fastapi_admin.providers.login import UsernamePasswordProvider + + +class Login(UsernamePasswordProvider): + model = User diff --git a/examples/resources.py b/examples/resources.py new file mode 100644 index 0000000..a1f5cb8 --- /dev/null +++ b/examples/resources.py @@ -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" diff --git a/examples/routes.py b/examples/routes.py new file mode 100644 index 0000000..7b7d957 --- /dev/null +++ b/examples/routes.py @@ -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", + }, + ) diff --git a/examples/settings.py b/examples/settings.py new file mode 100644 index 0000000..d6ce927 --- /dev/null +++ b/examples/settings.py @@ -0,0 +1,6 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() +DATABASE_URL = os.getenv("DATABASE_URL") diff --git a/examples/static/uploads/avatar.jpeg b/examples/static/uploads/avatar.jpeg new file mode 100644 index 0000000..e344138 Binary files /dev/null and b/examples/static/uploads/avatar.jpeg differ diff --git a/examples/static/uploads/s3_e6d5baeea33611eb9d70066d86dcb9a6.webp b/examples/static/uploads/s3_e6d5baeea33611eb9d70066d86dcb9a6.webp new file mode 100644 index 0000000..f83d554 Binary files /dev/null and b/examples/static/uploads/s3_e6d5baeea33611eb9d70066d86dcb9a6.webp differ diff --git a/examples/templates/home.html b/examples/templates/home.html index 129c246..c560308 100644 --- a/examples/templates/home.html +++ b/examples/templates/home.html @@ -1,9 +1,4 @@ -
-
-

Welcome to FastAPI ADMIN

-
-
-
-
-

Stargazers over time

-
Stargazers over time
+{% extends "layout.html" %} +{% block page_body %} +
This is home page
+{% endblock %} diff --git a/fastapi_admin/__init__.py b/fastapi_admin/__init__.py index 2be3308..1cf6267 100644 --- a/fastapi_admin/__init__.py +++ b/fastapi_admin/__init__.py @@ -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" diff --git a/fastapi_admin/app.py b/fastapi_admin/app.py new file mode 100644 index 0000000..4e3dfef --- /dev/null +++ b/fastapi_admin/app.py @@ -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) diff --git a/fastapi_admin/cli.py b/fastapi_admin/cli.py deleted file mode 100644 index 20ac603..0000000 --- a/fastapi_admin/cli.py +++ /dev/null @@ -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() diff --git a/fastapi_admin/common.py b/fastapi_admin/common.py deleted file mode 100644 index ca66a9c..0000000 --- a/fastapi_admin/common.py +++ /dev/null @@ -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 diff --git a/fastapi_admin/constants.py b/fastapi_admin/constants.py new file mode 100644 index 0000000..a6af2c5 --- /dev/null +++ b/fastapi_admin/constants.py @@ -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" diff --git a/fastapi_admin/depends.py b/fastapi_admin/depends.py index 4d69f30..de9f6a9 100644 --- a/fastapi_admin/depends.py +++ b/fastapi_admin/depends.py @@ -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 +def get_model(resource: Optional[str] = Path(...)): + if not resource: + return + for app, models in Tortoise.apps.items(): + model = models.get(resource.title()) + if model: + return model -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 - return +def get_model_resource(request: Request, model=Depends(get_model)): + return request.app.get_model_resource(model) -class QueryItem(BaseModel): - page: int = 1 - sort: dict - where: dict = {} - with_: dict = {} - size: int = 10 - sort: dict = {} - - class Config: - fields = {"with_": "with"} +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 -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) - 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 - - -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 - - -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) diff --git a/fastapi_admin/enums.py b/fastapi_admin/enums.py deleted file mode 100644 index 620f68f..0000000 --- a/fastapi_admin/enums.py +++ /dev/null @@ -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", - } diff --git a/fastapi_admin/exceptions.py b/fastapi_admin/exceptions.py index d8bab69..3f11be7 100644 --- a/fastapi_admin/exceptions.py +++ b/fastapi_admin/exceptions.py @@ -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 + """ diff --git a/fastapi_admin/factory.py b/fastapi_admin/factory.py deleted file mode 100644 index 61b3bdb..0000000 --- a/fastapi_admin/factory.py +++ /dev/null @@ -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) diff --git a/fastapi_admin/filters.py b/fastapi_admin/filters.py deleted file mode 100644 index fc3490e..0000000 --- a/fastapi_admin/filters.py +++ /dev/null @@ -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) diff --git a/fastapi_admin/locales/en_US/LC_MESSAGES/messages.po b/fastapi_admin/locales/en_US/LC_MESSAGES/messages.po new file mode 100644 index 0000000..f329269 --- /dev/null +++ b/fastapi_admin/locales/en_US/LC_MESSAGES/messages.po @@ -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 , 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 \n" +"Language: en_US\n" +"Language-Team: en_US \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" diff --git a/fastapi_admin/locales/zh_CN/LC_MESSAGES/messages.po b/fastapi_admin/locales/zh_CN/LC_MESSAGES/messages.po new file mode 100644 index 0000000..4d7e076 --- /dev/null +++ b/fastapi_admin/locales/zh_CN/LC_MESSAGES/messages.po @@ -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 , 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 \n" +"Language: zh_Hans_CN\n" +"Language-Team: zh_Hans_CN \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 "返回首页" diff --git a/fastapi_admin/middlewares.py b/fastapi_admin/middlewares.py new file mode 100644 index 0000000..50774ca --- /dev/null +++ b/fastapi_admin/middlewares.py @@ -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 diff --git a/fastapi_admin/models.py b/fastapi_admin/models.py deleted file mode 100644 index 08f8abc..0000000 --- a/fastapi_admin/models.py +++ /dev/null @@ -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 diff --git a/fastapi_admin/providers/__init__.py b/fastapi_admin/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi_admin/providers/file_upload.py b/fastapi_admin/providers/file_upload.py new file mode 100644 index 0000000..8a9cc01 --- /dev/null +++ b/fastapi_admin/providers/file_upload.py @@ -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) diff --git a/fastapi_admin/providers/login.py b/fastapi_admin/providers/login.py new file mode 100644 index 0000000..20506a6 --- /dev/null +++ b/fastapi_admin/providers/login.py @@ -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"]) diff --git a/fastapi_admin/resources.py b/fastapi_admin/resources.py new file mode 100644 index 0000000..bb5a738 --- /dev/null +++ b/fastapi_admin/resources.py @@ -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]] diff --git a/fastapi_admin/responses.py b/fastapi_admin/responses.py index 1fad50f..b907214 100644 --- a/fastapi_admin/responses.py +++ b/fastapi_admin/responses.py @@ -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, + ) diff --git a/fastapi_admin/routes.py b/fastapi_admin/routes.py new file mode 100644 index 0000000..3a87427 --- /dev/null +++ b/fastapi_admin/routes.py @@ -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")) diff --git a/fastapi_admin/routes/__init__.py b/fastapi_admin/routes/__init__.py deleted file mode 100644 index a051c9f..0000000 --- a/fastapi_admin/routes/__init__.py +++ /dev/null @@ -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") diff --git a/fastapi_admin/routes/other.py b/fastapi_admin/routes/other.py deleted file mode 100644 index 5ccef50..0000000 --- a/fastapi_admin/routes/other.py +++ /dev/null @@ -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} diff --git a/fastapi_admin/routes/rest.py b/fastapi_admin/routes/rest.py deleted file mode 100644 index 5042aca..0000000 --- a/fastapi_admin/routes/rest.py +++ /dev/null @@ -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 diff --git a/fastapi_admin/routes/site.py b/fastapi_admin/routes/site.py deleted file mode 100644 index 20f9cbd..0000000 --- a/fastapi_admin/routes/site.py +++ /dev/null @@ -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) diff --git a/fastapi_admin/schemas.py b/fastapi_admin/schemas.py deleted file mode 100644 index 32ca39e..0000000 --- a/fastapi_admin/schemas.py +++ /dev/null @@ -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 diff --git a/fastapi_admin/shortcuts.py b/fastapi_admin/shortcuts.py deleted file mode 100644 index 6b6b5f9..0000000 --- a/fastapi_admin/shortcuts.py +++ /dev/null @@ -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 diff --git a/fastapi_admin/site.py b/fastapi_admin/site.py deleted file mode 100644 index ebb2b9b..0000000 --- a/fastapi_admin/site.py +++ /dev/null @@ -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/ 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
due to being rendered in a - 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", - } diff --git a/fastapi_admin/template.py b/fastapi_admin/template.py new file mode 100644 index 0000000..ec1e654 --- /dev/null +++ b/fastapi_admin/template.py @@ -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 diff --git a/fastapi_admin/templates/base.html b/fastapi_admin/templates/base.html new file mode 100644 index 0000000..3ad3ff2 --- /dev/null +++ b/fastapi_admin/templates/base.html @@ -0,0 +1,23 @@ + + + + + + + + + + + {% block head %} + {% endblock %} + {{ title }} + +{% block outer_body %} + + {% block body %} + {% endblock %} + {% block footer %} + {% endblock %} + +{% endblock %} + diff --git a/fastapi_admin/templates/components/dropdown.html b/fastapi_admin/templates/components/dropdown.html new file mode 100644 index 0000000..6dd4b90 --- /dev/null +++ b/fastapi_admin/templates/components/dropdown.html @@ -0,0 +1,37 @@ + diff --git a/fastapi_admin/templates/components/link.html b/fastapi_admin/templates/components/link.html new file mode 100644 index 0000000..5e69f1d --- /dev/null +++ b/fastapi_admin/templates/components/link.html @@ -0,0 +1,12 @@ + diff --git a/fastapi_admin/templates/components/model.html b/fastapi_admin/templates/components/model.html new file mode 100644 index 0000000..9cc89e6 --- /dev/null +++ b/fastapi_admin/templates/components/model.html @@ -0,0 +1,12 @@ + diff --git a/fastapi_admin/templates/create.html b/fastapi_admin/templates/create.html new file mode 100644 index 0000000..6efb383 --- /dev/null +++ b/fastapi_admin/templates/create.html @@ -0,0 +1,24 @@ +{% extends "layout.html" %} +{% block page_body %} +
+
+
+

{{ resource_label }}

+
+
+
+ {% for input in inputs %} + {{ input|safe }} + {% endfor %} + +
+
+
+
+{% endblock %} diff --git a/fastapi_admin/templates/edit.html b/fastapi_admin/templates/edit.html new file mode 100644 index 0000000..03b59e3 --- /dev/null +++ b/fastapi_admin/templates/edit.html @@ -0,0 +1,24 @@ +{% extends "layout.html" %} +{% block page_body %} +
+
+
+

{{ resource_label }}

+
+
+
+ {% for input in inputs %} + {{ input|safe }} + {% endfor %} + +
+
+
+
+{% endblock %} diff --git a/fastapi_admin/templates/layout.html b/fastapi_admin/templates/layout.html new file mode 100644 index 0000000..eb58163 --- /dev/null +++ b/fastapi_admin/templates/layout.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% block body %} +
+ +
+
+ +
+
+
+
+ {% block page_body %} + + {% endblock %} +
+
+
+ +
+
+{% endblock %} diff --git a/fastapi_admin/templates/list.html b/fastapi_admin/templates/list.html new file mode 100644 index 0000000..898093c --- /dev/null +++ b/fastapi_admin/templates/list.html @@ -0,0 +1,120 @@ +{% extends "layout.html" %} +{% block page_body %} +
+
+
+

{{ resource_label }}

+ {% if model_resource.can_create %} + {{ _('create') }} + {% endif %} +
+
+
+
+
+ {{ _('show') }} +
+ +
+ {{ _('entries') }} +
+
+ {% for filter in filters %} +
+ {{ filter|safe }} +
+ {% endfor %} +
+ +
+
+
+
+
+
+ + + + + {% for label in fields_label %} + + {% endfor %} + + + + + {% for value in values %} + + + {% for v in value %} + + {% endfor %} + {% if model_resource.can_edit or model_resource.can_delete %} + + {% endif %} + + {% endfor %} + +
{{ label }}
+ + {{ v|safe }} + + + + +
+
+ +
+
+{% endblock %} diff --git a/fastapi_admin/templates/login.html b/fastapi_admin/templates/login.html new file mode 100644 index 0000000..ec6e82f --- /dev/null +++ b/fastapi_admin/templates/login.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block outer_body %} + +
+
+
+ +
+ {% if error %} + + {% endif %} +
+
+

{{ _('login_title') }}

+
+ + +
+
+ +
+ + + +
+
+
+ +
+ +
+
+
+
+ +{% endblock %} diff --git a/fastapi_admin/templates/widgets/displays/boolean.html b/fastapi_admin/templates/widgets/displays/boolean.html new file mode 100644 index 0000000..7a62b11 --- /dev/null +++ b/fastapi_admin/templates/widgets/displays/boolean.html @@ -0,0 +1,5 @@ +{% if value %} + true +{% else %} + false +{% endif %} diff --git a/fastapi_admin/templates/widgets/displays/image.html b/fastapi_admin/templates/widgets/displays/image.html new file mode 100644 index 0000000..3db806b --- /dev/null +++ b/fastapi_admin/templates/widgets/displays/image.html @@ -0,0 +1 @@ + diff --git a/fastapi_admin/templates/widgets/displays/json.html b/fastapi_admin/templates/widgets/displays/json.html new file mode 100644 index 0000000..7150ae7 --- /dev/null +++ b/fastapi_admin/templates/widgets/displays/json.html @@ -0,0 +1,4 @@ + + + +
{{ value }}
diff --git a/fastapi_admin/templates/widgets/filters/datetime.html b/fastapi_admin/templates/widgets/filters/datetime.html new file mode 100644 index 0000000..1996ebd --- /dev/null +++ b/fastapi_admin/templates/widgets/filters/datetime.html @@ -0,0 +1,47 @@ + + +
+ {{ label }}: +
+ +
+
+ diff --git a/fastapi_admin/templates/widgets/filters/search.html b/fastapi_admin/templates/widgets/filters/search.html new file mode 100644 index 0000000..a62a711 --- /dev/null +++ b/fastapi_admin/templates/widgets/filters/search.html @@ -0,0 +1,6 @@ +
+ {{ label }}: +
+ +
+
diff --git a/fastapi_admin/templates/widgets/filters/select.html b/fastapi_admin/templates/widgets/filters/select.html new file mode 100644 index 0000000..c0a3f28 --- /dev/null +++ b/fastapi_admin/templates/widgets/filters/select.html @@ -0,0 +1,12 @@ +
+ {{ label }}: +
+ +
+
diff --git a/fastapi_admin/templates/widgets/inputs/input.html b/fastapi_admin/templates/widgets/inputs/input.html new file mode 100644 index 0000000..8423871 --- /dev/null +++ b/fastapi_admin/templates/widgets/inputs/input.html @@ -0,0 +1,5 @@ +
+ + +
diff --git a/fastapi_admin/templates/widgets/inputs/json.html b/fastapi_admin/templates/widgets/inputs/json.html new file mode 100644 index 0000000..11c6ab4 --- /dev/null +++ b/fastapi_admin/templates/widgets/inputs/json.html @@ -0,0 +1,32 @@ + + +
{{ label }}
+
+ + + diff --git a/fastapi_admin/templates/widgets/inputs/radio.html b/fastapi_admin/templates/widgets/inputs/radio.html new file mode 100644 index 0000000..043fb1a --- /dev/null +++ b/fastapi_admin/templates/widgets/inputs/radio.html @@ -0,0 +1,11 @@ +
+
{{ label }}
+ {% for option in options %} + + {% endfor %} +
diff --git a/fastapi_admin/templates/widgets/inputs/select.html b/fastapi_admin/templates/widgets/inputs/select.html new file mode 100644 index 0000000..41dc52e --- /dev/null +++ b/fastapi_admin/templates/widgets/inputs/select.html @@ -0,0 +1,10 @@ +
+
{{ label }}
+ +
diff --git a/fastapi_admin/templates/widgets/inputs/switch.html b/fastapi_admin/templates/widgets/inputs/switch.html new file mode 100644 index 0000000..5e3acac --- /dev/null +++ b/fastapi_admin/templates/widgets/inputs/switch.html @@ -0,0 +1,10 @@ +
+
{{ label }}
+ +
diff --git a/fastapi_admin/templates/widgets/inputs/textarea.html b/fastapi_admin/templates/widgets/inputs/textarea.html new file mode 100644 index 0000000..bc02c2d --- /dev/null +++ b/fastapi_admin/templates/widgets/inputs/textarea.html @@ -0,0 +1,7 @@ +
+ + +
diff --git a/fastapi_admin/widgets/__init__.py b/fastapi_admin/widgets/__init__.py new file mode 100644 index 0000000..1719f6f --- /dev/null +++ b/fastapi_admin/widgets/__init__.py @@ -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) diff --git a/fastapi_admin/widgets/displays.py b/fastapi_admin/widgets/displays.py new file mode 100644 index 0000000..3a8b902 --- /dev/null +++ b/fastapi_admin/widgets/displays.py @@ -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), + ) diff --git a/fastapi_admin/widgets/filters.py b/fastapi_admin/widgets/filters.py new file mode 100644 index 0000000..ff38fb6 --- /dev/null +++ b/fastapi_admin/widgets/filters.py @@ -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) diff --git a/fastapi_admin/widgets/inputs.py b/fastapi_admin/widgets/inputs.py new file mode 100644 index 0000000..78304a7 --- /dev/null +++ b/fastapi_admin/widgets/inputs.py @@ -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" diff --git a/images/create.png b/images/create.png deleted file mode 100644 index 72d201c..0000000 Binary files a/images/create.png and /dev/null differ diff --git a/images/list.png b/images/list.png deleted file mode 100644 index ccdab71..0000000 Binary files a/images/list.png and /dev/null differ diff --git a/images/login.png b/images/login.png deleted file mode 100644 index d1b228a..0000000 Binary files a/images/login.png and /dev/null differ diff --git a/images/view.png b/images/view.png deleted file mode 100644 index 26bca71..0000000 Binary files a/images/view.png and /dev/null differ diff --git a/mkdocs.yml b/mkdocs.yml index 4dd03a2..3fae4f1 100644 --- a/mkdocs.yml +++ b/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 diff --git a/poetry.lock b/poetry.lock index 6ba3020..e7bfe89 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,11 @@ +[[package]] +name = "aiofiles" +version = "0.6.0" +description = "File support for asyncio." +category = "main" +optional = false +python-versions = "*" + [[package]] name = "aiosqlite" version = "0.16.1" @@ -9,6 +17,14 @@ python-versions = ">=3.6" [package.dependencies] typing_extensions = ">=3.7.2" +[[package]] +name = "apipkg" +version = "1.5" +description = "apipkg: namespace control and lazy-import mechanism" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "appdirs" version = "1.4.4" @@ -17,11 +33,24 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "astroid" +version = "2.5.5" +description = "An abstract syntax tree for Python with inference support." +category = "dev" +optional = false +python-versions = "~=3.6" + +[package.dependencies] +lazy-object-proxy = ">=1.4.0" +typed-ast = {version = ">=1.4.0,<1.5", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""} +wrapt = ">=1.11,<1.13" + [[package]] name = "asyncmy" -version = "0.1.4" +version = "0.1.5" description = "A fast asyncio MySQL driver" -category = "main" +category = "dev" optional = false python-versions = ">=3.7,<4.0" @@ -48,19 +77,15 @@ tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)" tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] [[package]] -name = "bandit" -version = "1.7.0" -description = "Security oriented static analyser for python code." -category = "dev" +name = "babel" +version = "2.9.0" +description = "Internationalization utilities" +category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -GitPython = ">=1.0.1" -PyYAML = ">=5.3.1" -six = ">=1.10.0" -stevedore = ">=1.20.0" +pytz = ">=2015.7" [[package]] name = "bcrypt" @@ -80,7 +105,7 @@ typecheck = ["mypy"] [[package]] name = "black" -version = "19.10b0" +version = "20.8b1" description = "The uncompromising code formatter." category = "dev" optional = false @@ -88,14 +113,16 @@ python-versions = ">=3.6" [package.dependencies] appdirs = "*" -attrs = ">=18.1.0" -click = ">=6.5" +click = ">=7.1.2" +mypy-extensions = ">=0.4.3" pathspec = ">=0.6,<1" -regex = "*" -toml = ">=0.9.4" +regex = ">=2020.1.8" +toml = ">=0.10.1" typed-ast = ">=1.4.0" +typing-extensions = ">=3.7.4" [package.extras] +colorama = ["colorama (>=0.4.3)"] d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] @@ -121,10 +148,24 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" name = "colorama" version = "0.4.4" description = "Cross-platform colored terminal text." -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "execnet" +version = "1.8.0" +description = "execnet: rapid multi-Python deployment" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +apipkg = ">=1.4" + +[package.extras] +testing = ["pre-commit"] + [[package]] name = "fastapi" version = "0.63.0" @@ -145,7 +186,7 @@ test = ["pytest (==5.4.3)", "pytest-cov (==2.10.0)", "pytest-asyncio (>=0.14.0,< [[package]] name = "flake8" -version = "3.9.0" +version = "3.9.1" description = "the modular source code checker: pep8 pyflakes and co" category = "dev" optional = false @@ -165,28 +206,6 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -[[package]] -name = "gitdb" -version = "4.0.7" -description = "Git Object Database" -category = "dev" -optional = false -python-versions = ">=3.4" - -[package.dependencies] -smmap = ">=3.0.1,<5" - -[[package]] -name = "gitpython" -version = "3.1.14" -description = "Python Git Library" -category = "dev" -optional = false -python-versions = ">=3.4" - -[package.dependencies] -gitdb = ">=4.0.1,<5" - [[package]] name = "h11" version = "0.12.0" @@ -195,20 +214,9 @@ category = "main" optional = false python-versions = ">=3.6" -[[package]] -name = "httptools" -version = "0.1.1" -description = "A collection of framework independent HTTP protocol utils." -category = "main" -optional = false -python-versions = "*" - -[package.extras] -test = ["Cython (==0.29.14)"] - [[package]] name = "importlib-metadata" -version = "3.10.0" +version = "4.0.1" description = "Read metadata from Python packages" category = "dev" optional = false @@ -273,6 +281,14 @@ category = "dev" optional = false python-versions = ">=3.6" +[[package]] +name = "lazy-object-proxy" +version = "1.6.0" +description = "A fast and thorough lazy object proxy." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + [[package]] name = "livereload" version = "2.6.3" @@ -331,6 +347,24 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "mike" +version = "1.0.0" +description = "Manage multiple versions of your MkDocs-powered documentation" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +jinja2 = "*" +mkdocs = ">=1.0" +pyyaml = "*" +verspec = "*" + +[package.extras] +dev = ["coverage", "flake8 (>=3.0)"] +test = ["coverage", "flake8 (>=3.0)"] + [[package]] name = "mkdocs" version = "1.1.2" @@ -350,7 +384,7 @@ tornado = ">=5.0" [[package]] name = "mkdocs-material" -version = "7.1.0" +version = "7.1.3" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -374,9 +408,33 @@ python-versions = ">=3.5" [package.dependencies] mkdocs-material = ">=5.0.0" +[[package]] +name = "mypy" +version = "0.812" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +mypy-extensions = ">=0.4.3,<0.5.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "nltk" -version = "3.6.1" +version = "3.6.2" description = "Natural Language Toolkit" category = "dev" optional = false @@ -389,7 +447,7 @@ regex = "*" tqdm = "*" [package.extras] -all = ["requests", "pyparsing", "numpy", "twython", "gensim (<4.0.0)", "scikit-learn", "scipy", "python-crfsuite", "matplotlib"] +all = ["matplotlib", "twython", "scipy", "numpy", "gensim (<4.0.0)", "python-crfsuite", "pyparsing", "scikit-learn", "requests"] corenlp = ["requests"] machine_learning = ["gensim (<4.0.0)", "numpy", "python-crfsuite", "scikit-learn", "scipy"] plot = ["matplotlib"] @@ -407,20 +465,6 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pyparsing = ">=2.0.2" -[[package]] -name = "passlib" -version = "1.7.4" -description = "comprehensive password hashing framework supporting over 30 schemes" -category = "main" -optional = false -python-versions = "*" - -[package.extras] -argon2 = ["argon2-cffi (>=18.2.0)"] -bcrypt = ["bcrypt (>=3.1.0)"] -build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.1)"] -totp = ["cryptography"] - [[package]] name = "pathspec" version = "0.8.1" @@ -429,14 +473,6 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -[[package]] -name = "pbr" -version = "5.5.1" -description = "Python Build Reasonableness" -category = "dev" -optional = false -python-versions = ">=2.6" - [[package]] name = "pluggy" version = "0.13.1" @@ -451,17 +487,6 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] -[[package]] -name = "prompt-toolkit" -version = "3.0.18" -description = "Library for building powerful interactive command lines in Python" -category = "main" -optional = false -python-versions = ">=3.6.1" - -[package.dependencies] -wcwidth = "*" - [[package]] name = "py" version = "1.10.0" @@ -518,18 +543,19 @@ optional = false python-versions = ">=3.5" [[package]] -name = "pyjwt" -version = "2.0.1" -description = "JSON Web Token implementation in Python" -category = "main" +name = "pylint" +version = "2.8.0" +description = "python code static checker" +category = "dev" optional = false -python-versions = ">=3.6" +python-versions = "~=3.6" -[package.extras] -crypto = ["cryptography (>=3.3.1,<4.0.0)"] -dev = ["sphinx", "sphinx-rtd-theme", "zope.interface", "cryptography (>=3.3.1,<4.0.0)", "pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)", "mypy", "pre-commit"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"] +[package.dependencies] +astroid = ">=2.5.4,<2.7" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +isort = ">=4.2.5,<6" +mccabe = ">=0.6,<0.7" +toml = ">=0.7.1" [[package]] name = "pymdown-extensions" @@ -580,11 +606,68 @@ toml = "*" [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.15.1" +description = "Pytest support for asyncio." +category = "dev" +optional = false +python-versions = ">= 3.6" + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +testing = ["coverage", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-forked" +version = "1.3.0" +description = "run tests in isolated forked subprocesses" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +py = "*" +pytest = ">=3.10" + +[[package]] +name = "pytest-mock" +version = "3.6.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "tox", "pytest-asyncio"] + +[[package]] +name = "pytest-xdist" +version = "2.2.1" +description = "pytest xdist plugin for distributed testing and loop-on-failing modes" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +execnet = ">=1.1" +pytest = ">=6.0.0" +pytest-forked = "*" + +[package.extras] +psutil = ["psutil (>=3.0)"] +testing = ["filelock"] + [[package]] name = "python-dotenv" version = "0.17.0" description = "Read key-value pairs from a .env file and set them as environment variables" -category = "main" +category = "dev" optional = false python-versions = "*" @@ -592,12 +675,15 @@ python-versions = "*" cli = ["click (>=5.0)"] [[package]] -name = "python-rapidjson" -version = "1.0" -description = "Python wrapper around rapidjson" +name = "python-multipart" +version = "0.0.5" +description = "A streaming multipart parser for Python" category = "main" optional = false -python-versions = ">=3.6" +python-versions = "*" + +[package.dependencies] +six = ">=1.4.0" [[package]] name = "pytz" @@ -611,7 +697,7 @@ python-versions = "*" name = "pyyaml" version = "5.4.1" description = "YAML parser and emitter for Python" -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" @@ -631,14 +717,6 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -[[package]] -name = "smmap" -version = "4.0.0" -description = "A pure Python implementation of a sliding window memory map manager" -category = "dev" -optional = false -python-versions = ">=3.5" - [[package]] name = "starlette" version = "0.13.6" @@ -650,18 +728,6 @@ python-versions = ">=3.6" [package.extras] full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"] -[[package]] -name = "stevedore" -version = "3.3.0" -description = "Manage dynamic plugins for Python applications" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -importlib-metadata = {version = ">=1.7.0", markers = "python_version < \"3.8\""} -pbr = ">=2.0.0,<2.1.0 || >2.1.0" - [[package]] name = "toml" version = "0.10.2" @@ -680,7 +746,7 @@ python-versions = ">= 3.5" [[package]] name = "tortoise-orm" -version = "0.17.1" +version = "0.17.2" description = "Easy async ORM for python, built with relations in mind" category = "main" optional = false @@ -714,7 +780,7 @@ telegram = ["requests"] [[package]] name = "typed-ast" -version = "1.4.2" +version = "1.4.3" description = "a fork of Python 2 and 3 ast modules with type comment support" category = "dev" optional = false @@ -738,61 +804,28 @@ python-versions = "*" [package.dependencies] click = ">=7.0.0,<8.0.0" -colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} h11 = ">=0.8" -httptools = {version = ">=0.1.0,<0.2.0", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} -python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} -PyYAML = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} typing-extensions = {version = "*", markers = "python_version < \"3.8\""} -uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} -watchgod = {version = ">=0.6", optional = true, markers = "extra == \"standard\""} -websockets = {version = ">=8.0.0,<9.0.0", optional = true, markers = "extra == \"standard\""} [package.extras] standard = ["websockets (>=8.0.0,<9.0.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "httptools (>=0.1.0,<0.2.0)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] [[package]] -name = "uvloop" -version = "0.15.2" -description = "Fast implementation of asyncio event loop on top of libuv" -category = "main" -optional = false -python-versions = ">=3.7" - -[package.extras] -dev = ["Cython (>=0.29.20,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=1.7.3,<1.8.0)", "sphinxcontrib-asyncio (>=0.2.0,<0.3.0)", "sphinx-rtd-theme (>=0.2.4,<0.3.0)", "aiohttp", "flake8 (>=3.8.4,<3.9.0)", "psutil", "pycodestyle (>=2.6.0,<2.7.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"] -docs = ["Sphinx (>=1.7.3,<1.8.0)", "sphinxcontrib-asyncio (>=0.2.0,<0.3.0)", "sphinx-rtd-theme (>=0.2.4,<0.3.0)"] -test = ["aiohttp", "flake8 (>=3.8.4,<3.9.0)", "psutil", "pycodestyle (>=2.6.0,<2.7.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"] - -[[package]] -name = "watchgod" -version = "0.7" -description = "Simple, modern file watching and code reload in python." -category = "main" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "wcwidth" -version = "0.2.5" -description = "Measures the displayed width of unicode strings in a terminal" -category = "main" +name = "verspec" +version = "0.1.0" +description = "Flexible version handling" +category = "dev" optional = false python-versions = "*" -[[package]] -name = "websockets" -version = "8.1" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -category = "main" -optional = false -python-versions = ">=3.6.1" +[package.extras] +test = ["coverage", "flake8 (>=3.7)", "mypy", "pretend", "pytest"] [[package]] -name = "xlsxwriter" -version = "1.3.8" -description = "A Python module for creating Excel XLSX files." -category = "main" +name = "wrapt" +version = "1.12.1" +description = "Module for decorators, wrappers and monkey patching." +category = "dev" optional = false python-versions = "*" @@ -809,25 +842,37 @@ docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [extras] -uvloop = ["uvloop"] +accel = [] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "1af6e8bd8573e90de396d6945e7a7e6a5bbe45f85fab5daa28c2351a5863b051" +content-hash = "0ee1758472eea9843534d97f4c51e301db2e8a3e6c5e993afcb024a0996d1e79" [metadata.files] +aiofiles = [ + {file = "aiofiles-0.6.0-py3-none-any.whl", hash = "sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27"}, + {file = "aiofiles-0.6.0.tar.gz", hash = "sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092"}, +] aiosqlite = [ {file = "aiosqlite-0.16.1-py3-none-any.whl", hash = "sha256:1df802815bb1e08a26c06d5ea9df589bcb8eec56e5f3378103b0f9b223c6703c"}, {file = "aiosqlite-0.16.1.tar.gz", hash = "sha256:2e915463164efa65b60fd1901aceca829b6090082f03082618afca6fb9c8fdf7"}, ] +apipkg = [ + {file = "apipkg-1.5-py2.py3-none-any.whl", hash = "sha256:58587dd4dc3daefad0487f6d9ae32b4542b185e1c36db6993290e7c41ca2b47c"}, + {file = "apipkg-1.5.tar.gz", hash = "sha256:37228cda29411948b422fae072f57e31d3396d2ee1c9783775980ee9c9990af6"}, +] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] +astroid = [ + {file = "astroid-2.5.5-py3-none-any.whl", hash = "sha256:b8823e7af8044b97b4b0c1bad06b980e4ce5519fee6d3d77ea5333a2b2d61d9a"}, + {file = "astroid-2.5.5.tar.gz", hash = "sha256:0f156ea0feec7fb78fd035d041a047b37f2aae9345d7f7960670bcd961182f77"}, +] asyncmy = [ - {file = "asyncmy-0.1.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:1934a7aab8dd3e944b031cbfbea1e2e3bcedea00783b20d3060501ef71547cec"}, - {file = "asyncmy-0.1.4.tar.gz", hash = "sha256:6ffa379555b4f40f6cf1d8324ffb1b456c901942d924bd14db373c30f3a54e09"}, + {file = "asyncmy-0.1.5-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:bfbf79b98277846f37a6952a8bd5bc3bc71fc416f969702d62d51d818f662b00"}, + {file = "asyncmy-0.1.5.tar.gz", hash = "sha256:921738ce6cd16ef6ec91d8b887268d5afbba8f460b80b3e4fd2bf37793f3035f"}, ] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, @@ -837,9 +882,9 @@ attrs = [ {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, ] -bandit = [ - {file = "bandit-1.7.0-py3-none-any.whl", hash = "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07"}, - {file = "bandit-1.7.0.tar.gz", hash = "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"}, +babel = [ + {file = "Babel-2.9.0-py2.py3-none-any.whl", hash = "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5"}, + {file = "Babel-2.9.0.tar.gz", hash = "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"}, ] bcrypt = [ {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6"}, @@ -851,8 +896,7 @@ bcrypt = [ {file = "bcrypt-3.2.0.tar.gz", hash = "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29"}, ] black = [ - {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, - {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, + {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] cffi = [ {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"}, @@ -901,46 +945,28 @@ colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] +execnet = [ + {file = "execnet-1.8.0-py2.py3-none-any.whl", hash = "sha256:7a13113028b1e1cc4c6492b28098b3c6576c9dccc7973bfe47b342afadafb2ac"}, + {file = "execnet-1.8.0.tar.gz", hash = "sha256:b73c5565e517f24b62dea8a5ceac178c661c4309d3aa0c3e420856c072c411b4"}, +] fastapi = [ {file = "fastapi-0.63.0-py3-none-any.whl", hash = "sha256:98d8ea9591d8512fdadf255d2a8fa56515cdd8624dca4af369da73727409508e"}, {file = "fastapi-0.63.0.tar.gz", hash = "sha256:63c4592f5ef3edf30afa9a44fa7c6b7ccb20e0d3f68cd9eba07b44d552058dcb"}, ] flake8 = [ - {file = "flake8-3.9.0-py2.py3-none-any.whl", hash = "sha256:12d05ab02614b6aee8df7c36b97d1a3b2372761222b19b58621355e82acddcff"}, - {file = "flake8-3.9.0.tar.gz", hash = "sha256:78873e372b12b093da7b5e5ed302e8ad9e988b38b063b61ad937f26ca58fc5f0"}, + {file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"}, + {file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"}, ] future = [ {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, ] -gitdb = [ - {file = "gitdb-4.0.7-py3-none-any.whl", hash = "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0"}, - {file = "gitdb-4.0.7.tar.gz", hash = "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"}, -] -gitpython = [ - {file = "GitPython-3.1.14-py3-none-any.whl", hash = "sha256:3283ae2fba31c913d857e12e5ba5f9a7772bbc064ae2bb09efafa71b0dd4939b"}, - {file = "GitPython-3.1.14.tar.gz", hash = "sha256:be27633e7509e58391f10207cd32b2a6cf5b908f92d9cd30da2e514e1137af61"}, -] h11 = [ {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, ] -httptools = [ - {file = "httptools-0.1.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce"}, - {file = "httptools-0.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4"}, - {file = "httptools-0.1.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6"}, - {file = "httptools-0.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c"}, - {file = "httptools-0.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a"}, - {file = "httptools-0.1.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f"}, - {file = "httptools-0.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2"}, - {file = "httptools-0.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009"}, - {file = "httptools-0.1.1-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437"}, - {file = "httptools-0.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d"}, - {file = "httptools-0.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be"}, - {file = "httptools-0.1.1.tar.gz", hash = "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce"}, -] importlib-metadata = [ - {file = "importlib_metadata-3.10.0-py3-none-any.whl", hash = "sha256:d2d46ef77ffc85cbf7dac7e81dd663fde71c45326131bea8033b9bad42268ebe"}, - {file = "importlib_metadata-3.10.0.tar.gz", hash = "sha256:c9db46394197244adf2f0b08ec5bc3cf16757e9590b02af1fca085c16c0d600a"}, + {file = "importlib_metadata-4.0.1-py3-none-any.whl", hash = "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d"}, + {file = "importlib_metadata-4.0.1.tar.gz", hash = "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -962,6 +988,30 @@ joblib = [ {file = "joblib-1.0.1-py3-none-any.whl", hash = "sha256:feeb1ec69c4d45129954f1b7034954241eedfd6ba39b5e9e4b6883be3332d5e5"}, {file = "joblib-1.0.1.tar.gz", hash = "sha256:9c17567692206d2f3fb9ecf5e991084254fe631665c450b443761c4186a613f7"}, ] +lazy-object-proxy = [ + {file = "lazy-object-proxy-1.6.0.tar.gz", hash = "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726"}, + {file = "lazy_object_proxy-1.6.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b"}, + {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win32.whl", hash = "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e"}, + {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win_amd64.whl", hash = "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93"}, + {file = "lazy_object_proxy-1.6.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741"}, + {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587"}, + {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4"}, + {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win32.whl", hash = "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f"}, + {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3"}, + {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981"}, + {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2"}, + {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win32.whl", hash = "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd"}, + {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837"}, + {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653"}, + {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3"}, + {file = "lazy_object_proxy-1.6.0-cp38-cp38-win32.whl", hash = "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8"}, + {file = "lazy_object_proxy-1.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf"}, + {file = "lazy_object_proxy-1.6.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad"}, + {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43"}, + {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a"}, + {file = "lazy_object_proxy-1.6.0-cp39-cp39-win32.whl", hash = "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61"}, + {file = "lazy_object_proxy-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b"}, +] livereload = [ {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, ] @@ -1031,46 +1081,66 @@ mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] +mike = [ + {file = "mike-1.0.0-py3-none-any.whl", hash = "sha256:521261cc1d72c07b995c0b80e0031c971d8839c9720c1a884203d9e4bdc6a47b"}, + {file = "mike-1.0.0.tar.gz", hash = "sha256:15819cef713366e58c85202a622871a0b62c85f227bc47e8525e17c7ad8198c6"}, +] mkdocs = [ {file = "mkdocs-1.1.2-py3-none-any.whl", hash = "sha256:096f52ff52c02c7e90332d2e53da862fde5c062086e1b5356a6e392d5d60f5e9"}, {file = "mkdocs-1.1.2.tar.gz", hash = "sha256:f0b61e5402b99d7789efa032c7a74c90a20220a9c81749da06dbfbcbd52ffb39"}, ] mkdocs-material = [ - {file = "mkdocs-material-7.1.0.tar.gz", hash = "sha256:1afaa5b174265eaa4a886f73187bb0e302a9596e9bfedb5aa2cb260d8b1d994e"}, - {file = "mkdocs_material-7.1.0-py2.py3-none-any.whl", hash = "sha256:13e73b3571d36f7e4a7dc11093323cff92095f4f219a00ba19c77a5e53aa6c55"}, + {file = "mkdocs-material-7.1.3.tar.gz", hash = "sha256:e34bba93ad1a0e6f9afc371f4ef55bedabbf13b9a786b013b0ce26ac55ec2932"}, + {file = "mkdocs_material-7.1.3-py2.py3-none-any.whl", hash = "sha256:437638b0de7a9113d7f1c9ddc93c0a29a3b808c71c3606713d8c1fa437697a3e"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, {file = "mkdocs_material_extensions-1.0.1-py3-none-any.whl", hash = "sha256:d90c807a88348aa6d1805657ec5c0b2d8d609c110e62b9dce4daf7fa981fa338"}, ] +mypy = [ + {file = "mypy-0.812-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49"}, + {file = "mypy-0.812-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c"}, + {file = "mypy-0.812-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521"}, + {file = "mypy-0.812-cp35-cp35m-win_amd64.whl", hash = "sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"}, + {file = "mypy-0.812-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a"}, + {file = "mypy-0.812-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c"}, + {file = "mypy-0.812-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6"}, + {file = "mypy-0.812-cp36-cp36m-win_amd64.whl", hash = "sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064"}, + {file = "mypy-0.812-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56"}, + {file = "mypy-0.812-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8"}, + {file = "mypy-0.812-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7"}, + {file = "mypy-0.812-cp37-cp37m-win_amd64.whl", hash = "sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564"}, + {file = "mypy-0.812-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506"}, + {file = "mypy-0.812-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5"}, + {file = "mypy-0.812-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66"}, + {file = "mypy-0.812-cp38-cp38-win_amd64.whl", hash = "sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e"}, + {file = "mypy-0.812-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a"}, + {file = "mypy-0.812-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a"}, + {file = "mypy-0.812-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97"}, + {file = "mypy-0.812-cp39-cp39-win_amd64.whl", hash = "sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df"}, + {file = "mypy-0.812-py3-none-any.whl", hash = "sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4"}, + {file = "mypy-0.812.tar.gz", hash = "sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] nltk = [ - {file = "nltk-3.6.1-py3-none-any.whl", hash = "sha256:1235660f52ab10fda34d5277096724747f767b2903e1c0c4e14bde013552c9ba"}, - {file = "nltk-3.6.1.zip", hash = "sha256:cbc2ed576998fcf7cd181eeb3ca029e5f0025b264074b4beb57ce780673f8b86"}, + {file = "nltk-3.6.2-py3-none-any.whl", hash = "sha256:240e23ab1ab159ef9940777d30c7c72d7e76d91877099218a7585370c11f6b9e"}, + {file = "nltk-3.6.2.zip", hash = "sha256:57d556abed621ab9be225cc6d2df1edce17572efb67a3d754630c9f8381503eb"}, ] packaging = [ {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, ] -passlib = [ - {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, - {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, -] pathspec = [ {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, ] -pbr = [ - {file = "pbr-5.5.1-py2.py3-none-any.whl", hash = "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00"}, - {file = "pbr-5.5.1.tar.gz", hash = "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9"}, -] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] -prompt-toolkit = [ - {file = "prompt_toolkit-3.0.18-py3-none-any.whl", hash = "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04"}, - {file = "prompt_toolkit-3.0.18.tar.gz", hash = "sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc"}, -] py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, @@ -1115,9 +1185,9 @@ pygments = [ {file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"}, {file = "Pygments-2.8.1.tar.gz", hash = "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94"}, ] -pyjwt = [ - {file = "PyJWT-2.0.1-py3-none-any.whl", hash = "sha256:b70b15f89dc69b993d8a8d32c299032d5355c82f9b5b7e851d1a6d706dffe847"}, - {file = "PyJWT-2.0.1.tar.gz", hash = "sha256:a5c70a06e1f33d81ef25eecd50d50bd30e34de1ca8b2b9fa3fe0daaabcf69bf7"}, +pylint = [ + {file = "pylint-2.8.0-py3-none-any.whl", hash = "sha256:a01cd675eccf6e25b3bdb42be184eb46aaf89187d612ba0fb5f93328ed6b0fd5"}, + {file = "pylint-2.8.0.tar.gz", hash = "sha256:082a6d461b54f90eea49ca90fff4ee8b6e45e8029e5dbd72f6107ef84f3779c0"}, ] pymdown-extensions = [ {file = "pymdown-extensions-8.1.1.tar.gz", hash = "sha256:632371fa3bf1b21a0e3f4063010da59b41db049f261f4c0b0872069a9b6d1735"}, @@ -1135,40 +1205,28 @@ pytest = [ {file = "pytest-6.2.3-py3-none-any.whl", hash = "sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"}, {file = "pytest-6.2.3.tar.gz", hash = "sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634"}, ] +pytest-asyncio = [ + {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, + {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, +] +pytest-forked = [ + {file = "pytest-forked-1.3.0.tar.gz", hash = "sha256:6aa9ac7e00ad1a539c41bec6d21011332de671e938c7637378ec9710204e37ca"}, + {file = "pytest_forked-1.3.0-py2.py3-none-any.whl", hash = "sha256:dc4147784048e70ef5d437951728825a131b81714b398d5d52f17c7c144d8815"}, +] +pytest-mock = [ + {file = "pytest-mock-3.6.0.tar.gz", hash = "sha256:f7c3d42d6287f4e45846c8231c31902b6fa2bea98735af413a43da4cf5b727f1"}, + {file = "pytest_mock-3.6.0-py3-none-any.whl", hash = "sha256:952139a535b5b48ac0bb2f90b5dd36b67c7e1ba92601f3a8012678c4bd7f0bcc"}, +] +pytest-xdist = [ + {file = "pytest-xdist-2.2.1.tar.gz", hash = "sha256:718887296892f92683f6a51f25a3ae584993b06f7076ce1e1fd482e59a8220a2"}, + {file = "pytest_xdist-2.2.1-py3-none-any.whl", hash = "sha256:2447a1592ab41745955fb870ac7023026f20a5f0bfccf1b52a879bd193d46450"}, +] python-dotenv = [ {file = "python-dotenv-0.17.0.tar.gz", hash = "sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a"}, {file = "python_dotenv-0.17.0-py2.py3-none-any.whl", hash = "sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2"}, ] -python-rapidjson = [ - {file = "python-rapidjson-1.0.tar.gz", hash = "sha256:a61fa61e41b0b85ba9e78444242fddcb3be724de1df79314e6b4766b66e4e11c"}, - {file = "python_rapidjson-1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ac728d59984e556ed56e59f497da4281bf96eb84bd035a866621dcfb90656ce5"}, - {file = "python_rapidjson-1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:139faed875bd794dba93a051038e450b52d0988a6c0b07cc1aea49627176bd4b"}, - {file = "python_rapidjson-1.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:52c663fd947438b640fca0a7d1b51b0f21de550f0e5c3d649f8cac0090ca4898"}, - {file = "python_rapidjson-1.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:2d73bae63c0b41c6d78ff983a9e97bd22d745b49c96a52edc5e6a3ce31cd4c5a"}, - {file = "python_rapidjson-1.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:eba0f11e39b5a3819c92f7c8c02a238d8784aa04fbce48233025712d5d32da89"}, - {file = "python_rapidjson-1.0-cp36-cp36m-win32.whl", hash = "sha256:c995c3e89f77baf72b814e941e355bbc0bb146b14c8cf75ca30f0f7788533118"}, - {file = "python_rapidjson-1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7be23a005e6424ee4336faba44f397b116aefe8ab7e3f3e33e2f4a7693469b0c"}, - {file = "python_rapidjson-1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:67a3487721f3a3bc1455eaf83e4e1bc729ad5d502f4b40b8a70f8893bbb0d6c6"}, - {file = "python_rapidjson-1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bb53b859c62a5b123495603319363b9a9a4a43d7823e1edaa72738827251b47f"}, - {file = "python_rapidjson-1.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:95d16d27a11c0c79cdf1b708221fe462abe98f7a3832a0b7c4e066522e597062"}, - {file = "python_rapidjson-1.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:72cc744212070768903417948cdc8e0432d52bcc2fcebd7ee3df9cb98fd37053"}, - {file = "python_rapidjson-1.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:2b08623e4069b1c71fa45fa95eca094f190e76fb176ac38a3682a0f2fcfe1752"}, - {file = "python_rapidjson-1.0-cp37-cp37m-win32.whl", hash = "sha256:35a908a129bd14fd4083784fe2db23368df59cdcbfa78f1f7d196665f6c40efe"}, - {file = "python_rapidjson-1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:70ba16319063098a034d6bc601f7ad356631a1fd550827e4b239805d38cbd455"}, - {file = "python_rapidjson-1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:72a22106bd49212d28f5565760019b0f5a449972bb85e5faf35f0e7760578f42"}, - {file = "python_rapidjson-1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:48da6c391e636a93989eb4994f81aad2915375763909e222896ee8b80600a812"}, - {file = "python_rapidjson-1.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7e385ed2838511acbde8a2ba362fe6aedf5a1cb1365ecc30f19f99da20280dfb"}, - {file = "python_rapidjson-1.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:88242ac09c42780c88ad348f35be83b688e594961ef1d8d75943cbca8ff23492"}, - {file = "python_rapidjson-1.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:3cf70741471e58983d29cfcb3b37a30bfe947519973fc206958d30d5a3504ae2"}, - {file = "python_rapidjson-1.0-cp38-cp38-win32.whl", hash = "sha256:fb158c3e53df83669e6dfd34fcc17edc2fdda9d466165d9798af06a7b5b878f3"}, - {file = "python_rapidjson-1.0-cp38-cp38-win_amd64.whl", hash = "sha256:cbc05e61d7e0754741382c747b3fd8af8267a5dac1d0e6bf6c3b611584373ac1"}, - {file = "python_rapidjson-1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4446c481bf469ae4c22d97c358b95091eb528612dac0cf15e50e112a04b22ded"}, - {file = "python_rapidjson-1.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:eb249674ddf238ea12ff84efc5b3ec868174dd54419ec2ad51601a834716757d"}, - {file = "python_rapidjson-1.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:987eba5e8b647c4f7e4249bdec870ab57d2b70b58d9e23b530c1047bd17c58ba"}, - {file = "python_rapidjson-1.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:49dae9df02fdabdbcbd6651a98a569b6e6c4ffdc2c7d2af7be805ff40005be03"}, - {file = "python_rapidjson-1.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:df72847d0c44969c9c49e3bd9df77a7580f43875427d865edfafd344901c22a0"}, - {file = "python_rapidjson-1.0-cp39-cp39-win32.whl", hash = "sha256:c446153975c5d9467fcdb8ea8545a7d2a991e0693fa84e38865eec821cc66584"}, - {file = "python_rapidjson-1.0-cp39-cp39-win_amd64.whl", hash = "sha256:00b7679ed075e4353beb2a728743d24096942faa008065ea4fd242dc91dc496b"}, +python-multipart = [ + {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, ] pytz = [ {file = "pytz-2020.5-py2.py3-none-any.whl", hash = "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4"}, @@ -1181,26 +1239,18 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, @@ -1252,18 +1302,10 @@ six = [ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, ] -smmap = [ - {file = "smmap-4.0.0-py2.py3-none-any.whl", hash = "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2"}, - {file = "smmap-4.0.0.tar.gz", hash = "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182"}, -] starlette = [ {file = "starlette-0.13.6-py3-none-any.whl", hash = "sha256:bd2ffe5e37fb75d014728511f8e68ebf2c80b0fa3d04ca1479f4dc752ae31ac9"}, {file = "starlette-0.13.6.tar.gz", hash = "sha256:ebe8ee08d9be96a3c9f31b2cb2a24dbdf845247b745664bd8a3f9bd0c977fdbc"}, ] -stevedore = [ - {file = "stevedore-3.3.0-py3-none-any.whl", hash = "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"}, - {file = "stevedore-3.3.0.tar.gz", hash = "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee"}, -] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, @@ -1312,44 +1354,44 @@ tornado = [ {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"}, ] tortoise-orm = [ - {file = "tortoise-orm-0.17.1.tar.gz", hash = "sha256:07816a575a1cdf31f212042f4ec399028540cd543f85ad0577531eccb5dc9453"}, - {file = "tortoise_orm-0.17.1-py3-none-any.whl", hash = "sha256:34eeca18d8266806f5c1028e5fd94e46d07554ea615927d660da4a266801c8e8"}, + {file = "tortoise-orm-0.17.2.tar.gz", hash = "sha256:1a742b2f15a31d47a8dea7706b478cc9a7ce9af268b61d77d0fa22cfbaea271a"}, + {file = "tortoise_orm-0.17.2-py3-none-any.whl", hash = "sha256:b0c02be3800398053058377ddca91fa051eb98eebb704d2db2a3ab1c6a58e347"}, ] tqdm = [ {file = "tqdm-4.60.0-py2.py3-none-any.whl", hash = "sha256:daec693491c52e9498632dfbe9ccfc4882a557f5fa08982db1b4d3adbe0887c3"}, {file = "tqdm-4.60.0.tar.gz", hash = "sha256:ebdebdb95e3477ceea267decfc0784859aa3df3e27e22d23b83e9b272bf157ae"}, ] typed-ast = [ - {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"}, - {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"}, - {file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"}, - {file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"}, - {file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"}, - {file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"}, - {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"}, - {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"}, - {file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"}, - {file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"}, - {file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"}, - {file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"}, - {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"}, - {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"}, - {file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"}, - {file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"}, - {file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"}, - {file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"}, - {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"}, - {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"}, - {file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"}, - {file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"}, - {file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"}, - {file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"}, - {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"}, - {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"}, - {file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"}, - {file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"}, - {file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"}, - {file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"}, + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, + {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, + {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, + {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, + {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, + {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, + {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, + {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, + {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, + {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, + {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, + {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, + {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, + {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, + {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, + {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, ] typing-extensions = [ {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, @@ -1360,53 +1402,12 @@ uvicorn = [ {file = "uvicorn-0.13.4-py3-none-any.whl", hash = "sha256:7587f7b08bd1efd2b9bad809a3d333e972f1d11af8a5e52a9371ee3a5de71524"}, {file = "uvicorn-0.13.4.tar.gz", hash = "sha256:3292251b3c7978e8e4a7868f4baf7f7f7bb7e40c759ecc125c37e99cdea34202"}, ] -uvloop = [ - {file = "uvloop-0.15.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:19fa1d56c91341318ac5d417e7b61c56e9a41183946cc70c411341173de02c69"}, - {file = "uvloop-0.15.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:e5e5f855c9bf483ee6cd1eb9a179b740de80cb0ae2988e3fa22309b78e2ea0e7"}, - {file = "uvloop-0.15.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:42eda9f525a208fbc4f7cecd00fa15c57cc57646c76632b3ba2fe005004f051d"}, - {file = "uvloop-0.15.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:90e56f17755e41b425ad19a08c41dc358fa7bf1226c0f8e54d4d02d556f7af7c"}, - {file = "uvloop-0.15.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:7ae39b11a5f4cec1432d706c21ecc62f9e04d116883178b09671aa29c46f7a47"}, - {file = "uvloop-0.15.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:b45218c99795803fb8bdbc9435ff7f54e3a591b44cd4c121b02fa83affb61c7c"}, - {file = "uvloop-0.15.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:114543c84e95df1b4ff546e6e3a27521580466a30127f12172a3278172ad68bc"}, - {file = "uvloop-0.15.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44cac8575bf168601424302045234d74e3561fbdbac39b2b54cc1d1d00b70760"}, - {file = "uvloop-0.15.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:6de130d0cb78985a5d080e323b86c5ecaf3af82f4890492c05981707852f983c"}, - {file = "uvloop-0.15.2.tar.gz", hash = "sha256:2bb0624a8a70834e54dde8feed62ed63b50bad7a1265c40d6403a2ac447bce01"}, +verspec = [ + {file = "verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31"}, + {file = "verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e"}, ] -watchgod = [ - {file = "watchgod-0.7-py3-none-any.whl", hash = "sha256:d6c1ea21df37847ac0537ca0d6c2f4cdf513562e95f77bb93abbcf05573407b7"}, - {file = "watchgod-0.7.tar.gz", hash = "sha256:48140d62b0ebe9dd9cf8381337f06351e1f2e70b2203fa9c6eff4e572ca84f29"}, -] -wcwidth = [ - {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, - {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, -] -websockets = [ - {file = "websockets-8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c"}, - {file = "websockets-8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170"}, - {file = "websockets-8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8"}, - {file = "websockets-8.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb"}, - {file = "websockets-8.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5"}, - {file = "websockets-8.1-cp36-cp36m-win32.whl", hash = "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a"}, - {file = "websockets-8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5"}, - {file = "websockets-8.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989"}, - {file = "websockets-8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d"}, - {file = "websockets-8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779"}, - {file = "websockets-8.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8"}, - {file = "websockets-8.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422"}, - {file = "websockets-8.1-cp37-cp37m-win32.whl", hash = "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc"}, - {file = "websockets-8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308"}, - {file = "websockets-8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092"}, - {file = "websockets-8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485"}, - {file = "websockets-8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1"}, - {file = "websockets-8.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55"}, - {file = "websockets-8.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824"}, - {file = "websockets-8.1-cp38-cp38-win32.whl", hash = "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36"}, - {file = "websockets-8.1-cp38-cp38-win_amd64.whl", hash = "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"}, - {file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"}, -] -xlsxwriter = [ - {file = "XlsxWriter-1.3.8-py2.py3-none-any.whl", hash = "sha256:30ebc19d0f201fafa34a6c622050ed2a268ac8dee24037a61605caa801dc8af5"}, - {file = "XlsxWriter-1.3.8.tar.gz", hash = "sha256:2b7e22b1268c2ed85d73e5629097c9a63357f2429667ada9863cd05ff8ee33aa"}, +wrapt = [ + {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, ] zipp = [ {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, diff --git a/pyproject.toml b/pyproject.toml index 170678d..66e5041 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] 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"]