├── docs ├── __init__.py ├── code │ ├── models │ │ ├── __init__.py │ │ └── tortoise.py │ ├── dashboard │ │ ├── __init__.py │ │ ├── tortoise.py │ │ └── djangoorm.py │ ├── inlines │ │ ├── __init__.py │ │ └── tortoise.py │ ├── quick_tutorial │ │ ├── __init__.py │ │ ├── fastapi.py │ │ ├── flask.py │ │ ├── django.py │ │ ├── djangoorm.py │ │ ├── ponyorm.py │ │ ├── tortoise.py │ │ └── sqlalchemy.py │ └── __init__.py ├── Makefile ├── assets │ ├── images │ │ ├── change.png │ │ ├── list.png │ │ ├── signin.png │ │ ├── favicon.png │ │ └── header-logo.svg │ └── js │ │ └── theme.js ├── robots.txt ├── sitemap.xml └── templates │ ├── block.md │ ├── block.html │ └── readme.md ├── tests ├── api │ ├── __init__.py │ ├── frameworks │ │ ├── __init__.py │ │ ├── flask │ │ │ ├── __init__.py │ │ │ └── test_app.py │ │ ├── django │ │ │ ├── __init__.py │ │ │ └── test_app.py │ │ └── fastapi │ │ │ ├── __init__.py │ │ │ └── test_app.py │ ├── test_index.py │ ├── test_service.py │ ├── test_export.py │ ├── test_delete.py │ ├── test_retrieve.py │ ├── test_change_password.py │ ├── test_action.py │ ├── test_helpers.py │ ├── test_auth.py │ └── test_add.py ├── models │ ├── __init__.py │ ├── test_decorators.py │ ├── test_helpers.py │ └── test_base.py ├── environment │ ├── __init__.py │ ├── django │ │ ├── __init__.py │ │ ├── dev │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ ├── asgi.py │ │ │ ├── wsgi.py │ │ │ └── settings.py │ │ ├── orm │ │ │ ├── __init__.py │ │ │ ├── migrations │ │ │ │ ├── __init__.py │ │ │ │ └── 0001_initial.py │ │ │ └── apps.py │ │ └── manage.py │ ├── fastapi │ │ ├── __init__.py │ │ ├── app.py │ │ └── dev.py │ ├── flask │ │ ├── __init__.py │ │ ├── dev.py │ │ └── app.py │ ├── ponyorm │ │ └── __init__.py │ ├── sqlalchemy │ │ └── __init__.py │ ├── tortoiseorm │ │ └── __init__.py │ └── Makefile ├── settings.py └── __init__.py ├── fastadmin ├── api │ ├── __init__.py │ ├── frameworks │ │ ├── __init__.py │ │ ├── django │ │ │ ├── __init__.py │ │ │ └── app │ │ │ │ ├── __init__.py │ │ │ │ ├── views.py │ │ │ │ └── urls.py │ │ ├── flask │ │ │ ├── __init__.py │ │ │ ├── views.py │ │ │ └── app.py │ │ └── fastapi │ │ │ ├── __init__.py │ │ │ ├── views.py │ │ │ └── app.py │ ├── exceptions.py │ ├── schemas.py │ └── helpers.py ├── models │ ├── __init__.py │ ├── orms │ │ └── __init__.py │ └── decorators.py ├── static │ ├── images │ │ ├── favicon.png │ │ ├── header-logo.svg │ │ └── sign-in-logo.svg │ └── index.html ├── templates │ └── index.html ├── __init__.py └── settings.py ├── examples ├── django_djangoorm │ ├── dev │ │ ├── __init__.py │ │ ├── urls.py │ │ ├── asgi.py │ │ ├── wsgi.py │ │ └── settings.py │ ├── orm │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── __init__.py │ │ └── apps.py │ ├── Makefile │ ├── __init__.py │ ├── README.md │ ├── pyproject.toml │ └── manage.py ├── fastapi_ponyorm │ ├── Makefile │ ├── __init__.py │ ├── README.md │ ├── pyproject.toml │ └── models.py ├── fastapi_sqlalchemy │ ├── Makefile │ ├── __init__.py │ ├── README.md │ └── pyproject.toml └── fastapi_tortoiseorm │ ├── Makefile │ ├── __init__.py │ ├── README.md │ ├── pyproject.toml │ └── models.py ├── frontend ├── src │ ├── interfaces │ │ ├── user.ts │ │ └── configuration.ts │ ├── vite-env.d.ts │ ├── providers │ │ ├── SignInUserProvider │ │ │ ├── index.tsx │ │ │ └── provider.tsx │ │ └── ConfigurationProvider │ │ │ ├── index.tsx │ │ │ └── provider.tsx │ ├── containers │ │ ├── add │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── list │ │ │ └── index.test.tsx │ │ ├── change │ │ │ └── index.test.tsx │ │ ├── sign-in │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── app │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ └── index │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ ├── components │ │ ├── export-btn │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── slug-input │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── table-or-cards │ │ │ ├── cards │ │ │ │ └── index.test.tsx │ │ │ ├── index.tsx │ │ │ └── index.test.tsx │ │ ├── json-textarea │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── texteditor-field │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── upload-input │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── phone-number-input │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── password-input │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── dashboard-widget │ │ │ └── index.test.tsx │ │ ├── async-select │ │ │ └── index.test.tsx │ │ ├── crud-container │ │ │ └── index.test.tsx │ │ ├── sign-in-container │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── async-transfer │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── inline-widget │ │ │ └── index.test.tsx │ │ ├── form-container │ │ │ └── index.test.tsx │ │ └── filter-column │ │ │ └── index.tsx │ ├── hooks │ │ ├── useIsMobile.ts │ │ └── useTableQuery.ts │ ├── helpers │ │ ├── configuration.ts │ │ ├── title.ts │ │ ├── forms.ts │ │ └── widgets.ts │ ├── fetchers │ │ └── fetchers.ts │ ├── main.tsx │ ├── providers.tsx │ └── index.css ├── tsconfig.node.json ├── .gitignore ├── biome.json ├── index.html ├── tsconfig.json ├── vite.config.ts ├── Makefile ├── eslint.config.mjs ├── README.md └── package.json ├── pytest.ini ├── .github ├── pull_request_template.md └── workflows │ ├── cd.yml │ └── ci.yml ├── LICENSE ├── Makefile └── .gitignore /docs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/code/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastadmin/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastadmin/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/code/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/code/inlines/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastadmin/models/orms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/frameworks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/environment/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/code/quick_tutorial/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastadmin/api/frameworks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/frameworks/flask/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/environment/django/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/environment/fastapi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/environment/flask/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/environment/ponyorm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/django_djangoorm/dev/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastadmin/api/frameworks/django/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastadmin/api/frameworks/flask/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/frameworks/django/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/frameworks/fastapi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/environment/django/dev/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/environment/django/orm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/environment/sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/environment/tortoiseorm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastadmin/api/frameworks/django/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastadmin/api/frameworks/fastapi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/django_djangoorm/orm/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/environment/django/orm/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/environment/flask/dev.py: -------------------------------------------------------------------------------- 1 | from .app import app # noqa: F401 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | poetry run python build.py 4 | -------------------------------------------------------------------------------- /frontend/src/interfaces/user.ts: -------------------------------------------------------------------------------- 1 | export interface IMe { 2 | id: string; 3 | username: string; 4 | } 5 | -------------------------------------------------------------------------------- /docs/assets/images/change.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsdudakov/fastadmin/HEAD/docs/assets/images/change.png -------------------------------------------------------------------------------- /docs/assets/images/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsdudakov/fastadmin/HEAD/docs/assets/images/list.png -------------------------------------------------------------------------------- /docs/assets/images/signin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsdudakov/fastadmin/HEAD/docs/assets/images/signin.png -------------------------------------------------------------------------------- /docs/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsdudakov/fastadmin/HEAD/docs/assets/images/favicon.png -------------------------------------------------------------------------------- /fastadmin/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsdudakov/fastadmin/HEAD/fastadmin/static/images/favicon.png -------------------------------------------------------------------------------- /tests/api/test_index.py: -------------------------------------------------------------------------------- 1 | async def test_index(client): 2 | r = await client.get("/") 3 | assert r.status_code == 200 4 | -------------------------------------------------------------------------------- /docs/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: Googlebot 2 | Disallow: /nogooglebot/ 3 | 4 | User-agent: * 5 | Allow: / 6 | 7 | Sitemap: https://github.com/vsdudakov/fastadmin/sitemap.xml 8 | -------------------------------------------------------------------------------- /docs/code/quick_tutorial/fastapi.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from fastadmin import fastapi_app as admin_app 4 | 5 | app = FastAPI() 6 | 7 | app.mount("/admin", admin_app) 8 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | # pytest.ini 2 | [pytest] 3 | asyncio_mode = auto 4 | filterwarnings = 5 | ignore::pytest.PytestCollectionWarning 6 | ignore::DeprecationWarning 7 | ignore::RuntimeWarning 8 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare module "react-file-download"; 3 | declare module "getbase64data"; 4 | declare module "react-json-editor-ajrm/locale/en"; 5 | -------------------------------------------------------------------------------- /docs/code/quick_tutorial/flask.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | from fastadmin import flask_app as admin_app 4 | 5 | app = Flask(__name__) 6 | 7 | app.register_blueprint(admin_app, url_prefix="/admin") 8 | -------------------------------------------------------------------------------- /examples/django_djangoorm/orm/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.environ["ADMIN_USER_MODEL"] = "User" 4 | os.environ["ADMIN_USER_MODEL_USERNAME_FIELD"] = "username" 5 | os.environ["ADMIN_SECRET_KEY"] = "secret" 6 | -------------------------------------------------------------------------------- /examples/fastapi_ponyorm/Makefile: -------------------------------------------------------------------------------- 1 | all: install run 2 | 3 | .PHONY: fastapi 4 | run: 5 | poetry run fastapi dev --reload --port=8090 example.py 6 | 7 | .PHONY: install 8 | install: 9 | poetry install 10 | -------------------------------------------------------------------------------- /examples/fastapi_sqlalchemy/Makefile: -------------------------------------------------------------------------------- 1 | all: install run 2 | 3 | .PHONY: fastapi 4 | run: 5 | poetry run fastapi dev --reload --port=8090 example.py 6 | 7 | .PHONY: install 8 | install: 9 | poetry install 10 | -------------------------------------------------------------------------------- /examples/fastapi_tortoiseorm/Makefile: -------------------------------------------------------------------------------- 1 | all: install run 2 | 3 | .PHONY: fastapi 4 | run: 5 | poetry run fastapi dev --reload --port=8090 example.py 6 | 7 | .PHONY: install 8 | install: 9 | poetry install 10 | -------------------------------------------------------------------------------- /tests/environment/django/orm/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OrmConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "tests.environment.django.orm" 7 | -------------------------------------------------------------------------------- /tests/api/frameworks/fastapi/test_app.py: -------------------------------------------------------------------------------- 1 | from fastadmin.api.frameworks.fastapi.app import exception_handler 2 | 3 | 4 | async def test_exception_handler(): 5 | assert await exception_handler(None, Exception("Test")) is not None 6 | -------------------------------------------------------------------------------- /tests/environment/fastapi/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from fastadmin import fastapi_app as admin_app 4 | from fastadmin.settings import settings 5 | 6 | app = FastAPI() 7 | app.mount(f"/{settings.ADMIN_PREFIX}", admin_app) 8 | -------------------------------------------------------------------------------- /examples/django_djangoorm/Makefile: -------------------------------------------------------------------------------- 1 | all: install run 2 | 3 | .PHONY: fastapi 4 | run: 5 | poetry run python manage.py migrate 6 | poetry run python manage.py runserver 8090 7 | 8 | .PHONY: install 9 | install: 10 | poetry install 11 | -------------------------------------------------------------------------------- /fastadmin/api/exceptions.py: -------------------------------------------------------------------------------- 1 | class AdminApiException(Exception): 2 | status_code: int 3 | detail: str | None 4 | 5 | def __init__(self, status_code: int, detail: str | None = None): 6 | self.status_code = status_code 7 | self.detail = detail 8 | -------------------------------------------------------------------------------- /docs/code/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from tests.settings import ROOT_DIR 4 | 5 | sys.path.append(str(ROOT_DIR / "examples" / "quick_tutorial" / "django" / "dev")) # for dev.settings 6 | sys.path.append(str(ROOT_DIR / "examples" / "quick_tutorial")) # for djangoorm 7 | -------------------------------------------------------------------------------- /docs/code/quick_tutorial/django.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from fastadmin import get_django_admin_urls as get_admin_urls 4 | from fastadmin.settings import settings 5 | 6 | urlpatterns = [ 7 | path(f"{settings.ADMIN_PREFIX}/", get_admin_urls()), 8 | ] 9 | -------------------------------------------------------------------------------- /examples/django_djangoorm/dev/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from fastadmin import get_django_admin_urls as get_admin_urls 4 | from fastadmin.settings import settings 5 | 6 | urlpatterns = [ 7 | path(f"{settings.ADMIN_PREFIX}/", get_admin_urls()), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/environment/django/dev/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from fastadmin import get_django_admin_urls as get_admin_urls 4 | from fastadmin.settings import settings 5 | 6 | urlpatterns = [ 7 | path(f"{settings.ADMIN_PREFIX}/", get_admin_urls()), 8 | ] 9 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /examples/django_djangoorm/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | 5 | os.environ["ADMIN_USER_MODEL"] = "User" 6 | os.environ["ADMIN_USER_MODEL_USERNAME_FIELD"] = "username" 7 | os.environ["ADMIN_SECRET_KEY"] = "secret" 8 | 9 | sys.path.append(str(Path(__file__).resolve().parent)) 10 | -------------------------------------------------------------------------------- /examples/fastapi_ponyorm/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | 5 | os.environ["ADMIN_USER_MODEL"] = "User" 6 | os.environ["ADMIN_USER_MODEL_USERNAME_FIELD"] = "username" 7 | os.environ["ADMIN_SECRET_KEY"] = "secret" 8 | 9 | sys.path.append(str(Path(__file__).resolve().parent)) 10 | -------------------------------------------------------------------------------- /examples/fastapi_sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | 5 | os.environ["ADMIN_USER_MODEL"] = "User" 6 | os.environ["ADMIN_USER_MODEL_USERNAME_FIELD"] = "username" 7 | os.environ["ADMIN_SECRET_KEY"] = "secret" 8 | 9 | sys.path.append(str(Path(__file__).resolve().parent)) 10 | -------------------------------------------------------------------------------- /examples/fastapi_tortoiseorm/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | 5 | os.environ["ADMIN_USER_MODEL"] = "User" 6 | os.environ["ADMIN_USER_MODEL_USERNAME_FIELD"] = "username" 7 | os.environ["ADMIN_SECRET_KEY"] = "secret" 8 | 9 | sys.path.append(str(Path(__file__).resolve().parent)) 10 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | logging.disable(logging.WARNING) 5 | 6 | ROOT_DIR = Path(__file__).parent 7 | 8 | FRAMEWORKS = [ 9 | "fastapi", 10 | "flask", 11 | "django", 12 | ] 13 | 14 | ORMS = [ 15 | "tortoiseorm", 16 | "djangoorm", 17 | "sqlalchemy", 18 | "ponyorm", 19 | ] 20 | -------------------------------------------------------------------------------- /examples/django_djangoorm/README.md: -------------------------------------------------------------------------------- 1 | ## Django + Django ORM Example 2 | 3 | - Uses in-memory SQLite 3 instance 4 | - Creates "admin/admin" superuser 5 | - Setup env variables in __init__.py 6 | 7 | ```bash 8 | make install # Creates virtualenv with Poetry 9 | make run # Runs fastapi dev 10 | ``` 11 | 12 | So open `http://127.0.0.1:8090/admin/` and have a fun! -------------------------------------------------------------------------------- /examples/fastapi_ponyorm/README.md: -------------------------------------------------------------------------------- 1 | ## FastAPI + Pony ORM Example 2 | 3 | - Uses in-memory SQLite 3 instance 4 | - Creates "admin/admin" superuser 5 | - Setup env variables in __init__.py 6 | 7 | ```bash 8 | make install # Creates virtualenv with Poetry 9 | make run # Runs fastapi dev 10 | ``` 11 | 12 | So open `http://127.0.0.1:8090/admin/` and have a fun! -------------------------------------------------------------------------------- /examples/fastapi_tortoiseorm/README.md: -------------------------------------------------------------------------------- 1 | ## FastAPI + Tortoise ORM Example 2 | 3 | - Uses in-memory SQLite 3 instance 4 | - Creates "admin/admin" superuser 5 | - Setup env variables in __init__.py 6 | 7 | ```bash 8 | make install # Creates virtualenv with Poetry 9 | make run # Runs fastapi dev 10 | ``` 11 | 12 | So open `http://127.0.0.1:8090/admin/` and have a fun! -------------------------------------------------------------------------------- /examples/fastapi_sqlalchemy/README.md: -------------------------------------------------------------------------------- 1 | ## FastAPI + SQLAlchemy 2.x Example 2 | 3 | - Uses in-memory SQLite 3 instance 4 | - Creates "admin/admin" superuser 5 | - Setup env variables in __init__.py 6 | 7 | ```bash 8 | make install # Creates virtualenv with Poetry 9 | make run # Runs fastapi dev 10 | ``` 11 | 12 | So open `http://127.0.0.1:8090/admin/` and have a fun! -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /tests/environment/fastapi/dev.py: -------------------------------------------------------------------------------- 1 | from fastapi.middleware.cors import CORSMiddleware 2 | 3 | from .app import app 4 | 5 | # CORS 6 | origins = [ 7 | "http://localhost:3030", 8 | "http://127.0.0.1:3030", 9 | ] 10 | app.add_middleware( 11 | CORSMiddleware, 12 | allow_origins=origins, 13 | allow_credentials=True, 14 | allow_methods=["*"], 15 | allow_headers=["*"], 16 | ) 17 | -------------------------------------------------------------------------------- /tests/environment/flask/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | from fastadmin import flask_app as admin_app 4 | from fastadmin.api.frameworks.flask.app import JSONProvider 5 | from fastadmin.settings import settings 6 | 7 | app = Flask(__name__) 8 | # TODO: works only here not on blueprint 9 | app.json = JSONProvider(app) 10 | app.register_blueprint(admin_app, url_prefix=f"/{settings.ADMIN_PREFIX}") 11 | -------------------------------------------------------------------------------- /tests/environment/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: fastapi 2 | fastapi: 3 | poetry run uvicorn fastapi.dev:app --reload --host=0.0.0.0 --port=8090 4 | 5 | .PHONY: flask 6 | flask: 7 | poetry run flask --app flask.dev run --debug --host 0.0.0.0 --port 8090 8 | 9 | .PHONY: django 10 | django: 11 | poetry run python django/dev/manage.py runserver 0.0.0.0:8090 12 | 13 | 14 | .PHONY: install 15 | install: 16 | poetry install --all-extras 17 | -------------------------------------------------------------------------------- /frontend/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.4.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "suspicious": { 11 | "noExplicitAny": "off" 12 | } 13 | } 14 | }, 15 | "formatter": { 16 | "formatWithErrors": true, 17 | "indentStyle": "space" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import django 5 | 6 | from tests.settings import ROOT_DIR 7 | 8 | os.environ["ADMIN_USER_MODEL"] = "User" 9 | os.environ["ADMIN_USER_MODEL_USERNAME_FIELD"] = "username" 10 | os.environ["ADMIN_SECRET_KEY"] = "secret" 11 | 12 | 13 | sys.path.append(str(ROOT_DIR / "environment" / "django")) 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dev.settings") 15 | django.setup(set_prefix=False) 16 | -------------------------------------------------------------------------------- /frontend/src/providers/SignInUserProvider/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import type { IMe } from "@/interfaces/user"; 4 | 5 | interface ISignInUserContext { 6 | signedInUser?: IMe; 7 | signedInUserRefetch(): void; 8 | signedIn: boolean; 9 | } 10 | 11 | export const SignInUserContext = React.createContext({ 12 | signedInUser: undefined, 13 | signedInUserRefetch: () => undefined, 14 | signedIn: false, 15 | }); 16 | -------------------------------------------------------------------------------- /examples/django_djangoorm/orm/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OrmConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "orm" 7 | 8 | def ready(self): 9 | try: 10 | from orm.models import User 11 | 12 | User.objects.update_or_create(username="admin", password="admin", is_superuser=True) 13 | except Exception: # noqa: BLE001, S110 14 | pass 15 | -------------------------------------------------------------------------------- /examples/django_djangoorm/dev/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for dev project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dev.settings") 15 | application = get_asgi_application() 16 | -------------------------------------------------------------------------------- /tests/environment/django/dev/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for dev project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dev.settings") 15 | application = get_asgi_application() 16 | -------------------------------------------------------------------------------- /examples/django_djangoorm/dev/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for dev project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dev.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /frontend/src/containers/add/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { Add } from "@/containers/add"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders Add", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/environment/django/dev/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for dev project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dev.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /frontend/src/containers/list/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { List } from "@/containers/list"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders List", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/containers/change/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { Change } from "@/containers/change"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders Change", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/containers/sign-in/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { SignIn } from "@/containers/sign-in"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders SignIn", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/containers/app/index.test.tsx: -------------------------------------------------------------------------------- 1 | // import { QueryClient } from "@tanstack/react-query"; 2 | // import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | // import { App } from "@/containers/app"; 6 | // import { TestProviders } from "@/providers"; 7 | 8 | test("Renders App", () => { 9 | // const queryClient = new QueryClient(); 10 | // render( 11 | // 12 | // 13 | // , 14 | // ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/components/export-btn/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { ExportBtn } from "@/components/export-btn"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders ExportBtn", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/components/slug-input/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { SlugInput } from "@/components/slug-input"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders SlugInput", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/components/table-or-cards/cards/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { Cards } from "@/components/table-or-cards/cards"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders Cards", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/containers/index/index.test.tsx: -------------------------------------------------------------------------------- 1 | // import { QueryClient } from "@tanstack/react-query"; 2 | // import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | // import { Index } from "@/containers/index"; 6 | // import { TestProviders } from "@/providers"; 7 | 8 | test("Renders Index", () => { 9 | // const queryClient = new QueryClient(); 10 | // render( 11 | // 12 | // 13 | // , 14 | // ); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/api/frameworks/django/test_app.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from uuid import uuid4 3 | 4 | from fastadmin.api.frameworks.django.app.api import JsonEncoder 5 | 6 | 7 | async def test_json_provider(): 8 | today = datetime.now(timezone.utc).date() 9 | now = datetime.now(timezone.utc) 10 | uuid = uuid4() 11 | assert JsonEncoder().default(today) == today.isoformat() 12 | assert JsonEncoder().default(now) == now.isoformat() 13 | assert JsonEncoder().default(uuid) == str(uuid) 14 | -------------------------------------------------------------------------------- /frontend/src/components/json-textarea/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { JsonTextArea } from "@/components/json-textarea"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders JsonTextArea", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/components/texteditor-field/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { TextEditor } from "@/components/texteditor-field"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders TextEditor", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/api/test_service.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from fastadmin.api.service import convert_id 4 | 5 | 6 | def test_convert_id(): 7 | assert convert_id("1") == 1 8 | assert convert_id("1000") == 1000 9 | assert convert_id(1000) == 1000 10 | assert convert_id("123e4567-e89b-12d3-a456-426614174000") == UUID("123e4567-e89b-12d3-a456-426614174000") 11 | assert convert_id(UUID("123e4567-e89b-12d3-a456-426614174000")) == UUID("123e4567-e89b-12d3-a456-426614174000") 12 | assert convert_id("invalid") is None 13 | -------------------------------------------------------------------------------- /frontend/src/components/upload-input/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { UploadInput } from "@/components/upload-input"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders UploadInput", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/hooks/useIsMobile.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useIsMobile = () => { 4 | const [width, setWidth] = useState(window.innerWidth); 5 | 6 | useEffect(() => { 7 | function handleWindowSizeChange() { 8 | setWidth(window.innerWidth); 9 | } 10 | window.addEventListener("resize", handleWindowSizeChange); 11 | return () => { 12 | window.removeEventListener("resize", handleWindowSizeChange); 13 | }; 14 | }, []); 15 | 16 | return width <= 768; 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/components/phone-number-input/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { PhoneNumberInput } from "@/components/phone-number-input"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders PhoneInput", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/components/password-input/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { PasswordInput } from "@/components/password-input"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders PasswordInput", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard-widget/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { TestProviders } from "@/providers"; 6 | // import { DashboardWidget } from '@/components/dashboard-widget'; 7 | 8 | test("Renders DashboardWidget", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | {/* */} 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/src/components/table-or-cards/index.tsx: -------------------------------------------------------------------------------- 1 | import { Table, type TableProps } from "antd"; 2 | 3 | import { useIsMobile } from "@/hooks/useIsMobile"; 4 | 5 | import { Cards } from "./cards"; 6 | 7 | export const TableOrCards = (props: Partial>) => { 8 | const isMobile = useIsMobile(); 9 | if (isMobile) { 10 | return ; 11 | } 12 | return ( 13 | 7 ? 1800 : 1200 }} 16 | {...props} 17 | /> 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/providers/ConfigurationProvider/index.tsx: -------------------------------------------------------------------------------- 1 | import type { IConfiguration } from "@/interfaces/configuration"; 2 | import React from "react"; 3 | 4 | export const defaultConfiguration = { 5 | site_name: "API Administration", 6 | username_field: "username", 7 | models: [], 8 | dashboard_widgets: [], 9 | }; 10 | 11 | interface IConfigurationContext { 12 | configuration: IConfiguration; 13 | } 14 | 15 | export const ConfigurationContext = React.createContext({ 16 | configuration: defaultConfiguration, 17 | }); 18 | -------------------------------------------------------------------------------- /frontend/src/components/table-or-cards/index.test.tsx: -------------------------------------------------------------------------------- 1 | // import { QueryClient } from "@tanstack/react-query"; 2 | // import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | // import { TableOrCards } from "@/components/table-or-cards"; 6 | // import { TestProviders } from "@/providers"; 7 | 8 | test("Renders TableOrCards", () => { 9 | // const queryClient = new QueryClient(); 10 | // render( 11 | // 12 | // 13 | // , 14 | // ); 15 | }); 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### References 2 | 3 | 4 | 5 | ### Summary 6 | 7 | 8 | 9 | 10 | ### Are there any open tasks/blockers for the ticket? 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: FastAdmin CD 2 | 3 | on: 4 | release: 5 | types: [released] 6 | workflow_dispatch: 7 | 8 | concurrency: 9 | group: ${{ github.workflow }} 10 | cancel-in-progress: false 11 | 12 | jobs: 13 | cd: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Install poetry 18 | run: pipx install poetry 19 | - name: Deploy Package 20 | run: | 21 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 22 | poetry build 23 | poetry publish 24 | -------------------------------------------------------------------------------- /frontend/src/components/async-select/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { AsyncSelect } from "@/components/async-select"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders AsyncTransfer", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 | , 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /fastadmin/api/frameworks/django/app/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | from fastadmin.api.helpers import get_template 4 | from fastadmin.settings import ROOT_DIR, settings 5 | 6 | 7 | def index(request): 8 | """This method is used to render index page. 9 | 10 | :params request: a request object. 11 | :return: A response object. 12 | """ 13 | template = get_template( 14 | ROOT_DIR / "templates" / "index.html", 15 | { 16 | "ADMIN_PREFIX": settings.ADMIN_PREFIX, 17 | }, 18 | ) 19 | return HttpResponse(template) 20 | -------------------------------------------------------------------------------- /frontend/src/components/crud-container/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { CrudContainer } from "@/components/crud-container"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders CrudContainer", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 13 |
14 | 15 | , 16 | ); 17 | }); 18 | -------------------------------------------------------------------------------- /frontend/src/helpers/configuration.ts: -------------------------------------------------------------------------------- 1 | import type { IConfiguration, IModel } from "@/interfaces/configuration"; 2 | 3 | export const getConfigurationModel = ( 4 | configuration: IConfiguration, 5 | modelName: string, 6 | ): IModel | undefined => { 7 | const model = configuration.models.find((item) => item.name === modelName); 8 | if (model) { 9 | return model; 10 | } 11 | 12 | for (const item of configuration.models) { 13 | for (const inline of item.inlines || []) { 14 | if (inline.name === modelName) { 15 | return inline as IModel; 16 | } 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /examples/django_djangoorm/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastadmin-django-djangoeorm" 3 | version = "0.1.0" 4 | description = "FastAdmin Example (Django + Django ORM)" 5 | authors = ["Seva D "] 6 | license = "MIT" 7 | homepage = "https://github.com/vsdudakov/fastadmin" 8 | repository = "https://github.com/vsdudakov/fastadmin" 9 | package-mode = false 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.10" 13 | django = "^5.2" 14 | fastadmin = {"version" = "^0.2.21", extras = ["django"]} 15 | 16 | [build-system] 17 | requires = ["poetry-core>=1.0.0"] 18 | build-backend = "poetry.core.masonry.api" 19 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Fast Admin 9 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/components/sign-in-container/index.test.tsx: -------------------------------------------------------------------------------- 1 | // import { QueryClient } from "@tanstack/react-query"; 2 | // import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | // import { SignInContainer } from "@/components/sign-in-container"; 6 | // import { TestProviders } from "@/providers"; 7 | 8 | test("Renders SignInContainer", () => { 9 | // const queryClient = new QueryClient(); 10 | // render( 11 | // 12 | // 13 | //
14 | // 15 | // , 16 | // ); 17 | }); 18 | -------------------------------------------------------------------------------- /fastadmin/api/frameworks/flask/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import Blueprint 4 | 5 | from fastadmin.api.helpers import get_template 6 | from fastadmin.settings import ROOT_DIR, settings 7 | 8 | logger = logging.getLogger(__name__) 9 | views_router = Blueprint( 10 | "views_router", 11 | __name__, 12 | ) 13 | 14 | 15 | @views_router.route("/") 16 | def index(): 17 | """This method is used to render index page. 18 | 19 | :return: A response object. 20 | """ 21 | return get_template( 22 | ROOT_DIR / "templates" / "index.html", 23 | { 24 | "ADMIN_PREFIX": settings.ADMIN_PREFIX, 25 | }, 26 | ) 27 | -------------------------------------------------------------------------------- /examples/fastapi_ponyorm/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastadmin-fastapi-ponyorm" 3 | version = "0.1.0" 4 | description = "FastAdmin Example (FastAPI + Pony ORM)" 5 | authors = ["Dmitrii "] 6 | license = "MIT" 7 | homepage = "https://github.com/vsdudakov/fastadmin" 8 | repository = "https://github.com/vsdudakov/fastadmin" 9 | package-mode = false 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.10" 13 | fastapi = {"version" = "^0.112.4", extras = ["standard"]} 14 | fastadmin = {"version" = "^0.2.21", extras = ["fastapi", "pony"]} 15 | 16 | [build-system] 17 | requires = ["poetry-core>=1.0.0"] 18 | build-backend = "poetry.core.masonry.api" 19 | -------------------------------------------------------------------------------- /examples/fastapi_tortoiseorm/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastadmin-fastapi-tortoiseorm" 3 | version = "0.1.0" 4 | description = "FastAdmin Example (FastAPI + Tortoise ORM)" 5 | authors = ["Seva D "] 6 | license = "MIT" 7 | homepage = "https://github.com/vsdudakov/fastadmin" 8 | repository = "https://github.com/vsdudakov/fastadmin" 9 | package-mode = false 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.10" 13 | fastapi = {"version" = "^0.112.4", extras = ["standard"]} 14 | fastadmin = {"version" = "^0.2.21", extras = ["fastapi", "tortoise-orm"]} 15 | 16 | [build-system] 17 | requires = ["poetry-core>=1.0.0"] 18 | build-backend = "poetry.core.masonry.api" 19 | -------------------------------------------------------------------------------- /fastadmin/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Fast Admin 9 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /fastadmin/api/frameworks/fastapi/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import APIRouter 4 | from fastapi.responses import HTMLResponse 5 | 6 | from fastadmin.api.helpers import get_template 7 | from fastadmin.settings import ROOT_DIR, settings 8 | 9 | logger = logging.getLogger(__name__) 10 | router = APIRouter() 11 | 12 | 13 | @router.get("/", response_class=HTMLResponse) 14 | def index(): 15 | """This method is used to render index page. 16 | 17 | :params request: a request object. 18 | :return: A response object. 19 | """ 20 | return get_template( 21 | ROOT_DIR / "templates" / "index.html", 22 | { 23 | "ADMIN_PREFIX": settings.ADMIN_PREFIX, 24 | }, 25 | ) 26 | -------------------------------------------------------------------------------- /frontend/src/helpers/title.ts: -------------------------------------------------------------------------------- 1 | import type { IModel } from "@/interfaces/configuration"; 2 | 3 | export const getTitleFromFieldName = (value: string) => { 4 | return value 5 | .split("_") 6 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 7 | .join(" "); 8 | }; 9 | 10 | export const getTitleFromModel = (model: IModel, plural = false) => { 11 | if (plural && model.verbose_name_plural) { 12 | return model.verbose_name_plural; 13 | } 14 | if (model.verbose_name) { 15 | return plural ? `${model.verbose_name}s` : model.verbose_name; 16 | } 17 | const name = (model.name.match(/[A-Z][a-z]+|[0-9]+/g) || [model.name]).join( 18 | " ", 19 | ); 20 | return plural ? `${name}s` : name; 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/components/async-transfer/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { AsyncTransfer } from "@/components/async-transfer"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders AsyncTransfer", () => { 9 | const queryClient = new QueryClient(); 10 | 11 | const onChange = () => undefined; 12 | render( 13 | 14 | 21 | , 22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /examples/fastapi_sqlalchemy/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastadmin-fastapi-sqlalchemy2" 3 | version = "0.1.0" 4 | description = "FastAdmin Example (FastAPI + SQLAlchemy 2.*)" 5 | authors = ["Dmitrii ", "Seva D "] 6 | license = "MIT" 7 | homepage = "https://github.com/vsdudakov/fastadmin" 8 | repository = "https://github.com/vsdudakov/fastadmin" 9 | package-mode = false 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.10" 13 | fastapi = {"version" = "^0.112.4", extras = ["standard"]} 14 | fastadmin = {"version" = "^0.2.21", extras = ["fastapi", "sqlalchemy"]} 15 | aiosqlite = "^0.20.0" 16 | 17 | [build-system] 18 | requires = ["poetry-core>=1.0.0"] 19 | build-backend = "poetry.core.masonry.api" 20 | -------------------------------------------------------------------------------- /frontend/src/components/inline-widget/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { InlineWidget } from "@/components/inline-widget"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders AsyncTransfer", () => { 9 | const queryClient = new QueryClient(); 10 | render( 11 | 12 | 22 | , 23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /examples/django_djangoorm/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dev.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /tests/environment/django/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dev.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /tests/models/test_decorators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastadmin import ModelAdmin, register 4 | 5 | 6 | async def test_register(): 7 | class Model: 8 | pass 9 | 10 | class MyModelAdmin(ModelAdmin): 11 | pass 12 | 13 | assert register(Model)(MyModelAdmin) 14 | 15 | 16 | async def test_register_error(): 17 | class Model: 18 | pass 19 | 20 | class InvalidModelAdmin: 21 | pass 22 | 23 | class MyModelAdmin(ModelAdmin): 24 | pass 25 | 26 | with pytest.raises(ValueError, match="At least one model must be passed to register."): 27 | register()(MyModelAdmin) 28 | 29 | with pytest.raises(ValueError, match="Wrapped class must subclass ModelAdmin."): 30 | register(Model)(InvalidModelAdmin) 31 | -------------------------------------------------------------------------------- /docs/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | https://vsdudakov.github.io/fastadmin/ 12 | 2023-03-07T23:14:57+00:00 13 | 1.00 14 | 15 | 16 | https://vsdudakov.github.io/fastadmin/index.html 17 | 2023-03-07T23:14:57+00:00 18 | 0.80 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@/*": ["src/*"] 6 | }, 7 | "target": "ES2020", 8 | "useDefineForClassFields": true, 9 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 10 | "module": "ESNext", 11 | "skipLibCheck": true, 12 | 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react-jsx", 20 | 21 | /* Linting */ 22 | "strict": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noFallthroughCasesInSwitch": true 26 | }, 27 | "include": ["src"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } 30 | -------------------------------------------------------------------------------- /tests/api/frameworks/flask/test_app.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from uuid import uuid4 3 | 4 | from flask import Flask 5 | from werkzeug.exceptions import HTTPException 6 | 7 | from fastadmin.api.frameworks.flask.app import JSONProvider, exception_handler 8 | 9 | 10 | async def test_exception_handler(): 11 | assert exception_handler(Exception()) is not None 12 | assert exception_handler(HTTPException()) is not None 13 | 14 | 15 | async def test_json_provider(): 16 | today = datetime.now(timezone.utc).date() 17 | now = datetime.now(timezone.utc) 18 | uuid = uuid4() 19 | app = Flask(__name__) 20 | assert JSONProvider(app).default(today) == today.isoformat() 21 | assert JSONProvider(app).default(now) == now.isoformat() 22 | assert JSONProvider(app).default(uuid) == str(uuid) 23 | -------------------------------------------------------------------------------- /fastadmin/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | FastAPI Admin 9 | 13 | 17 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | import EnvironmentPlugin from "vite-plugin-environment"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), EnvironmentPlugin("all", { prefix: "" })], 8 | resolve: { 9 | alias: [{ find: "@", replacement: "/src" }], 10 | }, 11 | build: { 12 | rollupOptions: { 13 | output: { 14 | dir: '../fastadmin/static/', 15 | entryFileNames: 'index.min.js', 16 | assetFileNames: 'index.min.css', 17 | chunkFileNames: "chunk.min.js", 18 | manualChunks: undefined, 19 | } 20 | }, 21 | target: "es2015", 22 | lib: { 23 | entry: "src/main.tsx", 24 | formats: ["umd"], 25 | name: "App", 26 | }, 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /frontend/src/components/form-container/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { render } from "@testing-library/react"; 3 | import { test } from "vitest"; 4 | 5 | import { FormContainer } from "@/components/form-container"; 6 | import { TestProviders } from "@/providers"; 7 | 8 | test("Renders FormContainer", () => { 9 | const queryClient = new QueryClient(); 10 | 11 | const onFinish = () => undefined; 12 | render( 13 | 14 | 25 |
26 | 27 | , 28 | ); 29 | }); 30 | -------------------------------------------------------------------------------- /docs/templates/block.md: -------------------------------------------------------------------------------- 1 | {% if section_block.type == 'text' %} 2 | {{section_block.content | safe}} 3 | {% endif %} 4 | 5 | {% if section_block.type == 'text-lead' %} 6 | {{section_block.content | safe}} 7 | {% endif %} 8 | 9 | {% if section_block.type == 'alert-info' %} 10 | {{section_block.content | safe}} 11 | {% endif %} 12 | 13 | {% if section_block.type == 'alert-warning' %} 14 | {{section_block.content | safe}} 15 | {% endif %} 16 | 17 | {% if section_block.type == 'code-bash' %} 18 | ```bash 19 | {{section_block.content | safe}} 20 | ``` 21 | {% endif %} 22 | 23 | {% if section_block.type == 'code-python' %} 24 | ```python 25 | {{section_block.content | safe}} 26 | ``` 27 | {% endif %} 28 | 29 | {% if section_block.type == 'tabs' %} 30 | {% for tab in section_block.content %} 31 | ### {{tab.name}} 32 | {% for section_block in tab.content %} 33 | {% include "templates/block.md" %} 34 | {% endfor %} 35 | 36 | {% endfor %} 37 | {% endif %} 38 | -------------------------------------------------------------------------------- /fastadmin/api/frameworks/fastapi/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import FastAPI, Request 4 | from fastapi.responses import JSONResponse 5 | from fastapi.staticfiles import StaticFiles 6 | 7 | from fastadmin.api.frameworks.fastapi.api import router as api_router 8 | from fastadmin.api.frameworks.fastapi.views import router as views_router 9 | from fastadmin.settings import ROOT_DIR 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | app = FastAPI( 14 | title="FastAdmin App", 15 | openapi_url=None, 16 | ) 17 | app.mount( 18 | "/static", 19 | StaticFiles(directory=str(ROOT_DIR / "static")), 20 | name="static", 21 | ) 22 | app.include_router(api_router) 23 | app.include_router(views_router) 24 | 25 | 26 | @app.exception_handler(Exception) 27 | async def exception_handler(_: Request, exc: Exception): 28 | return JSONResponse( 29 | status_code=500, 30 | content={"exception": str(exc)}, 31 | ) 32 | -------------------------------------------------------------------------------- /frontend/src/fetchers/fetchers.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const requestOptions = () => { 4 | return { withCredentials: true }; 5 | }; 6 | 7 | const instance = axios.create({ 8 | baseURL: (window as any).SERVER_URL, 9 | ...requestOptions(), 10 | }); 11 | 12 | export const getFetcher = async (url: string): Promise => { 13 | const response = await instance.get(url); 14 | return response.data; 15 | }; 16 | 17 | export const postFetcher = async (url: string, payload: any): Promise => { 18 | const response = await instance.post(url, payload); 19 | return response.data; 20 | }; 21 | 22 | export const patchFetcher = async (url: string, payload: any): Promise => { 23 | const response = await instance.patch(url, payload); 24 | return response.data; 25 | }; 26 | 27 | export const deleteFetcher = async (url: string): Promise => { 28 | const response = await instance.delete(url); 29 | return response.data; 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/src/components/slug-input/index.tsx: -------------------------------------------------------------------------------- 1 | import { SwapOutlined } from "@ant-design/icons"; 2 | import { Button, Input, Space, Tooltip } from "antd"; 3 | import type React from "react"; 4 | 5 | import { useTranslation } from "react-i18next"; 6 | import slugify from "slugify"; 7 | 8 | interface ISlugInputProps { 9 | value?: any; 10 | 11 | onChange?: (data: any) => void; 12 | } 13 | 14 | export const SlugInput: React.FC = ({ 15 | value, 16 | onChange, 17 | ...props 18 | }) => { 19 | const { t: _t } = useTranslation("SlugInput"); 20 | 21 | const onSwap = () => { 22 | if (onChange) onChange(slugify(value)); 23 | }; 24 | return ( 25 | 26 | 27 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/components/json-textarea/index.tsx: -------------------------------------------------------------------------------- 1 | import { Input, theme } from "antd"; 2 | import type React from "react"; 3 | 4 | import { isString } from "@/helpers/transform"; 5 | 6 | interface IJsonTextAreaProps { 7 | value?: any; 8 | 9 | onChange?: (data: any) => void; 10 | } 11 | 12 | const { useToken } = theme; 13 | 14 | export const JsonTextArea: React.FC = ({ 15 | value, 16 | onChange, 17 | ...props 18 | }) => { 19 | const { token } = useToken(); 20 | const jsonValue = !isString(value) 21 | ? JSON.stringify(value, null, "\t") 22 | : value; 23 | const rowsCount = jsonValue?.split(/\r\n|\r|\n/)?.length; 24 | return ( 25 | 30 ? 30 : rowsCount} 27 | value={jsonValue} 28 | onChange={onChange} 29 | style={{ 30 | backgroundColor: token.colorTextBase, 31 | color: token.colorBgBase, 32 | border: "none", 33 | }} 34 | {...props} 35 | /> 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import enUS from "antd/es/locale/en_US"; 3 | import i18next from "i18next"; 4 | import React from "react"; 5 | import ReactDOM from "react-dom/client"; 6 | import "@ant-design/v5-patch-for-react-19"; 7 | 8 | import { App } from "@/containers/app/index.tsx"; 9 | import { ExternalProviders, InternalProviders } from "@/providers.tsx"; 10 | 11 | import "./index.css"; 12 | 13 | const queryClient = new QueryClient(); 14 | 15 | i18next.init({ 16 | interpolation: { escapeValue: false }, // React already does escaping 17 | }); 18 | 19 | const root = document.getElementById("root"); 20 | if (!root) throw new Error("Root element not found"); 21 | 22 | ReactDOM.createRoot(root).render( 23 | 24 | 25 | 26 | 27 | 28 | 29 | , 30 | ); 31 | -------------------------------------------------------------------------------- /docs/code/quick_tutorial/djangoorm.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from fastadmin import DjangoModelAdmin, register 4 | 5 | 6 | class User(models.Model): 7 | username = models.CharField(max_length=255, unique=True) 8 | hash_password = models.CharField(max_length=255) 9 | is_superuser = models.BooleanField(default=False) 10 | is_active = models.BooleanField(default=False) 11 | 12 | def __str__(self): 13 | return self.username 14 | 15 | 16 | @register(User) 17 | class UserAdmin(DjangoModelAdmin): 18 | exclude = ("hash_password",) 19 | list_display = ("id", "username", "is_superuser", "is_active") 20 | list_display_links = ("id", "username") 21 | list_filter = ("id", "username", "is_superuser", "is_active") 22 | search_fields = ("username",) 23 | 24 | def authenticate(self, username, password): 25 | obj = User.objects.filter(username=username, is_superuser=True).first() 26 | if not obj: 27 | return None 28 | if not obj.check_password(password): 29 | return None 30 | return obj.id 31 | -------------------------------------------------------------------------------- /frontend/src/containers/index/index.tsx: -------------------------------------------------------------------------------- 1 | import { Col, Empty, Row } from "antd"; 2 | import type React from "react"; 3 | import { useContext } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | import { CrudContainer } from "@/components/crud-container"; 7 | import { DashboardWidget } from "@/components/dashboard-widget"; 8 | import type { IDashboardWidget } from "@/interfaces/configuration"; 9 | import { ConfigurationContext } from "@/providers/ConfigurationProvider"; 10 | 11 | export const Index: React.FC = () => { 12 | const { t: _t } = useTranslation("Dashboard"); 13 | const { configuration } = useContext(ConfigurationContext); 14 | return ( 15 | 16 | 17 | {configuration.dashboard_widgets.map((widget: IDashboardWidget) => ( 18 |
19 | 20 | 21 | ))} 22 | 23 | {configuration.dashboard_widgets.length === 0 && } 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Seva D 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/src/providers/SignInUserProvider/provider.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import type React from "react"; 3 | 4 | import { getFetcher } from "@/fetchers/fetchers"; 5 | 6 | import type { IMe } from "@/interfaces/user"; 7 | import { SignInUserContext } from "."; 8 | 9 | interface ISignInUserProvider { 10 | children?: React.ReactNode; 11 | } 12 | 13 | // export const SignInUserConsumer = SignInUserContext.Consumer; 14 | 15 | export const SignInUserProvider = ({ children }: ISignInUserProvider) => { 16 | const signedInData = useQuery({ 17 | queryKey: ["/me"], 18 | queryFn: () => getFetcher("/me"), 19 | retry: false, 20 | refetchOnWindowFocus: false, 21 | }); 22 | if (signedInData.isLoading) { 23 | return null; 24 | } 25 | const isNotAuth = !!signedInData.isError; 26 | return ( 27 | 34 | {children} 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/components/sign-in-container/index.tsx: -------------------------------------------------------------------------------- 1 | import { Col, Row } from "antd"; 2 | import type React from "react"; 3 | import { useContext, useEffect } from "react"; 4 | import { Helmet } from "react-helmet-async"; 5 | import { useNavigate } from "react-router-dom"; 6 | 7 | import { SignInUserContext } from "@/providers/SignInUserProvider"; 8 | 9 | interface ISignInContainer { 10 | title: string; 11 | children: React.ReactNode; 12 | } 13 | 14 | export const SignInContainer: React.FC = ({ 15 | title, 16 | children, 17 | }) => { 18 | const { signedIn } = useContext(SignInUserContext); 19 | const navigate = useNavigate(); 20 | 21 | useEffect(() => { 22 | if (signedIn) { 23 | navigate("/"); 24 | } 25 | }, [navigate, signedIn]); 26 | 27 | return ( 28 |
29 | 30 | 31 | 32 | 33 |
34 | {children} 35 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /fastadmin/api/frameworks/flask/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import date, time 3 | 4 | from flask import Blueprint 5 | from flask.json.provider import DefaultJSONProvider 6 | from werkzeug.exceptions import HTTPException 7 | 8 | from fastadmin.api.frameworks.flask.api import api_router 9 | from fastadmin.api.frameworks.flask.views import views_router 10 | from fastadmin.settings import ROOT_DIR 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class JSONProvider(DefaultJSONProvider): 16 | def default(self, o): 17 | if isinstance(o, date | time): 18 | return o.isoformat() 19 | return super().default(o) 20 | 21 | 22 | app = Blueprint( 23 | "FastAdmin App", 24 | __name__, 25 | url_prefix="/parent", 26 | static_url_path="/static", 27 | static_folder=ROOT_DIR / "static", 28 | ) 29 | app.register_blueprint(views_router) 30 | app.register_blueprint(api_router) 31 | 32 | 33 | @app.errorhandler(Exception) 34 | def exception_handler(exc): 35 | if isinstance(exc, HTTPException): 36 | return exc 37 | return { 38 | "status_code": 500, 39 | "content": {"exception": str(exc)}, 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: FastAdmin CI 2 | 3 | on: [create, push] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | ci: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Install poetry 15 | run: pipx install poetry 16 | - name: Set up Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: '3.13' 20 | cache: "poetry" 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: v22.4.1 25 | cache: 'yarn' 26 | cache-dependency-path: 'frontend/yarn.lock' 27 | - name: Install Dependencies 28 | run: make install 29 | - name: Run Lint 30 | run: make lint 31 | - name: Run Tests 32 | env: 33 | ADMIN_USER_MODEL: User 34 | ADMIN_USER_MODEL_USERNAME_FIELD: username 35 | ADMIN_SECRET_KEY: secret_key 36 | run: make test 37 | - name: Run Build 38 | run: make -C frontend build 39 | - name: Upload coverage reports to Codecov 40 | uses: codecov/codecov-action@v3 41 | -------------------------------------------------------------------------------- /frontend/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help # Shows this message 2 | help: 3 | @grep '^.PHONY: .* #' Makefile | sed 's/\.PHONY: \(.*\) # \(.*\)/\1 \2/' | expand -t20 4 | 5 | 6 | .PHONY: clean # Cleaning artefacts 7 | clean: 8 | find . | grep -E "(dist$$|node_modules$$)" | xargs rm -rf 9 | 10 | 11 | .PHONY: install # Install 12 | install: 13 | @echo "Install" 14 | @exec yarn install 15 | 16 | 17 | .PHONY: check # Runs linters 18 | check: 19 | @echo "Run linters" 20 | @exec yarn run lint 21 | @exec yarn run biome-lint 22 | 23 | 24 | .PHONY: fix # Runs linters and fixes auto-fixable errors 25 | fix: 26 | @echo "Fix linters" 27 | @exec yarn run lint --fix 28 | @exec yarn run biome-check 29 | 30 | 31 | .PHONY: lint # Runs linters 32 | lint: 33 | @echo "Run linters" 34 | @exec yarn run lint 35 | @exec yarn run biome-lint 36 | 37 | 38 | .PHONY: test # Runs tests 39 | test: 40 | @echo "Run tests" 41 | @exec yarn run coverage 42 | 43 | 44 | .PHONY: dev # Run dev server 45 | dev: 46 | @exec yarn run dev --host 0.0.0.0 --port 3030 47 | 48 | 49 | .PHONY: remove_cache # Remove cache 50 | remove_cache: 51 | rm -rf node_modules/.cache 52 | 53 | 54 | .PHONY: build # Build prod server 55 | build: 56 | @exec yarn run build 57 | -------------------------------------------------------------------------------- /tests/api/test_export.py: -------------------------------------------------------------------------------- 1 | async def test_export(session_id, event, client): 2 | assert session_id 3 | async with client.stream( 4 | "POST", 5 | f"/api/export/{event.get_model_name()}", 6 | json={}, 7 | ) as r: 8 | assert r.status_code == 200, r.text 9 | rows = [] 10 | async for line in r.aiter_lines(): 11 | rows.append(line) 12 | assert rows 13 | 14 | 15 | async def test_export_405(session_id, event, client): 16 | assert session_id 17 | r = await client.get( 18 | f"/api/export/{event.get_model_name()}", 19 | ) 20 | assert r.status_code == 405, r.text 21 | 22 | 23 | async def test_export_401(event, client): 24 | async with client.stream( 25 | "POST", 26 | f"/api/export/{event.get_model_name()}", 27 | json={}, 28 | ) as r: 29 | assert r.status_code == 401, r.text 30 | 31 | 32 | async def test_export_404(session_id, admin_models, event, client): 33 | assert session_id 34 | del admin_models[event.__class__] 35 | async with client.stream( 36 | "POST", 37 | f"/api/export/{event.get_model_name()}", 38 | json={}, 39 | ) as r: 40 | assert r.status_code == 404, r.text 41 | -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { fixupConfigRules } from "@eslint/compat"; 2 | import reactRefresh from "eslint-plugin-react-refresh"; 3 | import globals from "globals"; 4 | import tsParser from "@typescript-eslint/parser"; 5 | import path from "node:path"; 6 | import { fileURLToPath } from "node:url"; 7 | import js from "@eslint/js"; 8 | import { FlatCompat } from "@eslint/eslintrc"; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all 16 | }); 17 | 18 | export default [{ 19 | ignores: ["**/dist", "**/.eslintrc.cjs"], 20 | }, ...fixupConfigRules(compat.extends( 21 | "eslint:recommended", 22 | "plugin:@typescript-eslint/recommended", 23 | "plugin:react-hooks/recommended", 24 | )), { 25 | plugins: { 26 | "react-refresh": reactRefresh, 27 | }, 28 | languageOptions: { 29 | globals: { 30 | ...globals.browser, 31 | }, 32 | parser: tsParser, 33 | }, 34 | rules: { 35 | "react-refresh/only-export-components": "off", 36 | "@typescript-eslint/no-explicit-any": "off", 37 | }, 38 | }]; -------------------------------------------------------------------------------- /frontend/src/components/texteditor-field/index.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import ReactQuill from "react-quill-new"; 3 | 4 | import "react-quill-new/dist/quill.snow.css"; 5 | 6 | interface ITextEditorFieldProps { 7 | value?: any; 8 | 9 | onChange?: (value: any) => void; 10 | } 11 | 12 | export const TextEditor: React.FC = ({ 13 | value, 14 | onChange, 15 | ...props 16 | }) => { 17 | return ( 18 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /frontend/src/components/phone-number-input/index.tsx: -------------------------------------------------------------------------------- 1 | import { theme } from "antd"; 2 | import type React from "react"; 3 | import PhoneInput from "react-phone-input-2"; 4 | 5 | import "react-phone-input-2/lib/style.css"; 6 | 7 | interface IPhoneFieldProps { 8 | value?: any; 9 | 10 | onChange?: (value: any) => void; 11 | } 12 | 13 | const { useToken } = theme; 14 | 15 | export const PhoneNumberInput: React.FC = ({ 16 | value, 17 | onChange, 18 | ...props 19 | }) => { 20 | const { token } = useToken(); 21 | return ( 22 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /docs/assets/images/header-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /fastadmin/static/images/header-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /fastadmin/static/images/sign-in-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /frontend/src/providers.tsx: -------------------------------------------------------------------------------- 1 | import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 | import { ConfigProvider } from "antd"; 3 | import i18next from "i18next"; 4 | import { HelmetProvider } from "react-helmet-async"; 5 | import { I18nextProvider } from "react-i18next"; 6 | import { HashRouter } from "react-router-dom"; 7 | 8 | import { ConfigurationProvider } from "@/providers/ConfigurationProvider/provider"; 9 | import { SignInUserProvider } from "@/providers/SignInUserProvider/provider"; 10 | 11 | interface IInternalProviders { 12 | children?: React.ReactNode; 13 | } 14 | 15 | export const InternalProviders: React.FC = ({ 16 | children, 17 | }) => ( 18 | 19 | {children} 20 | 21 | ); 22 | 23 | interface IExternalProviders { 24 | children?: React.ReactNode; 25 | client: QueryClient; 26 | 27 | locale?: any; 28 | 29 | i18n?: any; 30 | } 31 | 32 | export const ExternalProviders: React.FC = ({ 33 | children, 34 | client, 35 | i18n, 36 | }) => ( 37 | 38 | 39 | 40 | {children} 41 | 42 | 43 | 44 | ); 45 | 46 | export const TestProviders: React.FC = ({ 47 | children, 48 | client, 49 | }) => ( 50 | 51 | {children} 52 | 53 | ); 54 | -------------------------------------------------------------------------------- /fastadmin/api/frameworks/django/app/urls.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.urls import path, re_path 4 | from django.views.static import serve 5 | 6 | from fastadmin.settings import ROOT_DIR 7 | 8 | from .api import ( 9 | action, 10 | add, 11 | change, 12 | change_password, 13 | configuration, 14 | dashboard_widget, 15 | delete, 16 | export, 17 | get, 18 | list_objs, 19 | me, 20 | sign_in, 21 | sign_out, 22 | ) 23 | from .views import index 24 | 25 | 26 | def get_admin_urls(): 27 | return ( 28 | [ 29 | path("", index), 30 | path("api/sign-in", sign_in), 31 | path("api/sign-out", sign_out), 32 | path("api/me", me), 33 | path("api/dashboard-widget/", dashboard_widget), 34 | path("api/list/", list_objs), 35 | path("api/retrieve//", get), 36 | path("api/add/", add), 37 | path("api/change-password/", change_password), 38 | path("api/change//", change), 39 | path("api/export/", export), 40 | path("api/delete//", delete), 41 | path("api/action//", action), 42 | path("api/configuration", configuration), 43 | re_path( 44 | r"^%s(?P.*)$" % re.escape("static"), # noqa: UP031 45 | serve, 46 | kwargs={"document_root": ROOT_DIR / "static"}, 47 | ), 48 | ], 49 | "admin", 50 | "FastAdmin", 51 | ) 52 | -------------------------------------------------------------------------------- /frontend/src/helpers/forms.ts: -------------------------------------------------------------------------------- 1 | import { message } from "antd"; 2 | import type { FormInstance } from "rc-field-form"; 3 | 4 | export const handleError = (error: any, form?: FormInstance) => { 5 | const errors = 6 | error?.response?.data?.detail || error?.response?.data?.description; 7 | if (!Array.isArray(errors)) { 8 | if (typeof errors === "string" || errors instanceof String) { 9 | message.error(errors); 10 | } else { 11 | message.error("Server error"); 12 | } 13 | return; 14 | } 15 | 16 | const errorsData: any = {}; 17 | for (const item of errors) { 18 | const fieldArray = item?.loc || []; 19 | const field = fieldArray[fieldArray.length - 1]; 20 | if (!field || !item?.msg) { 21 | continue; 22 | } 23 | errorsData[field] = [item?.msg]; 24 | } 25 | 26 | if (!form) { 27 | message.error("Server error"); 28 | return; 29 | } 30 | 31 | const errorsFields: any = []; 32 | const fields = Object.keys(form.getFieldsValue()).filter( 33 | (key) => key in errorsData, 34 | ); 35 | for (const field of fields) { 36 | errorsFields.push({ 37 | name: field, 38 | errors: errorsData[field], 39 | }); 40 | } 41 | 42 | if (errorsFields.length > 0) { 43 | form.setFields(errorsFields); 44 | } 45 | }; 46 | 47 | export const cleanFormErrors = (form: FormInstance) => { 48 | const errorsFields: any = []; 49 | const fields = Object.keys(form.getFieldsValue()); 50 | 51 | for (const field of fields) { 52 | errorsFields.push({ 53 | name: field, 54 | errors: [], 55 | }); 56 | } 57 | 58 | form.setFields(errorsFields); 59 | }; 60 | -------------------------------------------------------------------------------- /fastadmin/api/schemas.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from uuid import UUID 4 | 5 | 6 | class ExportFormat(str, Enum): 7 | """Export format""" 8 | 9 | CSV = "CSV" 10 | JSON = "JSON" 11 | 12 | 13 | @dataclass 14 | class DashboardWidgetQuerySchema: 15 | """DashboardWidge query schema""" 16 | 17 | min_x_field: str | None = None 18 | max_x_field: str | None = None 19 | period_x_field: str | None = None 20 | 21 | 22 | @dataclass 23 | class DashboardWidgetDataOutputSchema: 24 | """Dashboard widget data output schema""" 25 | 26 | results: list[dict[str, str | int | float]] 27 | min_x_field: str | None = None 28 | max_x_field: str | None = None 29 | period_x_field: str | None = None 30 | 31 | 32 | @dataclass 33 | class ListQuerySchema: 34 | """List query schema""" 35 | 36 | limit: int | None = 10 37 | offset: int | None = 0 38 | sort_by: str | None = None 39 | search: str | None = None 40 | filters: dict[str, str] | None = None 41 | 42 | 43 | @dataclass 44 | class SignInInputSchema: 45 | """Sign in input schema""" 46 | 47 | username: str 48 | password: str 49 | 50 | 51 | @dataclass 52 | class ChangePasswordInputSchema: 53 | """Change password input schema""" 54 | 55 | password: str 56 | confirm_password: str 57 | 58 | 59 | @dataclass 60 | class ExportInputSchema: 61 | """Export input schema""" 62 | 63 | format: ExportFormat | None = ExportFormat.CSV 64 | limit: int | None = 1000 65 | offset: int | None = 0 66 | 67 | 68 | @dataclass 69 | class ActionInputSchema: 70 | """Action input schema""" 71 | 72 | ids: list[int | UUID] 73 | -------------------------------------------------------------------------------- /tests/api/test_delete.py: -------------------------------------------------------------------------------- 1 | from fastadmin.api.service import get_user_id_from_session_id 2 | 3 | 4 | async def test_delete(session_id, event, client): 5 | assert session_id 6 | 7 | r = await client.delete( 8 | f"/api/delete/{event.get_model_name()}/{event.id}", 9 | ) 10 | assert r.status_code == 200, r.text 11 | obj_id = r.json() 12 | assert str(obj_id) == str(event.id) 13 | 14 | 15 | async def test_configuration_405(session_id, event, client): 16 | assert session_id 17 | r = await client.get( 18 | f"/api/delete/{event.get_model_name()}/{event.id}", 19 | ) 20 | assert r.status_code == 405, r.text 21 | 22 | 23 | async def test_delete_401(event, client): 24 | r = await client.delete( 25 | f"/api/delete/{event.get_model_name()}/{event.id}", 26 | ) 27 | assert r.status_code == 401, r.text 28 | 29 | 30 | async def test_delete_404(session_id, admin_models, event, client): 31 | assert session_id 32 | del admin_models[event.__class__] 33 | r = await client.delete( 34 | f"/api/delete/{event.get_model_name()}/{event.id}", 35 | ) 36 | assert r.status_code == 404, r.text 37 | 38 | 39 | async def test_delete_403(session_id, superuser, client): 40 | assert session_id 41 | user_id = await get_user_id_from_session_id(session_id) 42 | assert user_id 43 | r = await client.delete( 44 | f"/api/delete/{superuser.get_model_name()}/{user_id}", 45 | ) 46 | assert r.status_code == 403, r.text 47 | 48 | 49 | async def test_delete_422(event, client): 50 | r = await client.delete( 51 | f"/api/delete/{event.get_model_name()}/invalid", 52 | ) 53 | assert r.status_code == 422, r.text 54 | -------------------------------------------------------------------------------- /tests/models/test_helpers.py: -------------------------------------------------------------------------------- 1 | from fastadmin import ModelAdmin, display 2 | from fastadmin.models.helpers import ( 3 | generate_models_schema, 4 | get_admin_model, 5 | register_admin_model_class, 6 | unregister_admin_model_class, 7 | ) 8 | 9 | 10 | async def test_uregister_admin_model_class(): 11 | class AdminModelClass(ModelAdmin): 12 | pass 13 | 14 | class OrmModelClass: 15 | pass 16 | 17 | register_admin_model_class(AdminModelClass, [OrmModelClass]) 18 | assert get_admin_model(OrmModelClass.__name__) 19 | assert get_admin_model(OrmModelClass) 20 | unregister_admin_model_class([OrmModelClass]) 21 | assert not get_admin_model(OrmModelClass.__name__) 22 | assert not get_admin_model(OrmModelClass) 23 | 24 | 25 | async def test_admin_model_list_configuration_ordering(tournament, base_model_admin): 26 | Tournament = tournament.__class__ 27 | 28 | class TournamentModelAdmin(base_model_admin): 29 | list_display = ("pseudo_name", "name", "another_calculated_field") 30 | 31 | @display 32 | def pseudo_name(self, obj: Tournament) -> str: 33 | return "Pseudo name" 34 | 35 | @display 36 | def another_calculated_field(self, obj: Tournament) -> int: 37 | return 0 38 | 39 | model_schema = await generate_models_schema({Tournament: TournamentModelAdmin(Tournament)}) 40 | 41 | assert model_schema 42 | assert len(model_schema) == 1 43 | 44 | list_display = [ 45 | (field.list_configuration.index, field.name) for field in model_schema[0].fields if field.list_configuration 46 | ] 47 | list_display.sort() 48 | list_display = tuple(name for index, name in list_display) 49 | 50 | assert list_display == TournamentModelAdmin.list_display 51 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean 2 | clean: 3 | @exec find . -type d -name "__pycache__" -exec rm -rf {} + > /dev/null 2>&1 4 | @exec find . -type f -name "*.pyc" -exec rm -rf {} + > /dev/null 2>&1 5 | @exec rm -rf htmlcov 6 | @exec rm -rf .coverage 7 | 8 | .PHONY: fix 9 | fix: 10 | @echo "Run ruff" 11 | @exec poetry run ruff check --fix fastadmin tests docs examples 12 | @echo "Run isort" 13 | @exec poetry run isort fastadmin tests docs examples 14 | @echo "Run black" 15 | @exec poetry run black fastadmin tests docs examples 16 | @echo "Run mypy" 17 | @exec poetry run mypy -p fastadmin -p docs 18 | @echo "Run frontend linters" 19 | @exec make -C frontend fix 20 | 21 | .PHONY: lint 22 | lint: 23 | @echo "Run ruff" 24 | @exec poetry run ruff check fastadmin tests docs examples 25 | @echo "Run isort" 26 | @exec poetry run isort --check-only fastadmin tests docs examples 27 | @echo "Run black" 28 | @exec poetry run black --check --diff fastadmin tests docs examples 29 | @echo "Run mypy" 30 | @exec poetry run mypy -p fastadmin -p docs 31 | @echo "Run frontend linters" 32 | @exec make -C frontend lint 33 | 34 | # -n auto : fix django 35 | .PHONY: test 36 | test: 37 | @exec poetry run pytest -n 1 --cov=fastadmin --cov-report=term-missing --cov-report=xml --cov-fail-under=80 -s tests 38 | @exec make -C frontend test 39 | 40 | .PHONY: kill 41 | kill: 42 | @exec kill -9 $$(lsof -t -i:8090) 43 | @exec kill -9 $$(lsof -t -i:3030) 44 | 45 | .PHONY: install 46 | install: 47 | @exec pip install poetry 48 | @exec poetry install --all-extras 49 | @exec make -C frontend install 50 | 51 | .PHONY: docs 52 | docs: 53 | @exec make -C docs build 54 | 55 | .PHONY: build 56 | build: 57 | @exec make docs 58 | @exec make -C frontend build 59 | 60 | .PHONY: push 61 | pre-push: 62 | @exec make fix 63 | @exec make lint 64 | @exec make docs 65 | @exec make build 66 | -------------------------------------------------------------------------------- /docs/templates/block.html: -------------------------------------------------------------------------------- 1 | {% if section_block.type == 'text' %} 2 |

3 | {{section_block.content | safe}} 4 |

5 | {% endif %} 6 | 7 | {% if section_block.type == 'text-lead' %} 8 |

9 | {{section_block.content | safe}} 10 |

11 | {% endif %} 12 | 13 | {% if section_block.type == 'alert-info' %} 14 |

15 | {{section_block.content | safe}} 16 |

17 | {% endif %} 18 | 19 | {% if section_block.type == 'alert-warning' %} 20 |

21 | {{section_block.content | safe}} 22 |

23 | {% endif %} 24 | 25 | {% if section_block.type == 'code-bash' %} 26 |
27 |   
28 | {{section_block.content | safe}}
29 |   
30 | 
31 | {% endif %} 32 | 33 | {% if section_block.type == 'code-python' %} 34 |
35 |   
36 | {{section_block.content | safe}}
37 |   
38 | 
39 | {% endif %} 40 | 41 | {% if section_block.type == 'tabs' %} 42 | 49 |
50 | {% for tab in section_block.content %} 51 |
52 | {% for section_block in tab.content %} 53 | {% include "templates/block.html" %} 54 | {% endfor %} 55 |
56 | {% endfor %} 57 |
58 | {% endif %} 59 | -------------------------------------------------------------------------------- /fastadmin/__init__.py: -------------------------------------------------------------------------------- 1 | # api 2 | import logging 3 | 4 | try: 5 | from fastadmin.api.frameworks.django.app.urls import get_admin_urls as get_django_admin_urls # noqa: F401 6 | from fastadmin.models.orms.django import DjangoInlineModelAdmin, DjangoModelAdmin # noqa: F401 7 | except ModuleNotFoundError: # pragma: no cover 8 | logging.info("Django is not installed") # pragma: no cover 9 | 10 | try: 11 | from fastadmin.api.frameworks.fastapi.app import app as fastapi_app # noqa: F401 12 | except ModuleNotFoundError: # pragma: no cover 13 | logging.info("FastAPI is not installed") # pragma: no cover 14 | 15 | try: 16 | from fastadmin.api.frameworks.flask.app import app as flask_app # noqa: F401 17 | except ModuleNotFoundError: # pragma: no cover 18 | logging.info("Flask is not installed") # pragma: no cover 19 | 20 | try: 21 | from fastadmin.models.orms.ponyorm import PonyORMInlineModelAdmin, PonyORMModelAdmin # noqa: F401 22 | except ModuleNotFoundError: # pragma: no cover 23 | logging.info("PonyORM is not installed") # pragma: no cover 24 | 25 | try: 26 | from fastadmin.models.orms.sqlalchemy import SqlAlchemyInlineModelAdmin, SqlAlchemyModelAdmin # noqa: F401 27 | except ModuleNotFoundError: # pragma: no cover 28 | logging.info("SQLAlchemy is not installed") # pragma: no cover 29 | 30 | try: 31 | from fastadmin.models.orms.tortoise import TortoiseInlineModelAdmin, TortoiseModelAdmin # noqa: F401 32 | except ModuleNotFoundError: # pragma: no cover 33 | logging.info("TortoiseORM is not installed") # pragma: no cover 34 | 35 | # models 36 | from fastadmin.models.base import DashboardWidgetAdmin, InlineModelAdmin, ModelAdmin # noqa: F401 37 | from fastadmin.models.decorators import action, display, register, register_widget # noqa: F401 38 | from fastadmin.models.helpers import register_admin_model_class, unregister_admin_model_class # noqa: F401 39 | from fastadmin.models.schemas import DashboardWidgetType, WidgetType # noqa: F401 40 | -------------------------------------------------------------------------------- /frontend/src/providers/ConfigurationProvider/provider.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { Button, Popover, Result } from "antd"; 3 | import type React from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | import { getFetcher } from "@/fetchers/fetchers"; 7 | import type { IConfiguration } from "@/interfaces/configuration"; 8 | 9 | import { ConfigurationContext, defaultConfiguration } from "."; 10 | 11 | interface IConfigurationProvider { 12 | children?: React.ReactNode; 13 | } 14 | 15 | // export const ConfigurationConsumer = ConfigurationContext.Consumer; 16 | 17 | export const ConfigurationProvider = ({ children }: IConfigurationProvider) => { 18 | const { t: _t } = useTranslation("ConfigurationProvider"); 19 | const configurationData = useQuery({ 20 | queryKey: ["/configuration"], 21 | queryFn: () => getFetcher("/configuration"), 22 | retry: false, 23 | refetchOnWindowFocus: false, 24 | }); 25 | if (configurationData.isLoading) { 26 | return null; 27 | } 28 | if (configurationData.error) { 29 | const error: any = configurationData.error; 30 | return ( 31 | 41 | 44 | 45 | } 46 | /> 47 | ); 48 | } 49 | return ( 50 | 56 | {children} 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "biome-check": "biome check --write --unsafe src", 11 | "biome-lint": "biome lint src", 12 | "preview": "vite preview", 13 | "test": "vitest --environment jsdom --run", 14 | "coverage": "vitest --environment jsdom --run --coverage" 15 | }, 16 | "dependencies": { 17 | "@ant-design/charts": "^2.2.7", 18 | "@ant-design/v5-patch-for-react-19": "^1.0.3", 19 | "@tanstack/react-query": "^5.74.3", 20 | "antd": "^5.24.7", 21 | "antd-img-crop": "^4.21.0", 22 | "axios": "^1.8.4", 23 | "getbase64data": "^1.0.8", 24 | "i18next": "^24.2.3", 25 | "js-file-download": "^0.4.12", 26 | "lodash-es": "^4.17.21", 27 | "lodash.debounce": "^4.0.8", 28 | "query-string": "^9.0.0", 29 | "react": "^19.1.0", 30 | "react-dom": "^19.1.0", 31 | "react-helmet-async": "^2.0.4", 32 | "react-i18next": "^15.4.1", 33 | "react-phone-input-2": "^2.15.1", 34 | "react-quill-new": "^3.4.6", 35 | "react-router-dom": "^7.5.0", 36 | "slugify": "^1.6.6" 37 | }, 38 | "devDependencies": { 39 | "@biomejs/biome": "^1.6.1", 40 | "@eslint/compat": "^1.2.8", 41 | "@testing-library/dom": "^10.4.0", 42 | "@testing-library/react": "^16.3.0", 43 | "@types/lodash.debounce": "^4.0.9", 44 | "@types/react": "^19.1.2", 45 | "@types/react-dom": "^19.1.2", 46 | "@typescript-eslint/eslint-plugin": "^8.30.1", 47 | "@typescript-eslint/parser": "^8.30.1", 48 | "@vitejs/plugin-react-swc": "^3.8.1", 49 | "@vitest/coverage-v8": "^3.1.1", 50 | "eslint": "^9.24.0", 51 | "eslint-plugin-react-hooks": "^5.2.0", 52 | "eslint-plugin-react-refresh": "^0.4.19", 53 | "jsdom": "^26.1.0", 54 | "typescript": "^5.8.3", 55 | "vite": "^6.2.6", 56 | "vite-plugin-environment": "^1.1.3", 57 | "vitest": "^3.1.1" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/api/test_retrieve.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | async def test_retrieve(session_id, admin_models, event, client): 5 | assert session_id 6 | event_admin_model = admin_models[event.__class__] 7 | 8 | r = await client.get( 9 | f"/api/retrieve/{event.get_model_name()}/{event.id}", 10 | ) 11 | assert r.status_code == 200, r.text 12 | item = r.json() 13 | updated_event = await event_admin_model.get_obj(event.id) 14 | assert item["id"] == updated_event["id"] 15 | assert item["name"] == updated_event["name"] 16 | assert item["tournament"] == updated_event["tournament"] 17 | assert datetime.datetime.fromisoformat(item["created_at"]) == updated_event["created_at"] 18 | assert datetime.datetime.fromisoformat(item["updated_at"]) == updated_event["updated_at"] 19 | assert "participants" in item 20 | assert item["participants"] 21 | assert item["participants"][0] == updated_event["participants"][0] 22 | 23 | 24 | async def test_list_405(session_id, event, client): 25 | assert session_id 26 | r = await client.post( 27 | f"/api/retrieve/{event.get_model_name()}/{event.id}", 28 | ) 29 | assert r.status_code == 405, r.text 30 | 31 | 32 | async def test_retrieve_401(event, client): 33 | r = await client.get( 34 | f"/api/retrieve/{event.get_model_name()}/{event.id}", 35 | ) 36 | assert r.status_code == 401, r.text 37 | 38 | 39 | async def test_retrieve_404_admin_class_found(session_id, admin_models, event, client): 40 | assert session_id 41 | del admin_models[event.__class__] 42 | r = await client.get( 43 | f"/api/retrieve/{event.get_model_name()}/{event.id}", 44 | ) 45 | assert r.status_code == 404, r.text 46 | 47 | 48 | async def test_retrieve_404_obj_not_found(session_id, event, client): 49 | assert session_id 50 | 51 | r = await client.get( 52 | f"/api/retrieve/{event.get_model_name()}/invalid", 53 | ) 54 | assert r.status_code == 422, r.text 55 | r = await client.get( 56 | f"/api/retrieve/{event.get_model_name()}/-1", 57 | ) 58 | assert r.status_code == 404, r.text 59 | -------------------------------------------------------------------------------- /tests/api/test_change_password.py: -------------------------------------------------------------------------------- 1 | from fastadmin.models.base import ModelAdmin 2 | 3 | 4 | async def test_change_password(session_id, admin_models, user, client): 5 | assert session_id 6 | user_admin_model: ModelAdmin = admin_models[user.__class__] 7 | old_password = user.password 8 | r = await client.patch( 9 | f"/api/change-password/{user.id}", 10 | json={ 11 | "password": "test", 12 | "confirm_password": "test", 13 | }, 14 | ) 15 | assert r.status_code == 200, r.text 16 | 17 | updated_user = await user_admin_model.get_obj(user.id) 18 | assert str(r.json()) == str(updated_user["id"]) 19 | assert updated_user["password"] != old_password 20 | assert updated_user["password"] == "test" 21 | 22 | r = await client.patch( 23 | f"/api/change-password/{user.id}", 24 | json={ 25 | "password": old_password, 26 | "confirm_password": old_password, 27 | }, 28 | ) 29 | assert r.status_code == 200, r.text 30 | 31 | 32 | async def test_change_password_405(session_id, user, client): 33 | assert session_id 34 | r = await client.get( 35 | f"/api/change-password/{user.id}", 36 | ) 37 | assert r.status_code == 405, r.text 38 | 39 | 40 | async def test_change_password_401(user, client): 41 | r = await client.patch( 42 | f"/api/change-password/{user.id}", 43 | json={ 44 | "password": "test", 45 | "confirm_password": "test", 46 | }, 47 | ) 48 | assert r.status_code == 401, r.text 49 | 50 | 51 | async def test_change_password_404_obj_not_found(session_id, client): 52 | assert session_id 53 | r = await client.patch( 54 | "/api/change-password/invalid", 55 | json={ 56 | "password": "test", 57 | "confirm_password": "test", 58 | }, 59 | ) 60 | assert r.status_code == 422, r.text 61 | 62 | r = await client.patch( 63 | "/api/change-password/-1", 64 | json={ 65 | "password": "test", 66 | "confirm_password": "test", 67 | }, 68 | ) 69 | # we ignore 70 | assert r.status_code == 200, r.text 71 | -------------------------------------------------------------------------------- /docs/code/quick_tutorial/ponyorm.py: -------------------------------------------------------------------------------- 1 | import typing as tp 2 | import uuid 3 | 4 | import bcrypt 5 | from pony.orm import Database, LongStr, Optional, PrimaryKey, Required, commit, db_session 6 | 7 | from fastadmin import PonyORMModelAdmin, register 8 | 9 | db = Database() 10 | db.bind(provider="sqlite", filename=":memory:", create_db=True) 11 | 12 | 13 | class User(db.Entity): # type: ignore [name-defined] 14 | _table_ = "user" 15 | id = PrimaryKey(int, auto=True) 16 | username = Required(str) 17 | hash_password = Required(str) 18 | is_superuser = Required(bool, default=False) 19 | is_active = Required(bool, default=False) 20 | avatar_url = Optional(LongStr, nullable=True) 21 | 22 | def __str__(self): 23 | return self.username 24 | 25 | 26 | @register(User) 27 | class UserAdmin(PonyORMModelAdmin): 28 | exclude = ("hash_password",) 29 | list_display = ("id", "username", "is_superuser", "is_active") 30 | list_display_links = ("id", "username") 31 | list_filter = ("id", "username", "is_superuser", "is_active") 32 | search_fields = ("username",) 33 | 34 | @db_session 35 | def authenticate(self, username: str, password: str) -> uuid.UUID | int | None: 36 | obj = next((f for f in User.select(username=username, password=password, is_superuser=True)), None) # fmt: skip 37 | if not obj: 38 | return None 39 | if not bcrypt.checkpw(password.encode(), obj.hash_password.encode()): 40 | return None 41 | return obj.id 42 | 43 | @db_session 44 | def change_password(self, id: uuid.UUID | int, password: str) -> None: 45 | obj = next((f for f in self.model_cls.select(id=id)), None) 46 | if not obj: 47 | return 48 | hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() 49 | obj.hash_password = hash_password 50 | commit() 51 | 52 | @db_session 53 | def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None: 54 | obj = next((f for f in self.model_cls.select(id=obj.id)), None) 55 | if not obj: 56 | return 57 | # convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it) 58 | setattr(obj, field, base64) 59 | commit() 60 | -------------------------------------------------------------------------------- /docs/templates/readme.md: -------------------------------------------------------------------------------- 1 | ## Admin Dashboard App for FastAPI/Flask/Django 2 | 3 | [![codecov](https://codecov.io/gh/vsdudakov/fastadmin/branch/main/graph/badge.svg?token=RNGX5HOW3T)](https://codecov.io/gh/vsdudakov/fastadmin) 4 | [![License](https://img.shields.io/github/license/vsdudakov/fastadmin)](https://github.com/vsdudakov/fastadmin/blob/master/LICENSE) 5 | [![PyPi](https://badgen.net/pypi/v/fastadmin)](https://pypi.org/project/fastadmin/) 6 | [![Python 3.10](https://img.shields.io/badge/python-3.10-blue.svg)](https://www.python.org/downloads/release/python-3100/) 7 | [![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) 8 | [![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/release/python-3120/) 9 | 10 | ## Screenshots 11 | 12 | ![SignIn View](https://raw.githubusercontent.com/vsdudakov/fastadmin/main/docs/assets/images/signin.png) 13 | ![List View](https://raw.githubusercontent.com/vsdudakov/fastadmin/main/docs/assets/images/list.png) 14 | ![Change View](https://raw.githubusercontent.com/vsdudakov/fastadmin/main/docs/assets/images/change.png) 15 | 16 |

17 | 18 | tweet 19 | 20 |

21 | 22 | {% for section in sections %} 23 | {% if section.url == '#introduction' or section.url == '#getting_started' %} 24 | ## {{section.name}} 25 | {% for section_block in get_page_context(section.url) %} 26 | {% include "templates/block.md" %} 27 | {% endfor %} 28 | {% if section.children %} 29 | {% for subsection in section.children %} 30 | ### {{subsection.name}} 31 | {% for section_block in get_page_context(subsection.url) %} 32 | {% include "templates/block.md" %} 33 | {% endfor %} 34 | {% endfor %} 35 | {% endif %} 36 | {% endif %} 37 | {% endfor %} 38 | ## Documentation 39 | See full documentation [here](https://vsdudakov.github.io/fastadmin). 40 | 41 | ## License 42 | This project is licensed under the MIT License - see the [LICENSE](https://github.com/vsdudakov/fastadmin/blob/main/LICENSE) file for details. 43 | -------------------------------------------------------------------------------- /examples/fastapi_ponyorm/models.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, date, datetime, time 2 | from decimal import Decimal 3 | from enum import Enum 4 | 5 | from pony.orm import Database, Json, LongStr, Optional, PrimaryKey, Required, Set 6 | 7 | db = Database() 8 | 9 | 10 | class EventTypeEnum(str, Enum): 11 | PRIVATE = "PRIVATE" 12 | PUBLIC = "PUBLIC" 13 | 14 | 15 | class BaseModel: 16 | # id = PrimaryKey(int, auto=True) 17 | created_at = Required(datetime, default=datetime.utcnow) 18 | updated_at = Required(datetime, default=datetime.utcnow) 19 | 20 | def before_update(self): 21 | self.updated_at = datetime.now(tz=UTC) 22 | 23 | 24 | class User(db.Entity, BaseModel): 25 | _table_ = "user" 26 | 27 | username = Required(str, max_len=255) 28 | password = Required(str, max_len=255) 29 | is_superuser = Required(bool, default=False) 30 | 31 | avatar_url = Optional(LongStr, nullable=True) 32 | 33 | events = Set("Event", table="event_participants", column="event_id") 34 | 35 | def __str__(self): 36 | return self.username 37 | 38 | 39 | class Tournament(db.Entity, BaseModel): 40 | _table_ = "tournament" 41 | 42 | name = Required(str, max_len=255) 43 | 44 | events = Set("Event") 45 | 46 | def __str__(self): 47 | return self.name 48 | 49 | 50 | class BaseEvent(db.Entity, BaseModel): 51 | _table_ = "base_event" 52 | 53 | id = PrimaryKey(int, auto=True) 54 | name = Required(str, max_len=255) 55 | 56 | event = Optional("Event") 57 | 58 | def __str__(self): 59 | return self.name 60 | 61 | 62 | class Event(db.Entity, BaseModel): 63 | _table_ = "event" 64 | 65 | base = Optional(BaseEvent, column="base_id") 66 | name = Required(str) 67 | 68 | tournament = Required(Tournament, column="tournament_id") 69 | participants = Set(User, table="event_participants", column="user_id") 70 | 71 | rating = Required(int, default=0) 72 | description = Optional(LongStr) 73 | event_type = Required(EventTypeEnum, default=EventTypeEnum.PUBLIC) 74 | is_active = Required(bool, default=True) 75 | start_time = Optional(time) 76 | date = Optional(date) 77 | latitude = Optional(float) 78 | longitude = Optional(float) 79 | price = Optional(Decimal) 80 | 81 | json = Optional(Json) 82 | 83 | def __str__(self): 84 | return self.name 85 | -------------------------------------------------------------------------------- /frontend/src/containers/app/index.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useContext } from "react"; 3 | 4 | import { ConfigProvider } from "antd"; 5 | import { Helmet } from "react-helmet-async"; 6 | import { useTranslation } from "react-i18next"; 7 | import { Route, Routes } from "react-router-dom"; 8 | 9 | import { Add } from "@/containers/add"; 10 | import { Change } from "@/containers/change"; 11 | import { Index } from "@/containers/index"; 12 | import { List } from "@/containers/list"; 13 | import { SignIn } from "@/containers/sign-in"; 14 | import { ConfigurationContext } from "@/providers/ConfigurationProvider"; 15 | 16 | export const App: React.FC = () => { 17 | const { configuration } = useContext(ConfigurationContext); 18 | const { t: _t } = useTranslation("App"); 19 | return ( 20 | 43 | 47 | 48 | 52 | 53 | 54 | } /> 55 | } /> 56 | } /> 57 | } /> 58 | } /> 59 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /examples/fastapi_tortoiseorm/models.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from tortoise import fields 4 | from tortoise.models import Model 5 | 6 | 7 | class EventTypeEnum(str, Enum): 8 | PRIVATE = "PRIVATE" 9 | PUBLIC = "PUBLIC" 10 | 11 | 12 | class BaseModel(Model): 13 | id = fields.IntField(pk=True) 14 | created_at = fields.DatetimeField(auto_now_add=True) 15 | updated_at = fields.DatetimeField(auto_now=True) 16 | 17 | class Meta: 18 | abstract = True 19 | 20 | 21 | class User(BaseModel): 22 | username = fields.CharField(max_length=255) 23 | password = fields.CharField(max_length=255) 24 | is_superuser = fields.BooleanField(default=False) 25 | 26 | avatar_url = fields.TextField(null=True) 27 | 28 | def __str__(self): 29 | return self.username 30 | 31 | class Meta: 32 | table = "user" 33 | 34 | 35 | class Tournament(BaseModel): 36 | name = fields.CharField(max_length=255) 37 | 38 | def __str__(self): 39 | return self.name 40 | 41 | class Meta: 42 | table = "tournament" 43 | 44 | 45 | class BaseEvent(BaseModel): 46 | name = fields.CharField(max_length=255) 47 | 48 | def __str__(self): 49 | return self.name 50 | 51 | class Meta: 52 | table = "base_event" 53 | 54 | 55 | class Event(BaseModel): 56 | base = fields.OneToOneField("models.BaseEvent", related_name="event", null=True, on_delete=fields.SET_NULL) 57 | name = fields.CharField(max_length=255) 58 | 59 | tournament = fields.ForeignKeyField("models.Tournament", related_name="events", on_delete=fields.CASCADE) 60 | participants = fields.ManyToManyField("models.User", related_name="events", through="event_participants") 61 | 62 | rating = fields.IntField(default=0) 63 | description = fields.TextField(null=True) 64 | event_type = fields.CharEnumField(EventTypeEnum, max_length=255, default=EventTypeEnum.PUBLIC) 65 | is_active = fields.BooleanField(default=True) 66 | start_time = fields.DatetimeField(null=True) 67 | date = fields.DateField(null=True) 68 | latitude = fields.FloatField(null=True) 69 | longitude = fields.FloatField(null=True) 70 | price = fields.DecimalField(max_digits=10, decimal_places=2, null=True) 71 | 72 | json = fields.JSONField(null=True) 73 | 74 | def __str__(self): 75 | return self.name 76 | 77 | class Meta: 78 | table = "event" 79 | -------------------------------------------------------------------------------- /frontend/src/components/filter-column/index.tsx: -------------------------------------------------------------------------------- 1 | import { FilterOutlined, SearchOutlined } from "@ant-design/icons"; 2 | import { Button, Col, Input, Radio, Row, Space } from "antd"; 3 | import { useEffect, useState } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | import { getWidgetCls } from "@/helpers/widgets"; 7 | import type { EFieldWidgetType } from "@/interfaces/configuration"; 8 | 9 | interface IFilterColumn { 10 | widgetType: EFieldWidgetType; 11 | 12 | widgetProps?: any; 13 | 14 | value?: any; 15 | 16 | onFilter(value: any): void; 17 | onReset(): void; 18 | } 19 | 20 | export const FilterColumn = ({ 21 | widgetType, 22 | widgetProps, 23 | value, 24 | onFilter, 25 | onReset, 26 | }: IFilterColumn) => { 27 | const { t: _t } = useTranslation("List"); 28 | 29 | const [filterValue, setFilterValue] = useState(); 30 | 31 | const [FilterWidget, defaultProps]: any = getWidgetCls(widgetType, _t); 32 | 33 | useEffect(() => { 34 | setFilterValue(value); 35 | }, [value]); 36 | 37 | const onFilterButton = () => { 38 | onFilter(filterValue); 39 | }; 40 | 41 | const onChangeWidget = (widgetValue: any) => { 42 | switch (FilterWidget) { 43 | case Input: 44 | case Input.TextArea: 45 | case Radio.Group: 46 | setFilterValue(widgetValue.target.value); 47 | break; 48 | default: 49 | setFilterValue(widgetValue); 50 | break; 51 | } 52 | }; 53 | 54 | return ( 55 | 56 |
57 | 65 | 66 | 67 | 68 | 71 | 81 | 82 | 83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /docs/code/quick_tutorial/tortoise.py: -------------------------------------------------------------------------------- 1 | import typing as tp 2 | from uuid import UUID 3 | 4 | import bcrypt 5 | from tortoise import fields 6 | from tortoise.models import Model 7 | 8 | from fastadmin import TortoiseModelAdmin, WidgetType, register 9 | 10 | 11 | class User(Model): 12 | username = fields.CharField(max_length=255, unique=True) 13 | hash_password = fields.CharField(max_length=255) 14 | is_superuser = fields.BooleanField(default=False) 15 | is_active = fields.BooleanField(default=False) 16 | avatar_url = fields.TextField(null=True) 17 | 18 | def __str__(self): 19 | return self.username 20 | 21 | 22 | @register(User) 23 | class UserAdmin(TortoiseModelAdmin): 24 | exclude = ("hash_password",) 25 | list_display = ("id", "username", "is_superuser", "is_active") 26 | list_display_links = ("id", "username") 27 | list_filter = ("id", "username", "is_superuser", "is_active") 28 | search_fields = ("username",) 29 | formfield_overrides = { # noqa: RUF012 30 | "username": (WidgetType.SlugInput, {"required": True}), 31 | "password": (WidgetType.PasswordInput, {"passwordModalForm": True}), 32 | "avatar_url": ( 33 | WidgetType.Upload, 34 | { 35 | "required": False, 36 | # Disable crop image for upload field 37 | # "disableCropImage": True, 38 | }, 39 | ), 40 | } 41 | 42 | async def authenticate(self, username: str, password: str) -> int | None: 43 | user = await self.model_cls.filter(phone=username, is_superuser=True).first() 44 | if not user: 45 | return None 46 | if not bcrypt.checkpw(password.encode(), user.hash_password.encode()): 47 | return None 48 | return user.id 49 | 50 | async def change_password(self, id: UUID | int, password: str) -> None: 51 | user = await self.model_cls.filter(id=id).first() 52 | if not user: 53 | return 54 | user.hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() 55 | await user.save(update_fields=("hash_password",)) 56 | 57 | async def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None: 58 | # convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it) 59 | setattr(obj, field, base64) 60 | await obj.save(update_fields=(field,)) 61 | -------------------------------------------------------------------------------- /fastadmin/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | ROOT_DIR = Path(__file__).resolve().parent 5 | 6 | 7 | class Settings: 8 | """Settings""" 9 | 10 | # This value is the prefix you used for mounting FastAdmin app for FastAPI. 11 | ADMIN_PREFIX: str = os.getenv("ADMIN_PREFIX", "admin") 12 | 13 | # This value is the site name on sign-in page and on header. 14 | ADMIN_SITE_NAME: str = os.getenv("ADMIN_SITE_NAME", "FastAdmin") 15 | 16 | # This value is the logo path on sign-in page. 17 | ADMIN_SITE_SIGN_IN_LOGO: str = os.getenv("ADMIN_SITE_SIGN_IN_LOGO", "/admin/static/images/sign-in-logo.svg") 18 | 19 | # This value is the logo path on header. 20 | ADMIN_SITE_HEADER_LOGO: str = os.getenv("ADMIN_SITE_HEADER_LOGO", "/admin/static/images/header-logo.svg") 21 | 22 | # This value is the favicon path. 23 | ADMIN_SITE_FAVICON: str = os.getenv("ADMIN_SITE_FAVICON", "/admin/static/images/favicon.png") 24 | 25 | # This value is the primary color for FastAdmin. 26 | ADMIN_PRIMARY_COLOR: str = os.getenv("ADMIN_PRIMARY_COLOR", "#009485") 27 | 28 | # This value is the session id key to store session id in http only cookies. 29 | ADMIN_SESSION_ID_KEY: str = os.getenv("ADMIN_SESSION_ID_KEY", "admin_session_id") 30 | 31 | # This value is the expired_at period (in sec) for session id. 32 | ADMIN_SESSION_EXPIRED_AT: int = os.getenv("ADMIN_SESSION_EXPIRED_AT", 144000) # in sec 33 | 34 | # This value is the date format for JS widgets. 35 | ADMIN_DATE_FORMAT: str = os.getenv("ADMIN_DATE_FORMAT", "YYYY-MM-DD") 36 | 37 | # This value is the datetime format for JS widgets. 38 | ADMIN_DATETIME_FORMAT: str = os.getenv("ADMIN_DATETIME_FORMAT", "YYYY-MM-DD HH:mm") 39 | 40 | # This value is the time format for JS widgets. 41 | ADMIN_TIME_FORMAT: str = os.getenv("ADMIN_TIME_FORMAT", "HH:mm:ss") 42 | 43 | # This value is the name for User db/orm model class for authentication. 44 | ADMIN_USER_MODEL: str = os.getenv("ADMIN_USER_MODEL") 45 | 46 | # This value is the username field for User db/orm model for for authentication. 47 | ADMIN_USER_MODEL_USERNAME_FIELD: str = os.getenv("ADMIN_USER_MODEL_USERNAME_FIELD") 48 | 49 | # This value is the key to securing signed data - it is vital you keep this secure, 50 | # or attackers could use it to generate their own signed values. 51 | ADMIN_SECRET_KEY: str = os.getenv("ADMIN_SECRET_KEY") 52 | 53 | # This value disables the crop image feature in FastAdmin. 54 | ADMIN_DISABLE_CROP_IMAGE: bool = os.getenv("ADMIN_DISABLE_CROP_IMAGE", False) 55 | 56 | 57 | settings = Settings() 58 | -------------------------------------------------------------------------------- /frontend/src/components/async-transfer/index.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { Transfer } from "antd"; 3 | import type { TransferDirection } from "antd/es/transfer"; 4 | import debounce from "lodash.debounce"; 5 | import querystring from "query-string"; 6 | import type React from "react"; 7 | import { useState } from "react"; 8 | 9 | import { getFetcher } from "@/fetchers/fetchers"; 10 | import { useIsMobile } from "@/hooks/useIsMobile"; 11 | 12 | export interface IAsyncTransfer { 13 | parentModel: string; 14 | idField: string; 15 | labelFields: string[]; 16 | layout?: "horizontal" | "vertical"; 17 | value: string[] | undefined; 18 | 19 | onChange: any; 20 | } 21 | 22 | export const AsyncTransfer: React.FC = ({ 23 | parentModel, 24 | idField, 25 | labelFields, 26 | layout, 27 | value, 28 | onChange, 29 | ...props 30 | }) => { 31 | const [search, setSearch] = useState(); 32 | const isMobile = useIsMobile(); 33 | 34 | const queryString = querystring.stringify({ 35 | offset: 0, 36 | limit: 20, 37 | search, 38 | }); 39 | 40 | const { data } = useQuery({ 41 | queryKey: [`/list/${parentModel}`, queryString], 42 | queryFn: () => getFetcher(`/list/${parentModel}?${queryString}`), 43 | }); 44 | 45 | const onFilter = (input: string, option: any) => { 46 | return ( 47 | ((option?.key as any) || "").toLowerCase().indexOf(input.toLowerCase()) >= 48 | 0 || 49 | ((option?.value as any) || "") 50 | .toLowerCase() 51 | .indexOf(input.toLowerCase()) >= 0 52 | ); 53 | }; 54 | 55 | const onSearch = (direction: TransferDirection, v: string) => { 56 | if (direction === "left") { 57 | setSearch(v); 58 | } 59 | }; 60 | 61 | const dataSource = (data?.results || []).map((item: any) => { 62 | const labelField = labelFields.filter((f) => item[f])[0]; 63 | return { key: item[idField], title: item[labelField] }; 64 | }); 65 | 66 | const render = (item: any) => item.title; 67 | 68 | return ( 69 | 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /tests/api/test_action.py: -------------------------------------------------------------------------------- 1 | async def test_action(session_id, admin_models, event, client): 2 | assert session_id 3 | event_admin_model = admin_models[event.__class__] 4 | 5 | event_admin_model.actions = ("make_is_active",) 6 | await event_admin_model.save_model(event.id, {"is_active": False}) 7 | r = await client.post( 8 | f"/api/action/{event.get_model_name()}/make_is_active", 9 | json={ 10 | "ids": [event.id], 11 | }, 12 | ) 13 | assert r.status_code == 200, r.text 14 | item = r.json() 15 | assert not item 16 | updated_event = await event_admin_model.get_obj(event.id) 17 | assert updated_event["is_active"] 18 | 19 | 20 | async def test_action_405(session_id, event, client): 21 | assert session_id 22 | r = await client.get( 23 | f"/api/action/{event.get_model_name()}/make_is_active", 24 | ) 25 | assert r.status_code == 405, r.text 26 | 27 | 28 | async def test_action_401(event, client): 29 | r = await client.post( 30 | f"/api/action/{event.get_model_name()}/make_is_active", 31 | json={ 32 | "ids": [event.id], 33 | }, 34 | ) 35 | assert r.status_code == 401, r.text 36 | 37 | 38 | async def test_action_404(session_id, admin_models, event, client): 39 | assert session_id 40 | del admin_models[event.__class__] 41 | r = await client.post( 42 | f"/api/action/{event.get_model_name()}/make_is_active", 43 | json={ 44 | "ids": [event.id], 45 | }, 46 | ) 47 | assert r.status_code == 404, r.text 48 | 49 | 50 | async def test_action_422(session_id, admin_models, event, client): 51 | assert session_id 52 | event_admin_model = admin_models[event.__class__] 53 | 54 | event_admin_model.actions = () 55 | await event_admin_model.save_model(event.id, {"is_active": False}) 56 | r = await client.post( 57 | f"/api/action/{event.get_model_name()}/make_is_active", 58 | json={ 59 | "ids": [event.id], 60 | }, 61 | ) 62 | assert r.status_code == 422, r.text 63 | updated_event = await event_admin_model.get_obj(event.id) 64 | assert not updated_event["is_active"] 65 | 66 | event_admin_model.actions = ("invalid",) 67 | await event_admin_model.save_model(event.id, {"is_active": False}) 68 | r = await client.post( 69 | f"/api/action/{event.get_model_name()}/invalid", 70 | json={ 71 | "ids": [event.id], 72 | }, 73 | ) 74 | assert r.status_code == 422, r.text 75 | updated_event = await event_admin_model.get_obj(event.id) 76 | assert not updated_event["is_active"] 77 | -------------------------------------------------------------------------------- /tests/api/test_helpers.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime, timedelta, timezone 3 | 4 | import jwt 5 | 6 | from fastadmin.api.helpers import is_valid_id, is_valid_uuid, sanitize_filter_value 7 | from fastadmin.api.service import get_user_id_from_session_id 8 | from fastadmin.settings import settings 9 | 10 | 11 | async def test_sanitize_filter_value(): 12 | assert sanitize_filter_value("true") is True 13 | assert sanitize_filter_value("false") is False 14 | assert sanitize_filter_value("null") is None 15 | assert sanitize_filter_value("foo") == "foo" 16 | 17 | 18 | async def test_is_valid_uuid(): 19 | assert is_valid_uuid(str(uuid.uuid1())) is True 20 | assert is_valid_uuid(str(uuid.uuid3(uuid.uuid4(), "test"))) is True 21 | assert is_valid_uuid(str(uuid.uuid4())) is True 22 | assert is_valid_uuid(str(uuid.uuid5(uuid.uuid4(), "test"))) is True 23 | assert is_valid_uuid("invalid") is False 24 | 25 | 26 | async def test_is_valid_id(): 27 | assert is_valid_id(1) is True 28 | assert is_valid_uuid(str(uuid.uuid1())) is True 29 | 30 | 31 | async def test_get_user_id_from_session_id(session_id): 32 | assert await get_user_id_from_session_id(None) is None 33 | assert await get_user_id_from_session_id("invalid") is None 34 | user_id = await get_user_id_from_session_id(session_id) 35 | assert user_id is not None 36 | 37 | now = datetime.now(timezone.utc) 38 | session_expired_at = now + timedelta(seconds=settings.ADMIN_SESSION_EXPIRED_AT) 39 | without_expired_session_id = jwt.encode( 40 | { 41 | "user_id": str(user_id), 42 | }, 43 | settings.ADMIN_SECRET_KEY, 44 | algorithm="HS256", 45 | ) 46 | assert await get_user_id_from_session_id(without_expired_session_id) is None 47 | 48 | session_expired_at = now - timedelta(seconds=settings.ADMIN_SESSION_EXPIRED_AT) 49 | expired_session_id = jwt.encode( 50 | { 51 | "user_id": str(user_id), 52 | "session_expired_at": session_expired_at.isoformat(), 53 | }, 54 | settings.ADMIN_SECRET_KEY, 55 | algorithm="HS256", 56 | ) 57 | assert await get_user_id_from_session_id(expired_session_id) is None 58 | 59 | session_expired_at = now + timedelta(seconds=settings.ADMIN_SESSION_EXPIRED_AT) 60 | invalid_user_session_id = jwt.encode( 61 | { 62 | "user_id": str(-1), 63 | "session_expired_at": session_expired_at.isoformat(), 64 | }, 65 | settings.ADMIN_SECRET_KEY, 66 | algorithm="HS256", 67 | ) 68 | assert await get_user_id_from_session_id(invalid_user_session_id) is None 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | node_modules/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | .DS_Store 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | coverage 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | db.sqlite3-shm 67 | db.sqlite3-wal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | *.code-workspace 137 | .idea/ 138 | 139 | :sharedmemory: -------------------------------------------------------------------------------- /docs/code/dashboard/tortoise.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from tortoise import Tortoise, fields 4 | from tortoise.models import Model 5 | 6 | from fastadmin import DashboardWidgetAdmin, DashboardWidgetType, WidgetType, register_widget 7 | 8 | 9 | class DashboardUser(Model): 10 | username = fields.CharField(max_length=255, unique=True) 11 | hash_password = fields.CharField(max_length=255) 12 | is_superuser = fields.BooleanField(default=False) 13 | is_active = fields.BooleanField(default=False) 14 | 15 | def __str__(self): 16 | return self.username 17 | 18 | 19 | @register_widget 20 | class UsersDashboardWidgetAdmin(DashboardWidgetAdmin): 21 | title = "Users" 22 | dashboard_widget_type = DashboardWidgetType.ChartLine 23 | 24 | x_field = "date" 25 | x_field_filter_widget_type = WidgetType.DatePicker 26 | x_field_filter_widget_props: dict[str, str] = {"picker": "month"} # noqa: RUF012 27 | x_field_periods = ["day", "week", "month", "year"] # noqa: RUF012 28 | 29 | y_field = "count" 30 | 31 | async def get_data( 32 | self, 33 | min_x_field: str | None = None, 34 | max_x_field: str | None = None, 35 | period_x_field: str | None = None, 36 | ) -> dict: 37 | conn = Tortoise.get_connection("default") 38 | 39 | if not min_x_field: 40 | min_x_field_date = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(days=360) 41 | else: 42 | min_x_field_date = datetime.datetime.fromisoformat(min_x_field) 43 | if not max_x_field: 44 | max_x_field_date = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1) 45 | else: 46 | max_x_field_date = datetime.datetime.fromisoformat(max_x_field) 47 | 48 | if not period_x_field or period_x_field not in (self.x_field_periods or []): 49 | period_x_field = "month" 50 | 51 | results = await conn.execute_query_dict( 52 | """ 53 | SELECT 54 | to_char(date_trunc($1, "user"."created_at")::date, 'dd/mm/yyyy') "date", 55 | COUNT("user"."id") "count" 56 | FROM "user" 57 | WHERE "user"."created_at" >= $2 AND "user"."created_at" <= $3 58 | GROUP BY "date" ORDER BY "date" 59 | """, 60 | [period_x_field, min_x_field_date, max_x_field_date], 61 | ) 62 | return { 63 | "results": results, 64 | "min_x_field": min_x_field_date.isoformat(), 65 | "max_x_field": max_x_field_date.isoformat(), 66 | "period_x_field": period_x_field, 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/components/password-input/index.tsx: -------------------------------------------------------------------------------- 1 | import { EditOutlined } from "@ant-design/icons"; 2 | import { useMutation } from "@tanstack/react-query"; 3 | import { 4 | Button, 5 | Divider, 6 | Form, 7 | Input, 8 | Modal, 9 | Space, 10 | Tooltip, 11 | message, 12 | } from "antd"; 13 | import React from "react"; 14 | import { useTranslation } from "react-i18next"; 15 | 16 | import { patchFetcher } from "@/fetchers/fetchers"; 17 | import { handleError } from "@/helpers/forms"; 18 | 19 | export interface IPasswordInput { 20 | parentId?: string; 21 | } 22 | 23 | export const PasswordInput: React.FC = ({ 24 | parentId, 25 | ...props 26 | }) => { 27 | const { t: _t } = useTranslation("PasswordInput"); 28 | const [form] = Form.useForm(); 29 | const [open, setOpen] = React.useState(false); 30 | 31 | const onClose = () => { 32 | setOpen(false); 33 | form.resetFields(); 34 | }; 35 | 36 | const onOpen = () => { 37 | form.resetFields(); 38 | setOpen(true); 39 | }; 40 | 41 | const { mutate, isPending: isLoading } = useMutation({ 42 | mutationFn: (data: any) => 43 | patchFetcher(`/change-password/${parentId}`, data), 44 | onSuccess: () => { 45 | message.success(_t("Succesfully changed")); 46 | onClose(); 47 | }, 48 | onError: (error: Error) => { 49 | handleError(error, form); 50 | }, 51 | }); 52 | 53 | const onChangePassword = (data: any) => { 54 | mutate(data); 55 | }; 56 | 57 | if (!parentId) { 58 | return ; 59 | } 60 | return ( 61 | <> 62 | 63 | 64 | 67 | 68 | 69 | 70 | 76 | 77 |
78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 89 | 90 | 91 |
92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /fastadmin/api/helpers.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | from pathlib import Path 4 | from uuid import UUID 5 | 6 | from fastadmin.models.schemas import ModelFieldWidgetSchema 7 | 8 | 9 | def sanitize_filter_value(value: str) -> bool | None | str: 10 | """Sanitize value 11 | 12 | :params value: a value. 13 | :return: A sanitized value. 14 | """ 15 | match value: 16 | case "false": 17 | return False 18 | case "true": 19 | return True 20 | case "null": 21 | return None 22 | case _: 23 | return value 24 | 25 | 26 | def sanitize_filter_key(key: str, fields: list[ModelFieldWidgetSchema]) -> tuple[str, str]: 27 | """Sanitize key. 28 | 29 | :param key: A key. 30 | :param fields: A list of fields. 31 | :return: A tuple of sanitized key and condition. 32 | """ 33 | if "__" not in key: 34 | key += "__exact" 35 | field_name, _, condition = key.partition("__") 36 | field: ModelFieldWidgetSchema | None = next((field for field in fields if field.name == field_name), None) 37 | if field and field.filter_widget_props.get("parentModel") and not field.is_m2m: 38 | field_name += "_id" 39 | return field_name, condition 40 | 41 | 42 | def is_valid_uuid(uuid_to_test: str) -> bool: 43 | """Check if uuid_to_test is a valid uuid. 44 | 45 | :param uuid_to_test: A uuid to test. 46 | :return: True if uuid_to_test is a valid uuid, False otherwise. 47 | """ 48 | try: 49 | uuid_obj = UUID(uuid_to_test) 50 | except ValueError: 51 | return False 52 | return str(uuid_obj) == uuid_to_test 53 | 54 | 55 | def is_valid_id(id: UUID | int) -> bool: 56 | """Check if id is a valid id. 57 | 58 | :param id: An id to test. 59 | :return: True if id is a valid id, False otherwise. 60 | """ 61 | if is_valid_uuid(str(id)): 62 | return True 63 | try: 64 | int(id) 65 | return True 66 | except ValueError: 67 | pass 68 | return False 69 | 70 | 71 | def is_valid_base64(value: str) -> bool: 72 | """Check if a string is a valid base64. 73 | 74 | :param value: A string to test. 75 | :return: True if s is a valid base64, False otherwise. 76 | """ 77 | try: 78 | base64.decodebytes(value.encode("ascii")) 79 | return True 80 | except binascii.Error: 81 | return False 82 | 83 | 84 | def get_template(template: Path, context: dict) -> str: 85 | with Path.open(template, "r") as file: 86 | content = file.read() 87 | for key, value in context.items(): 88 | content = content.replace(f"{{{{{key}}}}}", value) 89 | return content 90 | -------------------------------------------------------------------------------- /docs/assets/js/theme.js: -------------------------------------------------------------------------------- 1 | /* 2 | ================================================================ 3 | * Template: iDocs - One Page Documentation HTML Template 4 | * Written by: Harnish Design - (http://www.harnishdesign.net) 5 | * Description: Main Custom Script File 6 | ================================================================ 7 | */ 8 | 9 | (function ($) { 10 | "use strict"; 11 | 12 | // Preloader 13 | $(window).on("load", function () { 14 | $(".lds-ellipsis").fadeOut(); // will first fade out the loading animation 15 | $(".preloader").delay(100).fadeOut("slow"); // will fade out the white DIV that covers the website. 16 | $("body").delay(100); 17 | }); 18 | 19 | /*------------------------------- 20 | Primary Menu 21 | --------------------------------- */ 22 | 23 | // Mobile Collapse Nav 24 | $( 25 | '.primary-menu .navbar-nav .dropdown-toggle[href="#"], .primary-menu .dropdown-toggle[href!="#"] .arrow' 26 | ).on("click", function (e) { 27 | if ($(window).width() < 991) { 28 | e.preventDefault(); 29 | var $parentli = $(this).closest("li"); 30 | $parentli.siblings("li").find(".dropdown-menu:visible").slideUp(); 31 | $parentli.find("> .dropdown-menu").stop().slideToggle(); 32 | $parentli.siblings("li").find("a .arrow.show").toggleClass("show"); 33 | $parentli.find("> a .arrow").toggleClass("show"); 34 | } 35 | }); 36 | 37 | // Mobile Menu 38 | $(".navbar-toggler").on("click", function () { 39 | $(this).toggleClass("show"); 40 | }); 41 | 42 | /*------------------------ 43 | Side Navigation 44 | -------------------------- */ 45 | 46 | $("#sidebarCollapse").on("click", function () { 47 | $("#sidebarCollapse span:nth-child(3)").toggleClass("w-50"); 48 | $(".app-navigation").toggleClass("active"); 49 | }); 50 | 51 | /*------------------------ 52 | Sections Scroll 53 | -------------------------- */ 54 | 55 | $(".app-navigation a").on("click", function () { 56 | event.preventDefault(); 57 | var sectionTo = $(this).attr("href"); 58 | $("html, body") 59 | .stop() 60 | .animate( 61 | { 62 | scrollTop: $(sectionTo).offset().top - 120, 63 | }, 64 | 200, 65 | ); 66 | }); 67 | 68 | /*------------------------ 69 | Highlight Js 70 | -------------------------- */ 71 | 72 | hljs.initHighlightingOnLoad(); 73 | 74 | /*------------------------ 75 | Scroll to top 76 | -------------------------- */ 77 | $(function () { 78 | $(window).on("scroll", function () { 79 | if ($(this).scrollTop() > 400) { 80 | $("#back-to-top").fadeIn(); 81 | } else { 82 | $("#back-to-top").fadeOut(); 83 | } 84 | }); 85 | }); 86 | $("#back-to-top").on("click", function () { 87 | $("html, body").animate({ scrollTop: 0 }); 88 | return false; 89 | }); 90 | })(jQuery); 91 | -------------------------------------------------------------------------------- /docs/code/dashboard/djangoorm.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.db import connection, models 4 | 5 | from fastadmin import DashboardWidgetAdmin, DashboardWidgetType, WidgetType, register_widget 6 | 7 | 8 | class DashboardUser(models.Model): 9 | username = models.CharField(max_length=255, unique=True) 10 | hash_password = models.CharField(max_length=255) 11 | is_superuser = models.BooleanField(default=False) 12 | is_active = models.BooleanField(default=False) 13 | 14 | def __str__(self): 15 | return self.username 16 | 17 | 18 | @register_widget 19 | class UsersDashboardWidgetAdmin(DashboardWidgetAdmin): 20 | title = "Users" 21 | dashboard_widget_type = DashboardWidgetType.ChartLine 22 | 23 | x_field = "date" 24 | x_field_filter_widget_type = WidgetType.DatePicker 25 | x_field_filter_widget_props: dict[str, str] = {"picker": "month"} # noqa: RUF012 26 | x_field_periods = ["day", "week", "month", "year"] # noqa: RUF012 27 | 28 | y_field = "count" 29 | 30 | def get_data( # type: ignore [override] 31 | self, 32 | min_x_field: str | None = None, 33 | max_x_field: str | None = None, 34 | period_x_field: str | None = None, 35 | ) -> dict: 36 | def dictfetchall(cursor): 37 | columns = [col[0] for col in cursor.description] 38 | return [dict(zip(columns, row, strict=True)) for row in cursor.fetchall()] 39 | 40 | with connection.cursor() as c: 41 | if not min_x_field: 42 | min_x_field_date = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(days=360) 43 | else: 44 | min_x_field_date = datetime.datetime.fromisoformat(min_x_field) 45 | if not max_x_field: 46 | max_x_field_date = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1) 47 | else: 48 | max_x_field_date = datetime.datetime.fromisoformat(max_x_field) 49 | 50 | if not period_x_field or period_x_field not in (self.x_field_periods or []): 51 | period_x_field = "month" 52 | 53 | c.execute( 54 | """ 55 | SELECT 56 | to_char(date_trunc($1, "user"."created_at")::date, 'dd/mm/yyyy') "date", 57 | COUNT("user"."id") "count" 58 | FROM "user" 59 | WHERE "user"."created_at" >= $2 AND "user"."created_at" <= $3 60 | GROUP BY "date" ORDER BY "date" 61 | """, 62 | [period_x_field, min_x_field_date, max_x_field_date], 63 | ) 64 | results = dictfetchall(c) 65 | return { 66 | "results": results, 67 | "min_x_field": min_x_field_date.isoformat(), 68 | "max_x_field": max_x_field_date.isoformat(), 69 | "period_x_field": period_x_field, 70 | } 71 | -------------------------------------------------------------------------------- /docs/code/inlines/tortoise.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | import bcrypt 4 | from tortoise import fields 5 | from tortoise.models import Model 6 | 7 | from fastadmin import TortoiseInlineModelAdmin, TortoiseModelAdmin, WidgetType, action, register 8 | 9 | 10 | class InlineUser(Model): 11 | username = fields.CharField(max_length=255, unique=True) 12 | hash_password = fields.CharField(max_length=255) 13 | is_superuser = fields.BooleanField(default=False) 14 | is_active = fields.BooleanField(default=False) 15 | 16 | def __str__(self): 17 | return self.username 18 | 19 | 20 | class InlineUserMessage(Model): 21 | user = fields.ForeignKeyField("models.InlineUser", related_name="messages") 22 | message = fields.TextField() 23 | 24 | def __str__(self): 25 | return self.message 26 | 27 | 28 | class UserMessageAdminInline(TortoiseInlineModelAdmin): 29 | model = InlineUserMessage 30 | list_display = ("user", "message") 31 | list_display_links = ("user", "message") 32 | list_filter = ("user", "message") 33 | search_fields = ("user", "message") 34 | 35 | 36 | @register(InlineUser) 37 | class UserAdmin(TortoiseModelAdmin): 38 | list_display = ("username", "is_superuser", "is_active") 39 | list_display_links = ("username",) 40 | list_filter = ( 41 | "username", 42 | "is_superuser", 43 | "is_active", 44 | ) 45 | search_fields = ( 46 | "id", 47 | "username", 48 | ) 49 | fieldsets = ( 50 | (None, {"fields": ("username", "hash_password")}), 51 | ("Permissions", {"fields": ("is_active", "is_superuser")}), 52 | ) 53 | formfield_overrides = { # noqa: RUF012 54 | "username": (WidgetType.SlugInput, {"required": True}), 55 | "password": (WidgetType.PasswordInput, {"passwordModalForm": True}), 56 | } 57 | actions = ( 58 | *TortoiseModelAdmin.actions, 59 | "activate", 60 | "deactivate", 61 | ) 62 | 63 | inlines = (UserMessageAdminInline,) 64 | 65 | async def authenticate(self, username: str, password: str) -> int | None: 66 | user = await self.model_cls.filter(phone=username, is_superuser=True).first() 67 | if not user: 68 | return None 69 | if not bcrypt.checkpw(password.encode(), user.hash_password.encode()): 70 | return None 71 | return user.id 72 | 73 | async def change_password(self, id: UUID | int, password: str) -> None: 74 | user = await self.model_cls.filter(id=id).first() 75 | if not user: 76 | return 77 | user.hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() 78 | await user.save(update_fields=("hash_password",)) 79 | 80 | @action(description="Set as active") 81 | async def activate(self, ids: list[int]) -> None: 82 | await self.model_cls.filter(id__in=ids).update(is_active=True) 83 | 84 | @action(description="Deactivate") 85 | async def deactivate(self, ids: list[int]) -> None: 86 | await self.model_cls.filter(id__in=ids).update(is_active=False) 87 | -------------------------------------------------------------------------------- /docs/code/models/tortoise.py: -------------------------------------------------------------------------------- 1 | import typing as tp 2 | from uuid import UUID 3 | 4 | import bcrypt 5 | from tortoise import fields 6 | from tortoise.models import Model 7 | 8 | from fastadmin import TortoiseModelAdmin, WidgetType, action, register 9 | 10 | 11 | class ModelUser(Model): 12 | username = fields.CharField(max_length=255, unique=True) 13 | hash_password = fields.CharField(max_length=255) 14 | is_superuser = fields.BooleanField(default=False) 15 | is_active = fields.BooleanField(default=False) 16 | 17 | avatar_url = fields.TextField(null=True) 18 | 19 | def __str__(self): 20 | return self.username 21 | 22 | 23 | @register(ModelUser) 24 | class UserAdmin(TortoiseModelAdmin): 25 | list_display = ("username", "is_superuser", "is_active") 26 | list_display_links = ("username",) 27 | list_filter = ( 28 | "username", 29 | "is_superuser", 30 | "is_active", 31 | ) 32 | search_fields = ( 33 | "id", 34 | "username", 35 | ) 36 | fieldsets = ( 37 | (None, {"fields": ("username", "hash_password")}), 38 | ("Permissions", {"fields": ("is_active", "is_superuser")}), 39 | ) 40 | formfield_overrides = { # noqa: RUF012 41 | "username": (WidgetType.SlugInput, {"required": True}), 42 | "password": (WidgetType.PasswordInput, {"passwordModalForm": True}), 43 | "avatar_url": ( 44 | WidgetType.Upload, 45 | { 46 | "required": False, 47 | # Disable crop image for upload field 48 | # "disableCropImage": True, 49 | }, 50 | ), 51 | } 52 | actions = ( 53 | *TortoiseModelAdmin.actions, 54 | "activate", 55 | "deactivate", 56 | ) 57 | 58 | async def authenticate(self, username: str, password: str) -> int | None: 59 | user = await self.model_cls.filter(phone=username, is_superuser=True).first() 60 | if not user: 61 | return None 62 | if not bcrypt.checkpw(password.encode(), user.hash_password.encode()): 63 | return None 64 | return user.id 65 | 66 | async def change_password(self, id: UUID | int, password: str) -> None: 67 | user = await self.model_cls.filter(id=id).first() 68 | if not user: 69 | return 70 | user.hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() 71 | await user.save(update_fields=("hash_password",)) 72 | 73 | @action(description="Set as active") 74 | async def activate(self, ids: list[int]) -> None: 75 | await self.model_cls.filter(id__in=ids).update(is_active=True) 76 | 77 | @action(description="Deactivate") 78 | async def deactivate(self, ids: list[int]) -> None: 79 | await self.model_cls.filter(id__in=ids).update(is_active=False) 80 | 81 | async def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None: 82 | # convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it) 83 | setattr(obj, field, base64) 84 | await obj.save(update_fields=(field,)) 85 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | 15 | .table-row-selected { 16 | background-color: #f0f0f0; 17 | } 18 | 19 | .quill { 20 | background-color: white !important; 21 | } 22 | .ql-snow.ql-toolbar button:hover, 23 | .ql-snow .ql-toolbar button:hover, 24 | .ql-snow.ql-toolbar button:focus, 25 | .ql-snow .ql-toolbar button:focus, 26 | .ql-snow.ql-toolbar button.ql-active, 27 | .ql-snow .ql-toolbar button.ql-active, 28 | .ql-snow.ql-toolbar .ql-picker-label:hover, 29 | .ql-snow .ql-toolbar .ql-picker-label:hover, 30 | .ql-snow.ql-toolbar .ql-picker-label.ql-active, 31 | .ql-snow .ql-toolbar .ql-picker-label.ql-active, 32 | .ql-snow.ql-toolbar .ql-picker-item:hover, 33 | .ql-snow .ql-toolbar .ql-picker-item:hover, 34 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected, 35 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected { 36 | color: #009485 !important; 37 | } 38 | .ql-snow.ql-toolbar button:hover .ql-stroke, 39 | .ql-snow .ql-toolbar button:hover .ql-stroke, 40 | .ql-snow.ql-toolbar button:focus .ql-stroke, 41 | .ql-snow .ql-toolbar button:focus .ql-stroke, 42 | .ql-snow.ql-toolbar button.ql-active .ql-stroke, 43 | .ql-snow .ql-toolbar button.ql-active .ql-stroke, 44 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke, 45 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke, 46 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke, 47 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke, 48 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke, 49 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke, 50 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke, 51 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke, 52 | .ql-snow.ql-toolbar button:hover .ql-stroke-miter, 53 | .ql-snow .ql-toolbar button:hover .ql-stroke-miter, 54 | .ql-snow.ql-toolbar button:focus .ql-stroke-miter, 55 | .ql-snow .ql-toolbar button:focus .ql-stroke-miter, 56 | .ql-snow.ql-toolbar button.ql-active .ql-stroke-miter, 57 | .ql-snow .ql-toolbar button.ql-active .ql-stroke-miter, 58 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke-miter, 59 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke-miter, 60 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter, 61 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter, 62 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke-miter, 63 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke-miter, 64 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter, 65 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter { 66 | stroke: #009485 !important; 67 | } 68 | .ql-snow a { 69 | color: #009485 !important; 70 | } 71 | .ql-toolbar { 72 | border-top-left-radius: 5px; 73 | border-top-right-radius: 5px; 74 | } 75 | .ql-container { 76 | border-bottom-left-radius: 5px; 77 | border-bottom-right-radius: 5px; 78 | } 79 | -------------------------------------------------------------------------------- /docs/code/quick_tutorial/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | import typing as tp 2 | import uuid 3 | 4 | import bcrypt 5 | from sqlalchemy import Boolean, Integer, String, Text, select, update 6 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 7 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 8 | 9 | from fastadmin import SqlAlchemyModelAdmin, register 10 | 11 | sqlalchemy_engine = create_async_engine( 12 | "sqlite+aiosqlite:///:memory:", 13 | echo=True, 14 | ) 15 | sqlalchemy_sessionmaker = async_sessionmaker(sqlalchemy_engine, expire_on_commit=False) 16 | 17 | 18 | class Base(DeclarativeBase): 19 | pass 20 | 21 | 22 | class User(Base): 23 | __tablename__ = "user" 24 | 25 | id: Mapped[int] = mapped_column(Integer, primary_key=True, nullable=False) 26 | username: Mapped[str] = mapped_column(String(length=255), nullable=False) 27 | hash_password: Mapped[str] = mapped_column(String(length=255), nullable=False) 28 | is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) 29 | is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) 30 | avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True) 31 | 32 | def __str__(self): 33 | return self.username 34 | 35 | 36 | @register(User, sqlalchemy_sessionmaker=sqlalchemy_sessionmaker) 37 | class UserAdmin(SqlAlchemyModelAdmin): 38 | exclude = ("hash_password",) 39 | list_display = ("id", "username", "is_superuser", "is_active") 40 | list_display_links = ("id", "username") 41 | list_filter = ("id", "username", "is_superuser", "is_active") 42 | search_fields = ("username",) 43 | 44 | async def authenticate(self, username: str, password: str) -> uuid.UUID | int | None: 45 | sessionmaker = self.get_sessionmaker() 46 | async with sessionmaker() as session: 47 | query = select(self.model_cls).filter_by(username=username, password=password, is_superuser=True) 48 | result = await session.scalars(query) 49 | obj = result.first() 50 | if not obj: 51 | return None 52 | if not bcrypt.checkpw(password.encode(), obj.hash_password.encode()): 53 | return None 54 | return obj.id 55 | 56 | async def change_password(self, id: uuid.UUID | int, password: str) -> None: 57 | sessionmaker = self.get_sessionmaker() 58 | async with sessionmaker() as session: 59 | hash_password = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() 60 | query = update(self.model_cls).where(User.id.in_([id])).values(hash_password=hash_password) 61 | await session.execute(query) 62 | await session.commit() 63 | 64 | async def orm_save_upload_field(self, obj: tp.Any, field: str, base64: str) -> None: 65 | sessionmaker = self.get_sessionmaker() 66 | async with sessionmaker() as session: 67 | # convert base64 to bytes, upload to s3/filestorage, get url and save or save base64 as is to db (don't recomment it) 68 | query = update(self.model_cls).where(User.id.in_([obj.id])).values(**{field: base64}) 69 | await session.execute(query) 70 | await session.commit() 71 | -------------------------------------------------------------------------------- /frontend/src/hooks/useTableQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import type { IInlineModel, IModel } from "@/interfaces/configuration"; 4 | 5 | const DEFAULT_PAGE = 1; 6 | const DEFAULT_PAGE_SIZE = 10; 7 | 8 | interface ITableQuery { 9 | defaultPage: number; 10 | defaultPageSize: number; 11 | page: number; 12 | setPage: (page: number) => void; 13 | pageSize: number; 14 | setPageSize: (pageSize: number) => void; 15 | search?: string; 16 | setSearch: (search?: string) => void; 17 | filters: any; 18 | setFilters: (filters: any) => void; 19 | sortBy?: string; 20 | setSortBy: (sortBy?: string) => void; 21 | action?: string; 22 | setAction: (action?: string) => void; 23 | selectedRowKeys: string[]; 24 | setSelectedRowKeys: (selectedRowKeys: string[]) => void; 25 | onTableChange: (pagination: any, tableFilters: any, sorter: any) => void; 26 | resetTable: (preserveFilters?: boolean) => void; 27 | } 28 | 29 | export const useTableQuery = ( 30 | modelConfiguration?: IModel | IInlineModel, 31 | ): ITableQuery => { 32 | const [action, setAction] = useState(); 33 | const [selectedRowKeys, setSelectedRowKeys] = useState([]); 34 | 35 | const [filters, setFilters] = useState({}); 36 | const [search, setSearch] = useState(); 37 | const [page, setPage] = useState(DEFAULT_PAGE); 38 | const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); 39 | const [sortBy, setSortBy] = useState(); 40 | 41 | useEffect(() => { 42 | if (modelConfiguration?.list_per_page) { 43 | setPageSize(modelConfiguration?.list_per_page); 44 | } 45 | }, [modelConfiguration?.list_per_page]); 46 | 47 | useEffect(() => { 48 | if (modelConfiguration?.name) { 49 | setPage(DEFAULT_PAGE); 50 | setSortBy(undefined); 51 | setFilters({}); 52 | } 53 | }, [modelConfiguration?.name]); 54 | 55 | const onTableChange = ( 56 | pagination: any, 57 | _tableFilters: any, 58 | sorter: any, 59 | ): void => { 60 | if (pagination.pageSize !== pageSize) { 61 | setPage(DEFAULT_PAGE); 62 | } else { 63 | setPage(pagination.current); 64 | } 65 | setPageSize(pagination.pageSize); 66 | if (sorter.field) { 67 | setSortBy(sorter.order === "ascend" ? sorter.field : `-${sorter.field}`); 68 | } 69 | }; 70 | 71 | const resetTable = (preserveFilters?: boolean): void => { 72 | setAction(undefined); 73 | setSelectedRowKeys([]); 74 | setPage(DEFAULT_PAGE); 75 | setPageSize(DEFAULT_PAGE_SIZE); 76 | setSortBy(undefined); 77 | 78 | if (!preserveFilters) { 79 | setFilters({}); 80 | setSearch(undefined); 81 | } 82 | }; 83 | 84 | return { 85 | defaultPage: DEFAULT_PAGE, 86 | defaultPageSize: DEFAULT_PAGE_SIZE, 87 | page, 88 | setPage, 89 | pageSize, 90 | setPageSize, 91 | search, 92 | setSearch, 93 | filters, 94 | setFilters, 95 | sortBy, 96 | setSortBy, 97 | action, 98 | setAction, 99 | selectedRowKeys, 100 | setSelectedRowKeys, 101 | onTableChange, 102 | resetTable, 103 | }; 104 | }; 105 | -------------------------------------------------------------------------------- /fastadmin/models/decorators.py: -------------------------------------------------------------------------------- 1 | def action(function=None, *, description: str | None = None): 2 | """Conveniently add attributes to an action function: 3 | 4 | Example of usage: 5 | @action( 6 | description="Mark selected stories as published", 7 | ) 8 | async def make_published(self, objs: list[Any]) -> None: 9 | ... 10 | 11 | :param function: A function to decorate. 12 | :param description: A string value to set the function's short_description 13 | """ 14 | 15 | def decorator(func): 16 | func.is_action = True 17 | if description is not None: 18 | func.short_description = description 19 | return func 20 | 21 | if function is None: 22 | return decorator 23 | else: 24 | return decorator(function) 25 | 26 | 27 | # TODO: make the sorter parameter a string to specify how to sort the data 28 | def display(function=None, *, sorter: bool = False): 29 | """Conveniently add attributes to a display function: 30 | 31 | Example of usage: 32 | @display 33 | async def is_published(self, obj): 34 | return obj.publish_date is not None 35 | 36 | :param function: A function to decorate. 37 | :param sorter: Enable sorting or not. **WARNING**: supported only for Django and Tortoise. 38 | Function name should be like an ORM ordering param, e.g. `def user__username(self, obj)`. 39 | """ 40 | 41 | def decorator(func): 42 | func.is_display = True 43 | func.sorter = sorter 44 | return func 45 | 46 | if function is None: 47 | return decorator 48 | else: 49 | return decorator(function) 50 | 51 | 52 | def register(*orm_model_classes, **kwargs): 53 | """Register the given model(s) classes and wrapped ModelAdmin class with 54 | admin site: 55 | 56 | Example of usage: 57 | @register(Author) 58 | class AuthorAdmin(admin.ModelAdmin): 59 | pass 60 | 61 | :param models: A list of models to register. 62 | """ 63 | from fastadmin.models.base import ModelAdmin 64 | from fastadmin.models.helpers import register_admin_model_class 65 | 66 | def wrapper(model_admin_cls): 67 | """Wrapper for register. 68 | 69 | :param model_admin_cls: A class to wrap. 70 | """ 71 | if not orm_model_classes: 72 | raise ValueError("At least one model must be passed to register.") 73 | 74 | if not issubclass(model_admin_cls, ModelAdmin): 75 | raise ValueError("Wrapped class must subclass ModelAdmin.") 76 | 77 | register_admin_model_class(model_admin_cls, orm_model_classes, **kwargs) 78 | 79 | return model_admin_cls 80 | 81 | return wrapper 82 | 83 | 84 | def register_widget(dashboard_widget_admin_cls): 85 | """Wrapper for register dashboard widget. 86 | 87 | :param admin_class: A class to wrap. 88 | """ 89 | from fastadmin.models.base import DashboardWidgetAdmin, admin_dashboard_widgets 90 | 91 | if not issubclass(dashboard_widget_admin_cls, DashboardWidgetAdmin): 92 | raise ValueError("Wrapped class must subclass DashboardWidgetAdmin.") 93 | 94 | admin_dashboard_widgets[dashboard_widget_admin_cls.__name__] = dashboard_widget_admin_cls() 95 | 96 | return dashboard_widget_admin_cls 97 | -------------------------------------------------------------------------------- /tests/api/test_auth.py: -------------------------------------------------------------------------------- 1 | from fastadmin.api.service import get_user_id_from_session_id 2 | from fastadmin.settings import settings 3 | 4 | 5 | async def test_sign_in_401_invalid_password(superuser, client): 6 | settings.ADMIN_USER_MODEL = superuser.get_model_name() 7 | r = await client.post( 8 | "/api/sign-in", 9 | json={ 10 | "username": superuser.username, 11 | "password": "invalid", 12 | }, 13 | ) 14 | assert r.status_code == 401, r.text 15 | 16 | 17 | async def test_sign_in_401(superuser, admin_models, client): 18 | settings.ADMIN_USER_MODEL = superuser.get_model_name() 19 | del admin_models[superuser.__class__] 20 | r = await client.post( 21 | "/api/sign-in", 22 | json={ 23 | "username": superuser.username, 24 | "password": superuser.password, 25 | }, 26 | ) 27 | assert r.status_code == 401, r.text 28 | 29 | 30 | async def test_sign_in_405(client): 31 | r = await client.get("/api/sign-in") 32 | assert r.status_code == 405, r.text 33 | 34 | 35 | async def test_sign_in(superuser, client): 36 | settings.ADMIN_USER_MODEL = superuser.get_model_name() 37 | r = await client.post( 38 | "/api/sign-in", 39 | json={ 40 | "username": superuser.username, 41 | "password": superuser.password, 42 | }, 43 | ) 44 | assert r.status_code == 200, r.text 45 | 46 | 47 | async def test_me(session_id, client): 48 | assert session_id 49 | user_id = await get_user_id_from_session_id(session_id) 50 | assert user_id 51 | r = await client.get( 52 | "/api/me", 53 | ) 54 | assert r.status_code == 200, r.text 55 | me = r.json() 56 | assert str(me["id"]) == str(user_id) 57 | 58 | 59 | async def test_me_401(client): 60 | r = await client.get("/api/me") 61 | assert r.status_code == 401, r.text 62 | 63 | 64 | async def test_me_405(session_id, client): 65 | assert session_id 66 | r = await client.post("/api/me") 67 | assert r.status_code == 405, r.text 68 | 69 | 70 | async def test_me_404(session_id, admin_models, superuser, client): 71 | assert session_id 72 | settings.ADMIN_USER_MODEL = superuser.get_model_name() 73 | del admin_models[superuser.__class__] 74 | r = await client.get("/api/me") 75 | assert r.status_code == 401, r.text 76 | 77 | 78 | async def test_sign_out(superuser, client): 79 | settings.ADMIN_USER_MODEL = superuser.get_model_name() 80 | r = await client.post( 81 | "/api/sign-in", 82 | json={ 83 | "username": superuser.username, 84 | "password": superuser.password, 85 | }, 86 | ) 87 | assert r.status_code == 200, r.status_code 88 | 89 | r = await client.post( 90 | "/api/sign-out", 91 | ) 92 | assert r.status_code == 200, r.text 93 | 94 | r = await client.get( 95 | "/api/me", 96 | ) 97 | assert r.status_code == 401, r.text 98 | 99 | r = await client.post( 100 | "/api/sign-out", 101 | ) 102 | assert r.status_code == 401, r.text 103 | 104 | 105 | async def test_sign_out_405(session_id, client): 106 | assert session_id 107 | r = await client.get("/api/sign-out") 108 | assert r.status_code == 405, r.text 109 | -------------------------------------------------------------------------------- /frontend/src/helpers/widgets.ts: -------------------------------------------------------------------------------- 1 | import { AsyncSelect } from "@/components/async-select"; 2 | import { AsyncTransfer } from "@/components/async-transfer"; 3 | import { JsonTextArea } from "@/components/json-textarea"; 4 | import { PasswordInput } from "@/components/password-input"; 5 | import { PhoneNumberInput } from "@/components/phone-number-input"; 6 | import { SlugInput } from "@/components/slug-input"; 7 | import { TextEditor } from "@/components/texteditor-field"; 8 | import { UploadInput } from "@/components/upload-input"; 9 | import { EFieldWidgetType } from "@/interfaces/configuration"; 10 | import { 11 | Checkbox, 12 | DatePicker, 13 | Input, 14 | InputNumber, 15 | Radio, 16 | Select, 17 | Switch, 18 | TimePicker, 19 | } from "antd"; 20 | 21 | export const getWidgetCls = ( 22 | widgetType: EFieldWidgetType, 23 | _t: any, 24 | id?: string, 25 | ) => { 26 | switch (widgetType) { 27 | case EFieldWidgetType.Input: 28 | return [Input, {}]; 29 | case EFieldWidgetType.InputNumber: 30 | return [ 31 | InputNumber, 32 | { 33 | style: { width: "100%" }, 34 | }, 35 | ]; 36 | case EFieldWidgetType.EmailInput: 37 | return [Input, {}]; 38 | case EFieldWidgetType.PhoneInput: 39 | return [PhoneNumberInput, {}]; 40 | case EFieldWidgetType.SlugInput: 41 | return [SlugInput, {}]; 42 | case EFieldWidgetType.UrlInput: 43 | return [Input, {}]; 44 | case EFieldWidgetType.PasswordInput: 45 | return [PasswordInput, { parentId: id }]; 46 | case EFieldWidgetType.TextArea: 47 | return [Input.TextArea, {}]; 48 | case EFieldWidgetType.RichTextArea: 49 | return [TextEditor, {}]; 50 | case EFieldWidgetType.JsonTextArea: 51 | return [JsonTextArea, {}]; 52 | case EFieldWidgetType.Select: 53 | return [ 54 | Select, 55 | { 56 | style: { width: "100%" }, 57 | }, 58 | ]; 59 | case EFieldWidgetType.AsyncSelect: 60 | return [ 61 | AsyncSelect, 62 | { 63 | style: { width: "100%" }, 64 | }, 65 | ]; 66 | case EFieldWidgetType.AsyncTransfer: 67 | return [AsyncTransfer, {}]; 68 | case EFieldWidgetType.Switch: 69 | return [Switch, {}]; 70 | case EFieldWidgetType.Checkbox: 71 | return [Checkbox, {}]; 72 | case EFieldWidgetType.CheckboxGroup: 73 | return [Checkbox.Group, {}]; 74 | case EFieldWidgetType.RadioGroup: 75 | return [Radio.Group, {}]; 76 | case EFieldWidgetType.DatePicker: 77 | return [ 78 | DatePicker, 79 | { 80 | style: { width: "100%" }, 81 | }, 82 | ]; 83 | case EFieldWidgetType.TimePicker: 84 | return [ 85 | TimePicker, 86 | { 87 | style: { width: "100%" }, 88 | }, 89 | ]; 90 | case EFieldWidgetType.DateTimePicker: 91 | return [DatePicker, { style: { width: "100%" }, showTime: true }]; 92 | case EFieldWidgetType.RangePicker: 93 | return [ 94 | DatePicker.RangePicker, 95 | { style: { width: "100%" }, placeholder: [_t("Start"), _t("End")] }, 96 | ]; 97 | case EFieldWidgetType.Upload: 98 | return [UploadInput, { parentId: id }]; 99 | default: 100 | return [Input, {}]; 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /frontend/src/containers/sign-in/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from "@tanstack/react-query"; 2 | import { Button, Card, Col, Form, Image, Input, Row, Space, theme } from "antd"; 3 | import type React from "react"; 4 | import { useContext } from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | import { SignInContainer } from "@/components/sign-in-container"; 8 | import { postFetcher } from "@/fetchers/fetchers"; 9 | import { handleError } from "@/helpers/forms"; 10 | import { getTitleFromFieldName } from "@/helpers/title"; 11 | import { ConfigurationContext } from "@/providers/ConfigurationProvider"; 12 | import { SignInUserContext } from "@/providers/SignInUserProvider"; 13 | 14 | export const SignIn: React.FC = () => { 15 | const [form] = Form.useForm(); 16 | const { 17 | token: { colorPrimary }, 18 | } = theme.useToken(); 19 | const { configuration } = useContext(ConfigurationContext); 20 | const { signedInUserRefetch } = useContext(SignInUserContext); 21 | const { t: _t } = useTranslation("SignIn"); 22 | 23 | const { mutate: mutateSignIn, isPending: loadingSignIn } = useMutation({ 24 | mutationFn: (payload: any) => postFetcher("/sign-in", payload), 25 | onSuccess: () => { 26 | signedInUserRefetch(); 27 | }, 28 | onError: (error) => { 29 | handleError(error, form); 30 | }, 31 | }); 32 | 33 | const onFinish = (data: any) => { 34 | mutateSignIn(data); 35 | }; 36 | 37 | return ( 38 | 39 | 40 | 41 |
42 | 43 | 52 | 55 | {configuration.site_name} 56 | 57 | 58 | 59 | 60 | 61 | 67 | 74 | 75 | 76 | 77 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /tests/models/test_base.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastadmin import ModelAdmin 4 | from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType 5 | 6 | 7 | async def test_not_implemented_methods(): 8 | class Model: 9 | pass 10 | 11 | base = ModelAdmin(Model) 12 | with pytest.raises(NotImplementedError): 13 | await base.authenticate("username", "password") 14 | 15 | with pytest.raises(NotImplementedError): 16 | await base.orm_save_obj(None, {}) 17 | 18 | with pytest.raises(NotImplementedError): 19 | await base.orm_delete_obj({}) 20 | 21 | with pytest.raises(NotImplementedError): 22 | await base.orm_get_list() 23 | 24 | with pytest.raises(NotImplementedError): 25 | await base.orm_get_obj(0) 26 | 27 | with pytest.raises(NotImplementedError): 28 | await base.orm_get_m2m_ids({}, "test") 29 | 30 | with pytest.raises(NotImplementedError): 31 | await base.orm_save_m2m_ids({}, "test", []) 32 | 33 | with pytest.raises(NotImplementedError): 34 | await base.get_model_fields_with_widget_types() 35 | 36 | with pytest.raises(NotImplementedError): 37 | await base.get_model_pk_name(base.model_cls) 38 | 39 | 40 | async def test_export_wrong_format(mocker): 41 | class Model: 42 | pass 43 | 44 | base = ModelAdmin(Model) 45 | 46 | mocker.patch.object(base, "get_model_fields_with_widget_types", return_value=[]) 47 | assert not base.get_fields_for_serialize() 48 | 49 | values = [ 50 | ModelFieldWidgetSchema( 51 | name=f"test_{index}", 52 | column_name=f"test_{index}", 53 | is_m2m=False, 54 | is_pk=False, 55 | is_immutable=False, 56 | form_widget_type=WidgetType.Input, 57 | form_widget_props={}, 58 | filter_widget_type=WidgetType.Input, 59 | filter_widget_props={}, 60 | ) 61 | for index in range(3) 62 | ] 63 | mocker.patch.object(base, "get_model_fields_with_widget_types", return_value=values) 64 | fields = base.get_fields_for_serialize() 65 | assert len(fields) == 3 66 | assert "test_0" in base.get_fields_for_serialize() 67 | 68 | base.exclude = ("test_0",) 69 | fields = base.get_fields_for_serialize() 70 | assert len(fields) == 2 71 | assert "test_0" not in base.get_fields_for_serialize() 72 | 73 | base.fields = ("test_0",) 74 | base.exclude = ("test_0",) 75 | fields = base.get_fields_for_serialize() 76 | assert len(fields) == 0 77 | assert "test_0" not in base.get_fields_for_serialize() 78 | 79 | base.fields = ("test_0",) 80 | base.exclude = () 81 | fields = base.get_fields_for_serialize() 82 | assert len(fields) == 1 83 | assert "test_0" in base.get_fields_for_serialize() 84 | 85 | base.fields = ("test_0",) 86 | base.list_display = ("test_1",) 87 | base.exclude = () 88 | fields = base.get_fields_for_serialize() 89 | assert len(fields) == 2 90 | assert "test_0" in base.get_fields_for_serialize() 91 | assert "test_1" in base.get_fields_for_serialize() 92 | 93 | 94 | async def test_get_fields_for_serialize(mocker): 95 | class Model: 96 | pass 97 | 98 | base = ModelAdmin(Model) 99 | 100 | mocker.patch.object(base, "orm_get_list", return_value=([], 0)) 101 | mocker.patch.object(base, "get_model_fields_with_widget_types", return_value=[]) 102 | assert await base.get_export("wrong_format") is None 103 | -------------------------------------------------------------------------------- /frontend/src/containers/add/index.tsx: -------------------------------------------------------------------------------- 1 | import { SaveOutlined } from "@ant-design/icons"; 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 | import { 4 | Breadcrumb, 5 | Button, 6 | Col, 7 | Empty, 8 | Form, 9 | Row, 10 | Space, 11 | message, 12 | } from "antd"; 13 | import type React from "react"; 14 | import { useContext } from "react"; 15 | import { useTranslation } from "react-i18next"; 16 | import { Link, useNavigate, useParams } from "react-router-dom"; 17 | 18 | import { CrudContainer } from "@/components/crud-container"; 19 | import { FormContainer } from "@/components/form-container"; 20 | import { postFetcher } from "@/fetchers/fetchers"; 21 | import { getConfigurationModel } from "@/helpers/configuration"; 22 | import { handleError } from "@/helpers/forms"; 23 | import { getTitleFromModel } from "@/helpers/title"; 24 | import { transformDataToServer } from "@/helpers/transform"; 25 | import { EModelPermission } from "@/interfaces/configuration"; 26 | import { ConfigurationContext } from "@/providers/ConfigurationProvider"; 27 | 28 | export const Add: React.FC = () => { 29 | const [form] = Form.useForm(); 30 | const queryClient = useQueryClient(); 31 | const navigate = useNavigate(); 32 | const { configuration } = useContext(ConfigurationContext); 33 | const { t: _t } = useTranslation("Add"); 34 | const { model } = useParams(); 35 | 36 | const modelConfiguration = getConfigurationModel( 37 | configuration, 38 | model as string, 39 | ); 40 | 41 | const { 42 | mutate, 43 | isPending: isLoading, 44 | isError, 45 | } = useMutation({ 46 | mutationFn: (payload: any) => postFetcher(`/add/${model}`, payload), 47 | onSuccess: () => { 48 | message.success(_t("Succesfully added")); 49 | 50 | queryClient.invalidateQueries([`/list/${model}`] as any); 51 | navigate(`/list/${model}`); 52 | }, 53 | onError: (error: Error) => { 54 | handleError(error, form); 55 | }, 56 | }); 57 | 58 | const onFinish = (payload: any) => { 59 | const data: any = transformDataToServer(payload); 60 | mutate(data); 61 | }; 62 | 63 | return ( 64 | {_t("Dashboard")} }, 72 | { 73 | title: ( 74 | 75 | {modelConfiguration && getTitleFromModel(modelConfiguration)} 76 | 77 | ), 78 | }, 79 | { title: _t("Add") }, 80 | ]} 81 | /> 82 | } 83 | > 84 | {modelConfiguration?.permissions.includes(EModelPermission.Add) ? ( 85 | 92 | 93 | 94 | 95 | 98 | 99 | 100 | 101 | 102 | ) : ( 103 | 104 | )} 105 | 106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /frontend/src/components/upload-input/index.tsx: -------------------------------------------------------------------------------- 1 | import { UploadOutlined } from "@ant-design/icons"; 2 | import { Image, Modal, Space, Upload } from "antd"; 3 | import ImgCrop from "antd-img-crop"; 4 | import getBase64 from "getbase64data"; 5 | import type React from "react"; 6 | import { useContext, useMemo, useState } from "react"; 7 | import { useTranslation } from "react-i18next"; 8 | 9 | import { isArray } from "@/helpers/transform"; 10 | import { ConfigurationContext } from "@/providers/ConfigurationProvider"; 11 | 12 | interface IUploadWrapperProps { 13 | withCrop?: boolean; 14 | children: React.ReactNode; 15 | } 16 | 17 | export interface IUploadInput { 18 | parentId: string; 19 | 20 | value?: any; 21 | 22 | defaultValue?: any; 23 | 24 | onChange?: (value: any) => void; 25 | multiple?: boolean; 26 | disableCropImage?: boolean; 27 | } 28 | 29 | export const UploadInput: React.FC = ({ 30 | value, 31 | onChange, 32 | multiple, 33 | disableCropImage, 34 | ...props 35 | }) => { 36 | const { t: _t } = useTranslation("UploadInput"); 37 | const [previewFileUrl, setPreviewFileUrl] = useState(); 38 | const { configuration } = useContext(ConfigurationContext); 39 | 40 | const onUpload = async (info: any) => { 41 | const changedFileList = multiple ? info.fileList : info.fileList.slice(-1); 42 | const base64List: string[] = []; 43 | for (const file of changedFileList) { 44 | if (file.originFileObj) { 45 | const content = await getBase64.fromFile(file.originFileObj); 46 | base64List.push(content); 47 | } else { 48 | base64List.push(file.url); 49 | } 50 | } 51 | if (multiple) { 52 | if (onChange) onChange(base64List); 53 | } else { 54 | if (onChange) onChange(base64List[0] || null); 55 | } 56 | }; 57 | 58 | const defaultFileList = useMemo(() => { 59 | if (!value) return undefined; 60 | const v = isArray(value) ? value : [value]; 61 | return v.map((url: string, index: number) => { 62 | return { 63 | uid: index, 64 | status: "done", 65 | url, 66 | }; 67 | }); 68 | }, [value]); 69 | 70 | const onPreview = async (file: any) => { 71 | setPreviewFileUrl( 72 | file.url ? file.url : await getBase64.fromFile(file.originFileObj), 73 | ); 74 | }; 75 | 76 | const onClosePreview = () => setPreviewFileUrl(undefined); 77 | 78 | const beforeUpload = () => false; 79 | 80 | const UploadWrapper: React.FC = ({ 81 | withCrop, 82 | children, 83 | }) => { 84 | if (withCrop) { 85 | return {children}; 86 | } 87 | return <>{children}; 88 | }; 89 | 90 | return ( 91 | <> 92 | 95 | 105 | 106 | 107 | {_t("Upload")} 108 | 109 | 110 | 111 | 117 | 118 | 119 | 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /tests/environment/django/orm/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.14 on 2024-08-08 05:19 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] # noqa: RUF012 11 | 12 | operations = [ # noqa: RUF012 13 | migrations.CreateModel( 14 | name="BaseEvent", 15 | fields=[ 16 | ("id", models.AutoField(primary_key=True, serialize=False)), 17 | ("created_at", models.DateTimeField(auto_now_add=True)), 18 | ("updated_at", models.DateTimeField(auto_now=True)), 19 | ], 20 | options={ 21 | "db_table": "base_event", 22 | }, 23 | ), 24 | migrations.CreateModel( 25 | name="Tournament", 26 | fields=[ 27 | ("id", models.AutoField(primary_key=True, serialize=False)), 28 | ("created_at", models.DateTimeField(auto_now_add=True)), 29 | ("updated_at", models.DateTimeField(auto_now=True)), 30 | ("name", models.CharField(max_length=255)), 31 | ], 32 | options={ 33 | "db_table": "tournament", 34 | }, 35 | ), 36 | migrations.CreateModel( 37 | name="User", 38 | fields=[ 39 | ("id", models.AutoField(primary_key=True, serialize=False)), 40 | ("created_at", models.DateTimeField(auto_now_add=True)), 41 | ("updated_at", models.DateTimeField(auto_now=True)), 42 | ("username", models.CharField(max_length=255)), 43 | ("password", models.CharField(max_length=255)), 44 | ("is_superuser", models.BooleanField(default=False)), 45 | ], 46 | options={ 47 | "db_table": "user", 48 | }, 49 | ), 50 | migrations.CreateModel( 51 | name="Event", 52 | fields=[ 53 | ("id", models.AutoField(primary_key=True, serialize=False)), 54 | ("created_at", models.DateTimeField(auto_now_add=True)), 55 | ("updated_at", models.DateTimeField(auto_now=True)), 56 | ("name", models.CharField(max_length=255)), 57 | ("rating", models.IntegerField(default=0)), 58 | ("description", models.TextField(null=True)), 59 | ( 60 | "event_type", 61 | models.CharField( 62 | choices=[("PRIVATE", "PRIVATE"), ("PUBLIC", "PUBLIC")], default="PUBLIC", max_length=255 63 | ), 64 | ), 65 | ("is_active", models.BooleanField(default=True)), 66 | ("start_time", models.TimeField(null=True)), 67 | ("date", models.DateField(null=True)), 68 | ("latitude", models.FloatField(null=True)), 69 | ("longitude", models.FloatField(null=True)), 70 | ("price", models.DecimalField(decimal_places=2, max_digits=10, null=True)), 71 | ("json", models.JSONField(null=True)), 72 | ( 73 | "base", 74 | models.OneToOneField( 75 | null=True, 76 | on_delete=django.db.models.deletion.SET_NULL, 77 | related_name="event", 78 | to="orm.baseevent", 79 | ), 80 | ), 81 | ("participants", models.ManyToManyField(related_name="events", to="orm.user")), 82 | ( 83 | "tournament", 84 | models.ForeignKey( 85 | on_delete=django.db.models.deletion.CASCADE, related_name="events", to="orm.tournament" 86 | ), 87 | ), 88 | ], 89 | options={ 90 | "db_table": "event", 91 | }, 92 | ), 93 | ] 94 | -------------------------------------------------------------------------------- /frontend/src/interfaces/configuration.ts: -------------------------------------------------------------------------------- 1 | export enum EFieldWidgetType { 2 | Input = "Input", 3 | InputNumber = "InputNumber", 4 | SlugInput = "SlugInput", 5 | EmailInput = "EmailInput", 6 | PhoneInput = "PhoneInput", 7 | UrlInput = "UrlInput", 8 | PasswordInput = "PasswordInput", 9 | TextArea = "TextArea", 10 | RichTextArea = "RichTextArea", 11 | JsonTextArea = "JsonTextArea", 12 | Select = "Select", 13 | AsyncSelect = "AsyncSelect", 14 | AsyncTransfer = "AsyncTransfer", 15 | Switch = "Switch", 16 | Checkbox = "Checkbox", 17 | TimePicker = "TimePicker", 18 | DatePicker = "DatePicker", 19 | DateTimePicker = "DateTimePicker", 20 | RangePicker = "RangePicker", 21 | RadioGroup = "RadioGroup", 22 | CheckboxGroup = "CheckboxGroup", 23 | Upload = "Upload", 24 | } 25 | 26 | export enum EDashboardWidgetType { 27 | ChartLine = "ChartLine", 28 | ChartArea = "ChartArea", 29 | ChartColumn = "ChartColumn", 30 | ChartBar = "ChartBar", 31 | ChartPie = "ChartPie", 32 | } 33 | 34 | export enum EModelPermission { 35 | Add = "Add", 36 | Change = "Change", 37 | Delete = "Delete", 38 | Export = "Export", 39 | } 40 | 41 | export enum EExportFormat { 42 | CSV = "CSV", 43 | JSON = "JSON", 44 | } 45 | 46 | export interface IModelAction { 47 | name: string; 48 | description?: string; 49 | } 50 | 51 | export interface IListConfigurationField { 52 | index?: number; 53 | sorter?: boolean; 54 | width?: string; 55 | is_link?: boolean; 56 | empty_value_display: string; 57 | filter_widget_type?: EFieldWidgetType; 58 | 59 | filter_widget_props?: any; 60 | } 61 | 62 | export interface IAddConfigurationField { 63 | index?: number; 64 | form_widget_type?: EFieldWidgetType; 65 | 66 | form_widget_props?: any; 67 | required?: boolean; 68 | } 69 | 70 | export interface IChangeConfigurationField { 71 | index?: number; 72 | form_widget_type?: EFieldWidgetType; 73 | 74 | form_widget_props?: any; 75 | required?: boolean; 76 | } 77 | 78 | export interface IModelField { 79 | name: string; 80 | list_configuration?: IListConfigurationField; 81 | add_configuration?: IAddConfigurationField; 82 | change_configuration?: IChangeConfigurationField; 83 | } 84 | 85 | interface IBaseModel { 86 | name: string; 87 | permissions: EModelPermission[]; 88 | actions: IModelAction[]; 89 | actions_on_top?: boolean; 90 | actions_on_bottom?: boolean; 91 | actions_selection_counter?: boolean; 92 | fields: IModelField[]; 93 | list_per_page?: number; 94 | search_help_text?: string; 95 | search_fields?: string[]; 96 | preserve_filters?: boolean; 97 | list_max_show_all?: number; 98 | show_full_result_count?: boolean; 99 | verbose_name?: string; 100 | verbose_name_plural?: string; 101 | } 102 | 103 | export interface IInlineModel extends IBaseModel { 104 | fk_name: string; 105 | max_num?: number; 106 | min_num?: number; 107 | } 108 | 109 | export interface IModel extends IBaseModel { 110 | fieldsets?: [string | undefined, Record][]; 111 | save_on_top?: boolean; 112 | save_as?: boolean; 113 | save_as_continue?: boolean; 114 | view_on_site?: string; 115 | inlines?: IInlineModel[]; 116 | } 117 | 118 | export interface IDashboardWidget { 119 | key: string; 120 | 121 | title: string; 122 | dashboard_widget_type: EDashboardWidgetType; 123 | dashboard_widget_props: Record; 124 | 125 | x_field: string; 126 | x_field_filter_widget_type?: EFieldWidgetType; 127 | x_field_filter_widget_props?: Record; 128 | x_field_periods?: string[]; 129 | 130 | y_field: string; 131 | } 132 | 133 | export interface IConfiguration { 134 | site_name: string; 135 | site_sign_in_logo?: string; 136 | site_header_logo?: string; 137 | site_favicon?: string; 138 | primary_color?: string; 139 | username_field: string; 140 | date_format?: string; 141 | datetime_format?: string; 142 | disable_crop_image?: boolean; 143 | models: IModel[]; 144 | dashboard_widgets: IDashboardWidget[]; 145 | } 146 | -------------------------------------------------------------------------------- /tests/environment/django/dev/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for dev project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "test" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = False 27 | 28 | ALLOWED_HOSTS = ["*"] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "orm", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "dev.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = "dev.wsgi.application" 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.sqlite3", 80 | "NAME": ":sharedmemory:", 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 91 | }, 92 | { 93 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 106 | 107 | LANGUAGE_CODE = "en-us" 108 | 109 | TIME_ZONE = "UTC" 110 | 111 | USE_I18N = True 112 | 113 | USE_TZ = True 114 | 115 | 116 | # Static files (CSS, JavaScript, Images) 117 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 118 | 119 | STATIC_URL = "static/" 120 | 121 | # Default primary key field type 122 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 123 | 124 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 125 | 126 | # Logging 127 | LOGGING = { 128 | "version": 1, 129 | "disable_existing_loggers": True, 130 | "handlers": { 131 | "console": { 132 | "class": "logging.StreamHandler", 133 | }, 134 | }, 135 | "root": { 136 | "handlers": ["console"], 137 | "level": "ERROR", 138 | }, 139 | } 140 | -------------------------------------------------------------------------------- /examples/django_djangoorm/dev/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for dev project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "test" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = False 27 | 28 | ALLOWED_HOSTS = ["*"] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "orm", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "dev.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = "dev.wsgi.application" 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.sqlite3", 80 | "NAME": ":sharedmemory:", 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 91 | }, 92 | { 93 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 106 | 107 | LANGUAGE_CODE = "en-us" 108 | 109 | TIME_ZONE = "UTC" 110 | 111 | USE_I18N = True 112 | 113 | USE_TZ = True 114 | 115 | 116 | # Static files (CSS, JavaScript, Images) 117 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 118 | 119 | STATIC_URL = "static/" 120 | 121 | # Default primary key field type 122 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 123 | 124 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 125 | 126 | # Logging 127 | LOGGING = { 128 | "version": 1, 129 | "disable_existing_loggers": True, 130 | "handlers": { 131 | "console": { 132 | "class": "logging.StreamHandler", 133 | }, 134 | }, 135 | "root": { 136 | "handlers": ["console"], 137 | "level": "ERROR", 138 | }, 139 | } 140 | -------------------------------------------------------------------------------- /tests/api/test_add.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from fastadmin.models.base import ModelAdmin 4 | from fastadmin.models.helpers import get_admin_model 5 | from fastadmin.models.schemas import ModelFieldWidgetSchema 6 | 7 | 8 | async def test_add(session_id, admin_models, event, client): 9 | assert session_id 10 | event_admin_model: ModelAdmin = admin_models[event.__class__] 11 | fields = event_admin_model.get_model_fields_with_widget_types() 12 | 13 | participants_field: ModelFieldWidgetSchema = next((f for f in fields if f.name == "participants"), None) 14 | assert participants_field 15 | tournament_field: ModelFieldWidgetSchema = next((f for f in fields if f.name == "tournament"), None) 16 | assert tournament_field 17 | 18 | participants_model_cls_name = participants_field.form_widget_props["parentModel"] 19 | participants_model = f"{event_admin_model.model_name_prefix}.{participants_model_cls_name}" 20 | participants_admin_model = get_admin_model(participants_model) 21 | participant = await participants_admin_model.save_model(None, {"username": "participant", "password": "test"}) 22 | 23 | tournament_model_cls_name = tournament_field.form_widget_props["parentModel"] 24 | tournament_model = f"{event_admin_model.model_name_prefix}.{tournament_model_cls_name}" 25 | tournament_admin_model = get_admin_model(tournament_model) 26 | tournament = await tournament_admin_model.save_model(None, {"name": "test_tournament"}) 27 | 28 | r = await client.post( 29 | f"/api/add/{event.get_model_name()}", 30 | json={ 31 | "name": "new name", 32 | "tournament": tournament["id"], 33 | "participants": [participant["id"]], 34 | "rating": 10, 35 | "description": "test", 36 | "event_type": "PRIVATE", 37 | "is_active": True, 38 | # TODO: sqlite doesn't support datetime.time 39 | # "start_time": datetime.datetime.now(tz=datetime.UTC).isoformat(), 40 | "date": datetime.datetime.now(tz=datetime.UTC).isoformat(), 41 | "latitude": 0.2, 42 | "longitude": 0.4, 43 | "price": "20.3", 44 | "json": {"test": "test"}, 45 | }, 46 | ) 47 | assert r.status_code == 200, r.text 48 | item = r.json() 49 | updated_event = await event_admin_model.get_obj(item["id"]) 50 | assert item["name"] == "new name" 51 | assert item["tournament"] == tournament["id"] 52 | assert datetime.datetime.fromisoformat(item["created_at"]) == updated_event["created_at"] 53 | assert datetime.datetime.fromisoformat(item["updated_at"]) == updated_event["updated_at"] 54 | assert item["participants"] == [participant["id"]] 55 | r = await client.delete(f"/api/delete/{event.get_model_name()}/{item['id']}") 56 | assert r.status_code == 200, r.text 57 | r = await client.delete(f"/api/delete/{participants_model}/{participant['id']}") 58 | assert r.status_code == 200, r.text 59 | r = await client.delete(f"/api/delete/{tournament_model}/{tournament['id']}") 60 | assert r.status_code == 200, r.text 61 | 62 | 63 | async def test_add_405(session_id, event, client): 64 | assert session_id 65 | r = await client.get( 66 | f"/api/add/{event.get_model_name()}", 67 | ) 68 | assert r.status_code == 405, r.text 69 | 70 | 71 | async def test_add_401(superuser, tournament, event, client): 72 | r = await client.post( 73 | f"/api/add/{event.get_model_name()}", 74 | json={ 75 | "name": "new name", 76 | "tournament": tournament.id, 77 | "participants": [superuser.id], 78 | }, 79 | ) 80 | assert r.status_code == 401, r.text 81 | 82 | 83 | async def test_add_404(session_id, admin_models, superuser, tournament, event, client): 84 | assert session_id 85 | del admin_models[event.__class__] 86 | r = await client.post( 87 | f"/api/add/{event.get_model_name()}", 88 | json={ 89 | "name": "new name", 90 | "tournament": tournament.id, 91 | "participants": [superuser.id], 92 | }, 93 | ) 94 | assert r.status_code == 404, r.text 95 | -------------------------------------------------------------------------------- /frontend/src/components/export-btn/index.tsx: -------------------------------------------------------------------------------- 1 | import { DownloadOutlined } from "@ant-design/icons"; 2 | import { useMutation } from "@tanstack/react-query"; 3 | import { 4 | Button, 5 | Col, 6 | Divider, 7 | Form, 8 | InputNumber, 9 | Modal, 10 | Row, 11 | Select, 12 | Space, 13 | message, 14 | } from "antd"; 15 | import fileDownload from "js-file-download"; 16 | import querystring from "query-string"; 17 | import type React from "react"; 18 | import { useCallback, useState } from "react"; 19 | import { useTranslation } from "react-i18next"; 20 | 21 | import { postFetcher } from "@/fetchers/fetchers"; 22 | import { transformFiltersToServer } from "@/helpers/transform"; 23 | import { EExportFormat } from "@/interfaces/configuration"; 24 | 25 | export interface IExportBtn { 26 | model?: string; 27 | search?: string; 28 | sortBy?: string; 29 | 30 | filters?: any; 31 | 32 | style?: any; 33 | } 34 | 35 | export const ExportBtn: React.FC = ({ 36 | model, 37 | search, 38 | sortBy, 39 | filters, 40 | style, 41 | }) => { 42 | const [form] = Form.useForm(); 43 | const { t: _t } = useTranslation("ExportBtn"); 44 | 45 | const [open, setOpen] = useState(false); 46 | 47 | const exportQueryString = querystring.stringify({ 48 | search, 49 | sort_by: sortBy, 50 | ...transformFiltersToServer(filters), 51 | }); 52 | 53 | const { mutate: mutateExport, isPending: isLoadingExport } = useMutation({ 54 | mutationFn: (payload: any) => 55 | postFetcher(`/export/${model}?${exportQueryString}`, payload), 56 | onSuccess: (data) => { 57 | const fileData = 58 | form.getFieldValue("format") === EExportFormat.JSON 59 | ? JSON.stringify(data) 60 | : data; 61 | fileDownload( 62 | fileData, 63 | `${model}.${form.getFieldValue("format").toLowerCase()}`, 64 | ); 65 | setOpen(false); 66 | form.resetFields(); 67 | message.success(_t("Successfully exported")); 68 | }, 69 | onError: () => { 70 | message.error(_t("Server error")); 71 | }, 72 | }); 73 | 74 | const onExport = useCallback( 75 | (data: any) => mutateExport(data), 76 | [mutateExport], 77 | ); 78 | const onClose = useCallback(() => setOpen(false), []); 79 | const onOpen = useCallback(() => setOpen(true), []); 80 | 81 | return ( 82 | <> 83 | 89 | 96 | 97 | 98 | 99 | 108 | 109 |