error pages

This commit is contained in:
long2ice
2021-05-05 14:46:26 +08:00
parent 3935adba00
commit 473faac93b
31 changed files with 390 additions and 246 deletions

View File

@ -43,6 +43,20 @@ Or pro version online demo [here](https://fastapi-admin-pro.long2ice.cn/admin/lo
## Run examples in local ## Run examples in local
1. Clone repo.
2. Create `.env` file.
```dotenv
DATABASE_URL=mysql://root:123456@127.0.0.1:3306/fastapi-admin
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
```
3. Run `docker-compose up -d --build`.
4. Visit <http://localhost:8000/admin/init> to create first admin.
## Documentation ## Documentation
See documentation at [https://fastapi-admin.github.io/fastapi-admin](https://fastapi-admin.github.io/fastapi-admin). See documentation at [https://fastapi-admin.github.io/fastapi-admin](https://fastapi-admin.github.io/fastapi-admin).

View File

@ -3,9 +3,7 @@ services:
app: app:
build: . build: .
restart: always restart: always
environment: env_file: .env
DATABASE_URL: mysql://root:123456@127.0.0.1:3306/fastapi-admin
TZ: Asia/Shanghai
network_mode: host network_mode: host
image: fastapi-admin image: fastapi-admin
command: uvicorn examples.main:app_ --port 8000 --host 0.0.0.0 command: uvicorn examples.main:app_ --port 8000 --host 0.0.0.0

View File

@ -1 +0,0 @@
# File Upload Provider

View File

@ -0,0 +1 @@
# File Upload

View File

@ -78,12 +78,13 @@ The `Model` make a TortoiseORM model as a menu with CURD page.
from examples.models import Admin from examples.models import Admin
from fastapi_admin.app import app from fastapi_admin.app import app
from fastapi_admin.resources import Field, Model,Action from fastapi_admin.resources import Field, Model, Action
from fastapi_admin.widgets import displays, filters, inputs from fastapi_admin.widgets import displays, filters, inputs
from typing import List from typing import List
from fastapi_admin.providers.file_upload import FileUploadProvider from fastapi_admin.file_upload import FileUpload
upload_provider = FileUpload(uploads_dir=os.path.join(BASE_DIR, "static", "uploads"))
upload_provider = FileUploadProvider(uploads_dir=os.path.join(BASE_DIR, "static", "uploads"))
@app.register @app.register
class AdminResource(Model): class AdminResource(Model):

View File

@ -18,48 +18,13 @@ admin_app.add_middleware(BaseHTTPMiddleware, dispatch=LoginPasswordMaxTryMiddlew
## Permission Control ## Permission Control
## Additional File Upload Providers ## Additional File Upload
### ALiYunOSSProvider ### ALiYunOSS
### AwsS3Provider ### AwsS3
## Error pages ## Maintenance
### 403
You can catch all `403` error to show builtin `403` page.
```python
from fastapi_admin.exceptions import forbidden_error_exception
from starlette.status import HTTP_403_FORBIDDEN
admin_app.add_exception_handler(HTTP_403_FORBIDDEN, forbidden_error_exception)
```
### 404
You can catch all `404` error to show builtin `404` page.
```python
from fastapi_admin.exceptions import not_found_error_exception
from starlette.status import HTTP_404_NOT_FOUND
app.add_exception_handler(HTTP_404_NOT_FOUND, not_found_error_exception)
```
### 500
You can catch all `500` error to show builtin `500` page.
```python
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
from fastapi_admin.exceptions import server_error_exception
app.add_exception_handler(HTTP_500_INTERNAL_SERVER_ERROR, server_error_exception)
```
### Maintenance
If your site is in maintenance, you can set `true` to `admin_app.configure(...)`. If your site is in maintenance, you can set `true` to `admin_app.configure(...)`.
@ -69,16 +34,16 @@ admin_app.configure(maintenance=True)
## Admin Log ## Admin Log
If you want to log all `create/update/delete` actions, you can set `admin_log_provider` to `admin_app.configure(...)`. If you want to log all `create/update/delete` actions, you can add `AdminLogProvider` to `admin_app.configure(...)`.
```python ```python
admin_app.configure(admin_log_provider=AdminLogProvider(Log)) admin_app.configure(providers=[AdminLogProvider(Log)])
``` ```
## Site Search ## Site Search
You can enable site search by set `search_provider` to `admin_app.configure(...)`. You can enable site search by add `SearchProvider` to `admin_app.configure(...)`.
```python ```python
admin_app.configure(search_provider=SearchProvider()) admin_app.configure(providers=[SearchProvider()])
``` ```

View File

@ -1 +1,3 @@
# Error Pages (💗 Pro only) # Error Pages
`fastapi-admin` will catch all `403`,`404`,`500` errors and redirect to builtin error pages default.

View File

@ -58,11 +58,12 @@ nav:
- custom/page.md - custom/page.md
- custom/overwrite.md - custom/overwrite.md
- custom/widget.md - custom/widget.md
- custom/login.md - custom/file_upload.md
- custom/file.md - Providers:
- custom/search.md - custom/providers/login.md
- custom/admin_log.md - custom/providers/search.md
- custom/permission.md - custom/providers/admin_log.md
- custom/providers/permission.md
- Pro Version For Sponsor: - Pro Version For Sponsor:
- pro/sponsor.md - pro/sponsor.md
- pro/exclusive.md - pro/exclusive.md

View File

@ -14,8 +14,6 @@ from examples.models import Admin
from examples.providers import LoginProvider from examples.providers import LoginProvider
from fastapi_admin.app import app as admin_app from fastapi_admin.app import app as admin_app
login_provider = LoginProvider(admin_model=Admin)
def create_app(): def create_app():
app = FastAPI() app = FastAPI()
@ -37,11 +35,11 @@ def create_app():
password=settings.REDIS_PASSWORD, password=settings.REDIS_PASSWORD,
encoding="utf8", encoding="utf8",
) )
admin_app.configure( await admin_app.configure(
logo_url="https://preview.tabler.io/static/logo-white.svg", logo_url="https://preview.tabler.io/static/logo-white.svg",
login_logo_url="https://preview.tabler.io/static/logo.svg", login_logo_url="https://preview.tabler.io/static/logo.svg",
template_folders=[os.path.join(BASE_DIR, "templates")], template_folders=[os.path.join(BASE_DIR, "templates")],
login_provider=login_provider, providers=[LoginProvider(admin_model=Admin)],
redis=redis, redis=redis,
) )

View File

@ -3,7 +3,7 @@ import datetime
from tortoise import Model, fields from tortoise import Model, fields
from examples.enums import ProductType, Status from examples.enums import ProductType, Status
from fastapi_admin.providers.login import AbstractAdmin from fastapi_admin.models import AbstractAdmin
class Admin(AbstractAdmin): class Admin(AbstractAdmin):

View File

@ -5,11 +5,11 @@ from examples import enums
from examples.constants import BASE_DIR from examples.constants import BASE_DIR
from examples.models import Admin, Category, Config, Product from examples.models import Admin, Category, Config, Product
from fastapi_admin.app import app from fastapi_admin.app import app
from fastapi_admin.providers.file_upload import FileUploadProvider from fastapi_admin.file_upload import FileUpload
from fastapi_admin.resources import Action, Dropdown, Field, Link, Model from fastapi_admin.resources import Action, Dropdown, Field, Link, Model
from fastapi_admin.widgets import displays, filters, inputs from fastapi_admin.widgets import displays, filters, inputs
upload_provider = FileUploadProvider(uploads_dir=os.path.join(BASE_DIR, "static", "uploads")) upload_provider = FileUpload(uploads_dir=os.path.join(BASE_DIR, "static", "uploads"))
@app.register @app.register

View File

@ -3,28 +3,33 @@ from typing import Dict, List, Optional, Type
from aioredis import Redis from aioredis import Redis
from fastapi import FastAPI from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_500_INTERNAL_SERVER_ERROR
from tortoise import Model from tortoise import Model
from fastapi_admin import i18n from fastapi_admin import i18n
from fastapi_admin.exceptions import (
forbidden_error_exception,
not_found_error_exception,
server_error_exception,
)
from . import middlewares, template from . import middlewares, template
from .providers.login import LoginProvider from .providers import Provider
from .resources import Dropdown from .resources import Dropdown
from .resources import Model as ModelResource from .resources import Model as ModelResource
from .resources import Resource from .resources import Resource
from .routes import router from .routes import router
class FastAdmin(FastAPI): class FastAPIAdmin(FastAPI):
logo_url: str logo_url: str
login_logo_url: str login_logo_url: str
admin_path: str admin_path: str
resources: List[Type[Resource]] = [] resources: List[Type[Resource]] = []
model_resources: Dict[Type[Model], Type[Resource]] = {} model_resources: Dict[Type[Model], Type[Resource]] = {}
login_provider: Optional[LoginProvider]
redis: Redis redis: Redis
def configure( async def configure(
self, self,
redis: Redis, redis: Redis,
logo_url: str = None, logo_url: str = None,
@ -32,17 +37,8 @@ class FastAdmin(FastAPI):
default_locale: str = "en_US", default_locale: str = "en_US",
admin_path: str = "/admin", admin_path: str = "/admin",
template_folders: Optional[List[str]] = None, template_folders: Optional[List[str]] = None,
login_provider: Optional[LoginProvider] = None, providers: Optional[List[Provider]] = None,
): ):
"""
Config FastAdmin
:param logo_url:
:param default_locale:
:param admin_path:
:param template_folders:
:param login_provider:
:return:
"""
self.redis = redis self.redis = redis
self.login_logo_url = login_logo_url self.login_logo_url = login_logo_url
i18n.set_locale(default_locale) i18n.set_locale(default_locale)
@ -50,16 +46,11 @@ class FastAdmin(FastAPI):
self.logo_url = logo_url self.logo_url = logo_url
if template_folders: if template_folders:
template.add_template_folder(*template_folders) template.add_template_folder(*template_folders)
self.login_provider = login_provider await self._register_providers(providers)
self._register_providers()
def _register_providers(self): async def _register_providers(self, providers: Optional[List[Provider]] = None):
if self.login_provider: for p in providers or []:
login_path = self.login_provider.login_path await p.register(self)
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]): def register_resources(self, *resource: Type[Resource]):
for r in resource: for r in resource:
@ -80,9 +71,12 @@ class FastAdmin(FastAPI):
return self.model_resources[model]() return self.model_resources[model]()
app = FastAdmin( app = FastAPIAdmin(
title="FastAdmin", title="FastAdmin",
description="A fast admin dashboard based on fastapi and tortoise-orm with tabler ui.", description="A fast admin dashboard based on fastapi and tortoise-orm with tabler ui.",
) )
app.add_middleware(BaseHTTPMiddleware, dispatch=middlewares.language_processor) app.add_middleware(BaseHTTPMiddleware, dispatch=middlewares.language_processor)
app.add_exception_handler(HTTP_500_INTERNAL_SERVER_ERROR, server_error_exception)
app.add_exception_handler(HTTP_404_NOT_FOUND, not_found_error_exception)
app.add_exception_handler(HTTP_403_FORBIDDEN, forbidden_error_exception)
app.include_router(router) app.include_router(router)

View File

@ -1,6 +1,9 @@
from fastapi import HTTPException from fastapi import HTTPException
from starlette.requests import Request
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
from fastapi_admin.template import templates
class ServerHTTPException(HTTPException): class ServerHTTPException(HTTPException):
def __init__(self, error: str = None): def __init__(self, error: str = None):
@ -31,3 +34,23 @@ class FileExtNotAllowed(ServerHTTPException):
""" """
raise when the upload file ext not allowed raise when the upload file ext not allowed
""" """
async def server_error_exception(request: Request, exc: HTTPException):
return templates.TemplateResponse(
"errors/500.html",
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
context={"request": request},
)
async def not_found_error_exception(request: Request, exc: HTTPException):
return templates.TemplateResponse(
"errors/404.html", status_code=exc.status_code, context={"request": request}
)
async def forbidden_error_exception(request: Request, exc: HTTPException):
return templates.TemplateResponse(
"errors/403.html", status_code=exc.status_code, context={"request": request}
)

View File

@ -7,7 +7,7 @@ from starlette.datastructures import UploadFile
from fastapi_admin.exceptions import FileExtNotAllowed, FileMaxSizeLimit from fastapi_admin.exceptions import FileExtNotAllowed, FileMaxSizeLimit
class FileUploadProvider: class FileUpload:
def __init__( def __init__(
self, self,
uploads_dir: str, uploads_dir: str,

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-05-02 14:00+0800\n" "POT-Creation-Date: 2021-05-05 14:09+0800\n"
"PO-Revision-Date: 2021-04-16 22:46+0800\n" "PO-Revision-Date: 2021-04-16 22:46+0800\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en_US\n" "Language: en_US\n"
@ -18,27 +18,31 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.1\n" "Generated-By: Babel 2.9.1\n"
#: fastapi_admin/resources.py:80 #: fastapi_admin/resources.py:75
msgid "update" msgid "update"
msgstr "Update" msgstr "Update"
#: fastapi_admin/resources.py:81 #: fastapi_admin/resources.py:76
msgid "delete" msgid "delete"
msgstr "Delete" msgstr "Delete"
#: fastapi_admin/resources.py:87 #: fastapi_admin/resources.py:81
msgid "delete_selected" msgid "delete_selected"
msgstr "Delete Selected" msgstr "Delete Selected"
#: fastapi_admin/providers/login.py:89 #: fastapi_admin/providers/login.py:68
msgid "login_failed" msgid "login_failed"
msgstr "Login to your account failed" msgstr "Login to your account failed"
#: fastapi_admin/routes/password.py:39 #: fastapi_admin/providers/login.py:152
msgid "confirm_password_different"
msgstr "Password is different"
#: fastapi_admin/providers/login.py:187 fastapi_admin/routes/password.py:38
msgid "old_password_error" msgid "old_password_error"
msgstr "Old password error" msgstr "Old password error"
#: fastapi_admin/routes/password.py:41 #: fastapi_admin/providers/login.py:189 fastapi_admin/routes/password.py:40
msgid "new_password_different" msgid "new_password_different"
msgstr "New password is different" msgstr "New password is different"
@ -89,27 +93,23 @@ msgstr "Bulk Actions"
msgid "create" msgid "create"
msgstr "Create" msgstr "Create"
#: fastapi_admin/templates/list.html:126 #: fastapi_admin/templates/list.html:132
msgid "actions" msgid "actions"
msgstr "Actions" msgstr "Actions"
#: fastapi_admin/templates/list.html:157 #: fastapi_admin/templates/list.html:163
#, python-format #, python-format
msgid "Showing %(from)s to %(to)s of %(total)s entries" msgid "Showing %(from)s to %(to)s of %(total)s entries"
msgstr "" msgstr ""
#: fastapi_admin/templates/list.html:168 #: fastapi_admin/templates/list.html:174
msgid "prev_page" msgid "prev_page"
msgstr "Prev" msgstr "Prev"
#: fastapi_admin/templates/list.html:189 #: fastapi_admin/templates/list.html:195
msgid "next_page" msgid "next_page"
msgstr "Next" msgstr "Next"
#: fastapi_admin/templates/login.html:38
msgid "login_title"
msgstr "Login to your account"
#: fastapi_admin/templates/login.html:40 #: fastapi_admin/templates/login.html:40
msgid "username" msgid "username"
msgstr "Username" msgstr "Username"
@ -165,3 +165,10 @@ msgstr "Submit"
#: fastapi_admin/templates/update.html:23 #: fastapi_admin/templates/update.html:23
msgid "save_and_return" msgid "save_and_return"
msgstr "Save and return" msgstr "Save and return"
#: fastapi_admin/templates/errors/403.html:21
#: fastapi_admin/templates/errors/404.html:21
#: fastapi_admin/templates/errors/500.html:21
msgid "return_home"
msgstr "Take me home"

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-05-02 14:00+0800\n" "POT-Creation-Date: 2021-05-05 14:09+0800\n"
"PO-Revision-Date: 2021-04-16 22:46+0800\n" "PO-Revision-Date: 2021-04-16 22:46+0800\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: zh_Hans_CN\n" "Language: zh_Hans_CN\n"
@ -18,27 +18,31 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.1\n" "Generated-By: Babel 2.9.1\n"
#: fastapi_admin/resources.py:80 #: fastapi_admin/resources.py:75
msgid "update" msgid "update"
msgstr "编辑" msgstr "编辑"
#: fastapi_admin/resources.py:81 #: fastapi_admin/resources.py:76
msgid "delete" msgid "delete"
msgstr "删除" msgstr "删除"
#: fastapi_admin/resources.py:87 #: fastapi_admin/resources.py:81
msgid "delete_selected" msgid "delete_selected"
msgstr "删除选中" msgstr "删除选中"
#: fastapi_admin/providers/login.py:89 #: fastapi_admin/providers/login.py:68
msgid "login_failed" msgid "login_failed"
msgstr "登录管理台" msgstr "登录管理台"
#: fastapi_admin/routes/password.py:39 #: fastapi_admin/providers/login.py:152
msgid "confirm_password_different"
msgstr "密码不一致"
#: fastapi_admin/providers/login.py:187 fastapi_admin/routes/password.py:38
msgid "old_password_error" msgid "old_password_error"
msgstr "旧密码错误" msgstr "旧密码错误"
#: fastapi_admin/routes/password.py:41 #: fastapi_admin/providers/login.py:189 fastapi_admin/routes/password.py:40
msgid "new_password_different" msgid "new_password_different"
msgstr "新密码不一致" msgstr "新密码不一致"
@ -89,27 +93,23 @@ msgstr "批量操作"
msgid "create" msgid "create"
msgstr "创建" msgstr "创建"
#: fastapi_admin/templates/list.html:126 #: fastapi_admin/templates/list.html:132
msgid "actions" msgid "actions"
msgstr "动作" msgstr "动作"
#: fastapi_admin/templates/list.html:157 #: fastapi_admin/templates/list.html:163
#, python-format #, python-format
msgid "Showing %(from)s to %(to)s of %(total)s entries" msgid "Showing %(from)s to %(to)s of %(total)s entries"
msgstr "显示 %(from)s 到 %(to)s 共 %(total)s 项" msgstr "显示 %(from)s 到 %(to)s 共 %(total)s 项"
#: fastapi_admin/templates/list.html:168 #: fastapi_admin/templates/list.html:174
msgid "prev_page" msgid "prev_page"
msgstr "上一页" msgstr "上一页"
#: fastapi_admin/templates/list.html:189 #: fastapi_admin/templates/list.html:195
msgid "next_page" msgid "next_page"
msgstr "下一页" msgstr "下一页"
#: fastapi_admin/templates/login.html:38
msgid "login_title"
msgstr "登录管理台"
#: fastapi_admin/templates/login.html:40 #: fastapi_admin/templates/login.html:40
msgid "username" msgid "username"
msgstr "用户名" msgstr "用户名"
@ -165,3 +165,10 @@ msgstr "提交"
#: fastapi_admin/templates/update.html:23 #: fastapi_admin/templates/update.html:23
msgid "save_and_return" msgid "save_and_return"
msgstr "保存并返回" msgstr "保存并返回"
#: fastapi_admin/templates/errors/403.html:21
#: fastapi_admin/templates/errors/404.html:21
#: fastapi_admin/templates/errors/500.html:21
msgid "return_home"
msgstr "返回首页"

View File

@ -0,0 +1,11 @@
import typing
if typing.TYPE_CHECKING:
from fastapi_admin.app import FastAPIAdmin
class Provider:
name = "provider"
async def register(self, app: "FastAPIAdmin"):
setattr(app, self.name, self)

View File

@ -1,69 +1,29 @@
import typing
import uuid import uuid
from typing import Callable, Type from typing import Type
import bcrypt
from aioredis import Redis from aioredis import Redis
from fastapi import Depends from fastapi import Depends, Form
from pydantic import EmailStr from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED
from tortoise import Model, fields
from fastapi_admin import constants from fastapi_admin import constants
from fastapi_admin.depends import get_redis from fastapi_admin.depends import get_current_admin, get_redis, get_resources
from fastapi_admin.i18n import _ from fastapi_admin.i18n import _
from fastapi_admin.models import AbstractAdmin
from fastapi_admin.providers import Provider
from fastapi_admin.template import templates from fastapi_admin.template import templates
from fastapi_admin.utils import check_password, hash_password
if typing.TYPE_CHECKING:
from fastapi_admin.app import FastAPIAdmin
class LoginProvider: class UsernamePasswordProvider(Provider):
def __init__(self, login_path="/login", logout_path="/logout", template="login.html"): name = "login_provider"
self.template = template
self.logout_path = logout_path
self.login_path = login_path
async def get(
self,
request: Request,
):
return templates.TemplateResponse(self.template, context={"request": request})
async def post(
self,
request: Request,
):
"""
Post login
:param request:
:return:
"""
async def authenticate(
self,
request: Request,
call_next: Callable,
):
response = await call_next(request)
return response
def redirect_login(self, request: Request):
return RedirectResponse(
url=request.app.admin_path + self.login_path, status_code=HTTP_303_SEE_OTHER
)
async def logout(self, request: Request):
return self.redirect_login(request)
class AbstractAdmin(Model):
username = fields.CharField(max_length=50, unique=True)
password = fields.CharField(max_length=200)
class Meta:
abstract = True
class UsernamePasswordProvider(LoginProvider):
access_token = "access_token" access_token = "access_token"
def __init__( def __init__(
@ -72,17 +32,39 @@ class UsernamePasswordProvider(LoginProvider):
login_path="/login", login_path="/login",
logout_path="/logout", logout_path="/logout",
template="login.html", template="login.html",
login_title="Login to your account",
): ):
super().__init__(login_path, logout_path, template) self.login_path = login_path
self.logout_path = logout_path
self.template = template
self.admin_model = admin_model self.admin_model = admin_model
self.login_title = login_title
async def post(self, request: Request, redis: Redis = Depends(get_redis)): async def login_view(
self,
request: Request,
):
return templates.TemplateResponse(self.template, context={"request": request})
async def register(self, app: "FastAPIAdmin"):
await super(UsernamePasswordProvider, self).register(app)
login_path = self.login_path
app.get(login_path)(self.login_view)
app.post(login_path)(self.login)
app.get(self.logout_path)(self.logout)
app.add_middleware(BaseHTTPMiddleware, dispatch=self.authenticate)
app.get("/init")(self.init_view)
app.post("/init")(self.init)
app.get("/password")(self.password_view)
app.post("/password")(self.password)
async def login(self, request: Request, redis: Redis = Depends(get_redis)):
form = await request.form() form = await request.form()
username = form.get("username") username = form.get("username")
password = form.get("password") password = form.get("password")
remember_me = form.get("remember_me") remember_me = form.get("remember_me")
admin = await self.admin_model.get_or_none(username=username) admin = await self.admin_model.get_or_none(username=username)
if not admin or not self.check_password(admin, password): if not admin or not check_password(password, admin.password):
return templates.TemplateResponse( return templates.TemplateResponse(
self.template, self.template,
status_code=HTTP_401_UNAUTHORIZED, status_code=HTTP_401_UNAUTHORIZED,
@ -106,52 +88,106 @@ class UsernamePasswordProvider(LoginProvider):
await redis.set(constants.LOGIN_USER.format(token=token), admin.pk, expire=expire) await redis.set(constants.LOGIN_USER.format(token=token), admin.pk, expire=expire)
return response return response
async def logout(self, request: Request, redis: Redis = Depends(get_redis)): async def logout(self, request: Request):
response = await super(UsernamePasswordProvider, self).logout(request) response = self.redirect_login(request)
response.delete_cookie(self.access_token) response.delete_cookie(self.access_token, path=request.app.admin_path)
token = request.cookies.get(self.access_token) token = request.cookies.get(self.access_token)
await redis.delete(constants.LOGIN_USER.format(token=token)) await request.app.redis.delete(constants.LOGIN_USER.format(token=token))
return response return response
async def authenticate( async def authenticate(
self, self,
request: Request, request: Request,
call_next: Callable, call_next: RequestResponseEndpoint,
): ):
redis = request.app.redis # type:Redis redis = request.app.redis # type:Redis
token = request.cookies.get(self.access_token) token = request.cookies.get(self.access_token)
path = request.scope["path"] path = request.scope["path"]
token_key = constants.LOGIN_USER.format(token=token) if not token and path != self.login_path and path != "/init":
user_id = await redis.get(token_key)
if not user_id and path != self.login_path:
return self.redirect_login(request) return self.redirect_login(request)
admin = await self.admin_model.get_or_none(pk=user_id) token_key = constants.LOGIN_USER.format(token=token)
if not admin: admin_id = await redis.get(token_key)
if path != self.login_path: admin = await self.admin_model.get_or_none(pk=admin_id)
response = self.redirect_login(request)
response.delete_cookie(self.access_token)
return response
else:
if path == self.login_path:
return RedirectResponse(url=request.app.admin_path, status_code=HTTP_303_SEE_OTHER)
request.state.admin = admin request.state.admin = admin
if path == self.login_path and admin:
return RedirectResponse(url=request.app.admin_path, status_code=HTTP_303_SEE_OTHER)
response = await call_next(request) response = await call_next(request)
return response return response
def check_password(self, admin: AbstractAdmin, password: str): async def create_user(self, username: str, password: str):
return bcrypt.checkpw(password.encode(), admin.password.encode())
def hash_password(self, password: str):
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
async def create_admin(self, username: str, password: str, email: EmailStr):
return await self.admin_model.create( return await self.admin_model.create(
username=username, username=username,
password=self.hash_password(password), password=hash_password(password),
email=email,
) )
async def update_password(self, admin: AbstractAdmin, password: str): async def update_password(self, admin: AbstractAdmin, password: str):
admin.password = self.hash_password(password) admin.password = hash_password(password)
await admin.save(update_fields=["password"]) await admin.save(update_fields=["password"])
async def init_view(self, request: Request):
exists = await self.admin_model.all().limit(1).exists()
if exists:
return self.redirect_login(request)
return templates.TemplateResponse("init.html", context={"request": request})
async def init(
self,
request: Request,
):
exists = await self.admin_model.all().limit(1).exists()
if exists:
return self.redirect_login(request)
form = await request.form()
password = form.get("password")
confirm_password = form.get("confirm_password")
username = form.get("username")
if password != confirm_password:
return templates.TemplateResponse(
"init.html",
context={"request": request, "error": _("confirm_password_different")},
)
await self.create_user(username, password)
return self.redirect_login(request)
def redirect_login(self, request: Request):
return RedirectResponse(
url=request.app.admin_path + self.login_path, status_code=HTTP_303_SEE_OTHER
)
async def password_view(
self,
request: Request,
resources=Depends(get_resources),
):
return templates.TemplateResponse(
"password.html",
context={
"request": request,
"resources": resources,
},
)
async def password(
self,
request: Request,
old_password: str = Form(...),
new_password: str = Form(...),
re_new_password: str = Form(...),
admin: AbstractAdmin = Depends(get_current_admin),
resources=Depends(get_resources),
):
error = None
if not check_password(old_password, admin.password):
error = _("old_password_error")
elif new_password != re_new_password:
error = _("new_password_different")
if error:
return templates.TemplateResponse(
"password.html",
context={"request": request, "resources": resources, "error": error},
)
await self.update_password(admin, new_password)
return await self.logout(request)

View File

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block body %}
<div class="page page-center">
<div class="container-tight py-4">
<div class="empty">
<div class="empty-header">403</div>
<p class="empty-title">Oops… You are forbidden</p>
<p class="empty-subtitle text-muted">
We are sorry but the page you are looking for was forbidden
</p>
<div class="empty-action">
<a href="{{ request.app.admin_path }}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<line x1="5" y1="12" x2="19" y2="12"/>
<line x1="5" y1="12" x2="11" y2="18"/>
<line x1="5" y1="12" x2="11" y2="6"/>
</svg>
{{ _('return_home') }}
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block body %}
<div class="page page-center">
<div class="container-tight py-4">
<div class="empty">
<div class="empty-header">404</div>
<p class="empty-title">Oops… You just found an error page</p>
<p class="empty-subtitle text-muted">
We are sorry but the page you are looking for was not found
</p>
<div class="empty-action">
<a href="{{ request.app.admin_path }}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<line x1="5" y1="12" x2="19" y2="12"/>
<line x1="5" y1="12" x2="11" y2="18"/>
<line x1="5" y1="12" x2="11" y2="6"/>
</svg>
{{ _('return_home') }}
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block body %}
<div class="page page-center">
<div class="container-tight py-4">
<div class="empty">
<div class="empty-header">500</div>
<p class="empty-title">Oops… You just found an error page</p>
<p class="empty-subtitle text-muted">
We are sorry but our server encountered an internal error
</p>
<div class="empty-action">
<a href="{{ request.app.admin_path }}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<line x1="5" y1="12" x2="19" y2="12"/>
<line x1="5" y1="12" x2="11" y2="18"/>
<line x1="5" y1="12" x2="11" y2="6"/>
</svg>
{{ _('return_home') }}
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% block body %}
<div class="page page-center">
<div class="container-tight py-4">
<div class="empty">
<div class="empty-img"><img src="https://preview.tabler.io/static/illustrations/undraw_quitting_time_dm8t.svg" height="128"
alt="">
</div>
<p class="empty-title">Temporarily down for maintenance</p>
<p class="empty-subtitle text-muted">
Sorry for the inconvenience but were performing some maintenance at the moment. Well be back
online shortly!
</p>
</div>
</div>
</div>
{% endblock %}

View File

@ -35,7 +35,9 @@
autocomplete="off" autocomplete="off"
> >
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-center mb-4">{{ _('login_title') }}</h2> <h2 class="card-title text-center mb-4">
{{ request.app.login_provider.login_title }}
</h2>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">{{ _('username') }}</label> <label class="form-label">{{ _('username') }}</label>
<input <input

View File

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

23
fastapi_admin/utils.py Normal file
View File

@ -0,0 +1,23 @@
import random
import string
import bcrypt
def generate_random_str(
length: int,
is_digit: bool = True,
):
if is_digit:
all_char = string.digits
else:
all_char = string.ascii_letters + string.digits
return "".join(random.sample(all_char, length))
def check_password(password: str, password_hash: str):
return bcrypt.checkpw(password.encode(), password_hash.encode())
def hash_password(password: str):
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()

View File

@ -6,7 +6,7 @@ from typing import Any, List, Optional, Tuple, Type
from starlette.datastructures import UploadFile from starlette.datastructures import UploadFile
from tortoise import Model from tortoise import Model
from fastapi_admin.providers.file_upload import FileUploadProvider from fastapi_admin.file_upload import FileUpload
from fastapi_admin.widgets import Widget from fastapi_admin.widgets import Widget
@ -189,7 +189,7 @@ class File(Input):
def __init__( def __init__(
self, self,
upload_provider: FileUploadProvider, upload_provider: FileUpload,
default: Any = None, default: Any = None,
null: bool = False, null: bool = False,
disabled: bool = False, disabled: bool = False,

View File

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg8"
version="1.1"
viewBox="0 0 6.3499999 6.3499999"
height="6.3499999mm"
width="6.3499999mm">
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
transform="translate(-87.539286,-84.426191)"
id="layer1">
<path
id="path815"
d="m 87.539286,84.426191 h 6.35 v 6.35 h -6.35 z"
style="fill:none;stroke-width:0.26458332" />
<path
style="stroke-width:0.26458332;fill:#ffffff"
id="path817"
d="m 90.714286,84.960649 c -1.457854,0 -2.640542,1.182688 -2.640542,2.640542 0,1.457854 1.182688,2.640542 2.640542,2.640542 1.457854,0 2.640542,-1.182688 2.640542,-2.640542 0,-1.457854 -1.182688,-2.640542 -2.640542,-2.640542 z m -0.137583,4.757209 v -1.656292 h -0.92075 l 1.322916,-2.577042 v 1.656292 h 0.886354 z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB