diff --git a/examples/enums.py b/examples/enums.py
index e1d1a4d..c51e2cb 100644
--- a/examples/enums.py
+++ b/examples/enums.py
@@ -1,6 +1,6 @@
from enum import IntEnum
-from fastapi_admin.enum import EnumMixin
+from fastapi_admin.enums import EnumMixin
class ProductType(EnumMixin, IntEnum):
diff --git a/examples/main.py b/examples/main.py
index f90fdef..ee155a4 100644
--- a/examples/main.py
+++ b/examples/main.py
@@ -38,12 +38,14 @@ def create_app():
fast_app = FastAPI(debug=True)
register_tortoise(fast_app, config=TORTOISE_ORM, generate_schemas=True)
- fast_app.mount('/admin', admin_app)
+ fast_app.mount('/admin', admin_app)
+ admin_app.debug = True
admin_app.init(
user_model='User',
admin_secret='test',
models='examples.models',
+ permission=True,
site=Site(
name='FastAPI-admin Demo',
logo='https://github.com/long2ice/fastapi-admin/raw/master/front/static/img/logo.png',
@@ -101,12 +103,19 @@ def create_app():
name='User',
url='/rest/User',
icon='fa fa-user',
- fields_type={
- 'avatar': 'image'
- },
exclude=('password',),
search_fields=('username',)
),
+ Menu(
+ name='Role',
+ url='/rest/Role',
+ icon='fa fa-group'
+ ),
+ Menu(
+ name='Permission',
+ url='/rest/Permission',
+ icon='fa fa-copyright'
+ ),
Menu(
name='Logout',
url='/logout',
diff --git a/examples/models.py b/examples/models.py
index f5eae89..71702d1 100644
--- a/examples/models.py
+++ b/examples/models.py
@@ -1,14 +1,17 @@
+import datetime
+
from tortoise import fields, Model
-from fastapi_admin.models import User as AdminUser
+from fastapi_admin.models import User as AdminUser, Permission, Role
from .enums import ProductType
class User(AdminUser, Model):
- last_login = fields.DatetimeField(description='Last Login')
+ last_login = fields.DatetimeField(description='Last Login', default=datetime.datetime.now)
is_active = fields.BooleanField(default=True, description='Is Active')
- avatar = fields.CharField(max_length=200)
- intro = fields.TextField()
+ is_superuser = fields.BooleanField(default=False, description='Is SuperUser')
+ avatar = fields.CharField(max_length=200, default='')
+ intro = fields.TextField(default='')
created_at = fields.DatetimeField(auto_now_add=True)
def __str__(self):
diff --git a/fastapi_admin/cli.py b/fastapi_admin/cli.py
new file mode 100644
index 0000000..fcddd39
--- /dev/null
+++ b/fastapi_admin/cli.py
@@ -0,0 +1,105 @@
+import argparse
+import importlib
+
+from colorama import Fore, init
+from prompt_toolkit import PromptSession
+from tortoise import Tortoise, run_async
+
+from fastapi_admin import enums
+from fastapi_admin.common import pwd_context
+from fastapi_admin.models import Permission
+
+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)
+
+
+def import_obj(path):
+ module_name, class_name = path.rsplit('.', 1)
+ return getattr(importlib.import_module(module_name), class_name)
+
+
+async def init_tortoise(args):
+ await Tortoise.init(config=import_obj(args.config))
+
+
+async def register_permissions(args):
+ await init_tortoise(args)
+ if args.clean:
+ await Permission.all().delete()
+ Logger.waring('Cleaned all permissions success.')
+ models = Tortoise.apps.get('models').keys()
+ models = list(models)
+ for model in models:
+ for action in enums.PermissionAction:
+ label = f'{enums.PermissionAction.choices().get(action)} {model}'
+ defaults = dict(
+ label=label,
+ model=model,
+ action=action,
+ )
+ _, created = await Permission.get_or_create(
+ **defaults,
+ )
+ if created:
+ Logger.success(f'Create permission {label} success.')
+
+
+async def createsuperuser(args):
+ await init_tortoise(args)
+
+ user_model = import_obj(args.user_model)
+ 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(f'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_register_permissions = subparsers.add_parser('register_permissions')
+ parser_register_permissions.add_argument('--clean', required=False, action='store_true',
+ help='Clean up old permissions then renew.')
+ parser_register_permissions.set_defaults(func=register_permissions)
+
+ parser_createsuperuser = subparsers.add_parser('createsuperuser')
+ parser_createsuperuser.add_argument('--user-model', required=True,
+ help='User model import path,like examples.models.User.')
+ parser_createsuperuser.set_defaults(func=createsuperuser)
+
+ parse_args = parser.parse_args()
+ run_async(parse_args.func(parse_args))
+
+
+if __name__ == '__main__':
+ cli()
diff --git a/fastapi_admin/common.py b/fastapi_admin/common.py
index 23e4bc5..c69672c 100644
--- a/fastapi_admin/common.py
+++ b/fastapi_admin/common.py
@@ -1,5 +1,9 @@
from copy import deepcopy
+from passlib.context import CryptContext
+
+pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
+
async def handle_m2m_fields_create_or_update(body, m2m_fields, model, create=True, pk=None):
"""
@@ -25,7 +29,7 @@ async def handle_m2m_fields_create_or_update(body, m2m_fields, model, create=Tru
m2m_related = getattr(obj, k)
if not create:
await m2m_related.clear()
- m2m_model = m2m_related.model
+ m2m_model = m2m_related.remote_model
m2m_objs = await m2m_model.filter(pk__in=v)
await m2m_related.add(*m2m_objs)
return obj
diff --git a/fastapi_admin/depends.py b/fastapi_admin/depends.py
index 78807fc..755e701 100644
--- a/fastapi_admin/depends.py
+++ b/fastapi_admin/depends.py
@@ -1,10 +1,14 @@
+import json
+
import jwt
-import orjson
from fastapi import Query, Path, Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from fastapi.security.utils import get_authorization_scheme_param
from pydantic import BaseModel
from starlette.requests import Request
-from starlette.status import HTTP_401_UNAUTHORIZED
+from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND
+
+from . import enums
from .factory import app
auth_schema = HTTPBearer()
@@ -38,7 +42,7 @@ class QueryItem(BaseModel):
def get_query(query=Query(...)):
- query = orjson.loads(query)
+ query = json.loads(query)
return QueryItem.parse_obj(query)
@@ -57,3 +61,35 @@ async def parse_body(request: Request, resource: str = Path(...)):
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, detail='User is not Active!')
+ 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, detail='No Permission!')
+
+
+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)
diff --git a/fastapi_admin/enum.py b/fastapi_admin/enum.py
deleted file mode 100644
index 15b4199..0000000
--- a/fastapi_admin/enum.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import abc
-
-
-class EnumMixin:
- @classmethod
- @abc.abstractmethod
- def choices(cls):
- pass
diff --git a/fastapi_admin/enums.py b/fastapi_admin/enums.py
new file mode 100644
index 0000000..7b1a5a6
--- /dev/null
+++ b/fastapi_admin/enums.py
@@ -0,0 +1,25 @@
+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
new file mode 100644
index 0000000..846e19d
--- /dev/null
+++ b/fastapi_admin/exceptions.py
@@ -0,0 +1,10 @@
+from starlette.requests import Request
+from fastapi.exceptions import HTTPException
+from starlette.responses import UJSONResponse
+
+
+async def exception_handler(request: Request, exc: HTTPException):
+ return UJSONResponse(
+ status_code=exc.status_code,
+ content={'message': exc.detail},
+ )
diff --git a/fastapi_admin/factory.py b/fastapi_admin/factory.py
index 642a07f..8e40a63 100644
--- a/fastapi_admin/factory.py
+++ b/fastapi_admin/factory.py
@@ -1,30 +1,35 @@
import importlib
from typing import Type, List, Dict
-from fastapi import FastAPI
-from tortoise import Model, Tortoise
+from fastapi import FastAPI, HTTPException
+from tortoise import Model
+from tortoise.fields import IntField, BooleanField, DatetimeField, DateField
+from tortoise.fields.data import IntEnumFieldInstance, CharEnumFieldInstance, DecimalField, FloatField, TextField, \
+ SmallIntField, JSONField
+from .exceptions import exception_handler
from .site import Site, Resource, Field, Menu
class AdminApp(FastAPI):
models: str
admin_secret: str
- user_model: str
+ user_model: Model
site: Site
+ permission: 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',
+ 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] = {}
@@ -35,16 +40,18 @@ class AdminApp(FastAPI):
else:
self.model_menu_mapping[menu.url.split('?')[0].split('/')[-1]] = menu
- def init(self, site: Site, user_model: str, admin_secret: str, models: str, ):
+ def init(self, site: Site, user_model: str, admin_secret: str, models: str, permission: bool = False):
"""
init admin site
+ :param permission: active builtin permission
+ :param models:
:param site:
:param user_model: admin user model path,like admin.models.user
:param admin_secret: admin jwt secret.
- :param models: tortoise models
:return:
"""
self.site = site
+ self.permission = permission
self.admin_secret = admin_secret
self.models = importlib.import_module(models)
self.user_model = getattr(self.models, user_model)
@@ -160,7 +167,7 @@ class AdminApp(FastAPI):
name = m2m_field.get('name')
if not self._exclude_field(resource, name):
label = m2m_field.get('description') or name.title()
- m2m_model_class = model._meta.fields_map[name].model
+ 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(
@@ -190,3 +197,4 @@ class AdminApp(FastAPI):
app = AdminApp(
openapi_prefix='/admin',
)
+app.add_exception_handler(HTTPException, exception_handler)
diff --git a/fastapi_admin/models.py b/fastapi_admin/models.py
index 89a0a85..26e2466 100644
--- a/fastapi_admin/models.py
+++ b/fastapi_admin/models.py
@@ -1,5 +1,7 @@
from tortoise import Model, fields
+from fastapi_admin import enums
+
class User(Model):
username = fields.CharField(max_length=20, unique=True)
@@ -7,3 +9,22 @@ class User(Model):
class Meta:
abstract = True
+
+
+class Permission(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 Role(Model):
+ label = fields.CharField(max_length=50)
+ users = fields.ManyToManyField('models.User')
+
+ permissions: fields.ManyToManyRelation[Permission] = fields.ManyToManyField('models.Permission')
+
+ def __str__(self):
+ return self.label
diff --git a/fastapi_admin/routes/index.py b/fastapi_admin/routes/index.py
index 5c48239..5901eff 100644
--- a/fastapi_admin/routes/index.py
+++ b/fastapi_admin/routes/index.py
@@ -1,5 +1,5 @@
from fastapi import Depends, APIRouter
-from fastapi.responses import ORJSONResponse
+from fastapi.responses import UJSONResponse
from starlette.status import HTTP_409_CONFLICT
from tortoise import Model
from tortoise.contrib.pydantic import pydantic_model_creator
@@ -7,7 +7,8 @@ from tortoise.exceptions import IntegrityError
from tortoise.fields import ManyToManyRelation
from ..common import handle_m2m_fields_create_or_update
-from ..depends import QueryItem, get_query, parse_body, get_model
+from ..depends import QueryItem, get_query, parse_body, get_model, read_checker, delete_checker, update_checker, \
+ create_checker
from ..factory import app
from ..responses import GetManyOut
from ..schemas import BulkIn
@@ -17,7 +18,8 @@ router = APIRouter()
@router.get(
- '/{resource}'
+ '/{resource}',
+ dependencies=[Depends(read_checker)]
)
async def get_resource(
resource: str,
@@ -45,7 +47,8 @@ async def get_resource(
@router.get(
- '/{resource}/form'
+ '/{resource}/form',
+ dependencies=[Depends(read_checker)]
)
async def form(
resource: str,
@@ -55,7 +58,8 @@ async def form(
@router.get(
- '/{resource}/grid'
+ '/{resource}/grid',
+ dependencies=[Depends(read_checker)]
)
async def grid(
resource: str,
@@ -65,7 +69,8 @@ async def grid(
@router.get(
- '/{resource}/view'
+ '/{resource}/view',
+ dependencies=[Depends(read_checker)]
)
async def view(
resource: str,
@@ -75,7 +80,8 @@ async def view(
@router.post(
- '/{resource}/bulk/delete'
+ '/{resource}/bulk/delete',
+ dependencies=[Depends(delete_checker)]
)
async def bulk_delete(
bulk_in: BulkIn,
@@ -86,7 +92,8 @@ async def bulk_delete(
@router.delete(
- '/{resource}/{id}'
+ '/{resource}/{id}',
+ dependencies=[Depends(delete_checker)]
)
async def delete_one(
id: int,
@@ -97,7 +104,8 @@ async def delete_one(
@router.put(
- '/{resource}/{id}'
+ '/{resource}/{id}',
+ dependencies=[Depends(update_checker)]
)
async def update_one(
id: int,
@@ -109,7 +117,7 @@ async def update_one(
try:
obj = await handle_m2m_fields_create_or_update(body, m2m_fields, model, False, id)
except IntegrityError as e:
- return ORJSONResponse(status_code=HTTP_409_CONFLICT, content=dict(
+ return UJSONResponse(status_code=HTTP_409_CONFLICT, content=dict(
message=f'Update Error,{e}'
))
creator = pydantic_model_creator(model, include=resource_fields, exclude=m2m_fields)
@@ -117,7 +125,8 @@ async def update_one(
@router.post(
- '/{resource}'
+ '/{resource}',
+ dependencies=[Depends(create_checker)]
)
async def create_one(
parsed=Depends(parse_body),
@@ -129,14 +138,15 @@ async def create_one(
try:
obj = await handle_m2m_fields_create_or_update(body, m2m_fields, model)
except IntegrityError as e:
- return ORJSONResponse(status_code=HTTP_409_CONFLICT, content=dict(
+ return UJSONResponse(status_code=HTTP_409_CONFLICT, content=dict(
message=f'Create Error,{e}'
))
return creator.from_orm(obj).dict()
@router.get(
- '/{resource}/{id}'
+ '/{resource}/{id}',
+ dependencies=[Depends(read_checker)]
)
async def get_one(
id: int,
diff --git a/fastapi_admin/routes/login.py b/fastapi_admin/routes/login.py
index d39f5b0..08414c4 100644
--- a/fastapi_admin/routes/login.py
+++ b/fastapi_admin/routes/login.py
@@ -1,15 +1,12 @@
import jwt
-from fastapi import APIRouter
-from fastapi.responses import ORJSONResponse
-from passlib.context import CryptContext
+from fastapi import APIRouter, HTTPException
from starlette.status import HTTP_403_FORBIDDEN
+from ..common import pwd_context
from ..factory import app
from ..schemas import LoginIn
from ..shortcuts import get_object_or_404
-pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
-
router = APIRouter()
@@ -21,13 +18,16 @@ async def login(
):
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):
- return ORJSONResponse(status_code=HTTP_403_FORBIDDEN, content={
- 'message': 'Incorrect Password'
- })
- return {
+ raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail='Incorrect Password!')
+ ret = {
'user': {
- 'username': user.username
+ 'username': user.username,
+ 'is_superuser': user.is_superuser,
+
},
'token': jwt.encode({'user_id': user.pk}, app.admin_secret, algorithm='HS256')
}
+ return ret
diff --git a/fastapi_admin/routes/site.py b/fastapi_admin/routes/site.py
index b0e6123..60e4f77 100644
--- a/fastapi_admin/routes/site.py
+++ b/fastapi_admin/routes/site.py
@@ -1,5 +1,8 @@
-from fastapi import APIRouter
+from copy import deepcopy
+from fastapi import APIRouter, Depends
+
+from ..depends import get_current_user
from ..factory import app
router = APIRouter()
@@ -17,5 +20,19 @@ async def home():
@router.get(
'/site',
)
-async def site():
- return app.site.dict(by_alias=True, exclude_unset=True)
+async def site(
+ user=Depends(get_current_user)
+):
+ site_ = app.site
+ if app.permission and not user.is_superuser:
+ site_ = deepcopy(site_)
+ await user.fetch_related('roles')
+ for menu in site_.menus:
+ has_permission = False
+ if not has_permission:
+ for role in user.roles:
+ if await role.permissions.filter(model=menu.name):
+ has_permission = True
+ if not has_permission:
+ site_.menus.remove(menu)
+ return site_.dict(by_alias=True, exclude_unset=True)
diff --git a/front/src/components/FormBuilder.vue b/front/src/components/FormBuilder.vue
index 49367ea..b4aee1b 100644
--- a/front/src/components/FormBuilder.vue
+++ b/front/src/components/FormBuilder.vue
@@ -328,9 +328,7 @@ export default {
return this.onSubmit(this.model);
}
const methodName = String(this.method).toLowerCase();
- // console.log(this.$refs.form);
let formData = this.model;
- console.log(formData,)
if (this.useFormData) {
formData = new FormData();
_.mapValues(this.model, (v, k) => formData.append(k, v));
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index 79cd958..4935e91 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -5,8 +5,7 @@