From 4e70174c850870690cef61e59e5393aac39ca1db Mon Sep 17 00:00:00 2001 From: long2ice Date: Wed, 15 Apr 2020 18:59:41 +0800 Subject: [PATCH] add builtin permissions remove orjson fix tortoise error --- examples/enums.py | 2 +- examples/main.py | 17 ++++- examples/models.py | 11 ++- fastapi_admin/cli.py | 105 +++++++++++++++++++++++++++ fastapi_admin/common.py | 6 +- fastapi_admin/depends.py | 42 ++++++++++- fastapi_admin/enum.py | 8 -- fastapi_admin/enums.py | 25 +++++++ fastapi_admin/exceptions.py | 10 +++ fastapi_admin/factory.py | 42 ++++++----- fastapi_admin/models.py | 21 ++++++ fastapi_admin/routes/index.py | 36 +++++---- fastapi_admin/routes/login.py | 20 ++--- fastapi_admin/routes/site.py | 23 +++++- front/src/components/FormBuilder.vue | 2 - front/src/components/Sidebar.vue | 3 +- front/src/storage.js | 6 +- front/src/views/CustomTable.vue | 2 + poetry.lock | 90 +++++++++++------------ pyproject.toml | 3 +- requirements.txt | 6 +- setup.py | 3 + 22 files changed, 362 insertions(+), 121 deletions(-) create mode 100644 fastapi_admin/cli.py delete mode 100644 fastapi_admin/enum.py create mode 100644 fastapi_admin/enums.py create mode 100644 fastapi_admin/exceptions.py 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 @@