├── tests ├── __init__.py ├── test_ra.py ├── conftest.py ├── test_plugin.py ├── test_basic.py ├── test_sqlalchemy.py └── test_peewee.py ├── muffin_admin ├── py.typed ├── peewee │ ├── log.py │ ├── schemas.py │ └── __init__.py ├── utils.py ├── __init__.py ├── types.py ├── admin.html ├── sqlalchemy │ └── __init__.py ├── plugin.py └── handler.py ├── frontend ├── .npmignore ├── src │ ├── actions │ │ ├── index.ts │ │ └── LinkAction.tsx │ ├── i18n │ │ ├── index.ts │ │ ├── en.ts │ │ ├── ru.ts │ │ └── provider.ts │ ├── web.ts │ ├── buttons │ │ ├── index.ts │ │ ├── CopyButton.tsx │ │ ├── LinkButton.tsx │ │ └── ActionButton.tsx │ ├── filters │ │ ├── index.ts │ │ ├── DateRangeFilter.tsx │ │ ├── SearchFilter.tsx │ │ └── Filter.tsx │ ├── hooks │ │ ├── index.ts │ │ ├── useMuffinAdminOpts.ts │ │ ├── useMuffinResourceOpts.ts │ │ └── useAction.ts │ ├── inputs │ │ ├── index.ts │ │ ├── TimestampInput.tsx │ │ ├── ImgInput.tsx │ │ ├── FKInput.tsx │ │ ├── DateRangeInput.tsx │ │ └── JsonInput.tsx │ ├── common │ │ ├── index.ts │ │ ├── HelpLink.tsx │ │ ├── PayloadButtons.tsx │ │ ├── AdminModal.tsx │ │ └── ConfirmationProvider.tsx │ ├── context.ts │ ├── fields │ │ ├── index.ts │ │ ├── EditableBooleanField.tsx │ │ ├── CopyField.tsx │ │ ├── AvatarField.tsx │ │ ├── FKField.tsx │ │ └── JsonField.tsx │ ├── MuffinResource.tsx │ ├── themes.ts │ ├── MuffinResourceCreate.tsx │ ├── MuffinRecordList.tsx │ ├── index.tsx │ ├── MuffinResourceShow.tsx │ ├── MuffinResourceEdit.tsx │ ├── authprovider.ts │ ├── buildRA.tsx │ ├── MuffinDashboard.tsx │ ├── utils.tsx │ ├── MuffinMenu.tsx │ ├── types.ts │ ├── MuffinResourceList.tsx │ ├── MuffinAdmin.tsx │ └── dataprovider.ts ├── Makefile ├── tsconfig.json ├── .eslintrc.json ├── webpack.config.js └── package.json ├── .github ├── codeowners ├── muffin-admin.png ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── security.md ├── dependabot.yml ├── workflows │ ├── release.yml │ └── tests.yml ├── contributing.md └── code_of_conduct.md ├── example ├── __init__.py ├── admin.css ├── Makefile ├── sqlalchemy_core │ ├── __init__.py │ ├── database.py │ └── admin.py ├── peewee_orm │ ├── __init__.py │ ├── schemas.py │ ├── database.py │ ├── manage.py │ └── admin.py └── views.py ├── .gitignore ├── .git-commits.yaml ├── .pre-commit-config.yaml ├── Makefile ├── pyproject.toml └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /muffin_admin/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | -------------------------------------------------------------------------------- /.github/codeowners: -------------------------------------------------------------------------------- 1 | # default owners 2 | * @klen 3 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- 1 | """Muffin Admin Examples.""" 2 | -------------------------------------------------------------------------------- /frontend/src/actions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./LinkAction" 2 | -------------------------------------------------------------------------------- /muffin_admin/peewee/log.py: -------------------------------------------------------------------------------- 1 | class LogModel: 2 | pass 3 | -------------------------------------------------------------------------------- /example/admin.css: -------------------------------------------------------------------------------- 1 | th span[data-sort] { 2 | border-bottom: 1px dashed #ccc; 3 | } 4 | -------------------------------------------------------------------------------- /.github/muffin-admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klen/muffin-admin/HEAD/.github/muffin-admin.png -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | Changes is in this PR 4 | - 5 | - 6 | 7 | cc/ @klen 8 | -------------------------------------------------------------------------------- /frontend/src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./en" 2 | export * from "./provider" 3 | export * from "./ru" 4 | -------------------------------------------------------------------------------- /frontend/src/web.ts: -------------------------------------------------------------------------------- 1 | import { initAdmin } from "." 2 | 3 | // @ts-ignore 4 | globalThis.initAdmin = initAdmin 5 | -------------------------------------------------------------------------------- /frontend/src/buttons/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ActionButton" 2 | export * from "./CopyButton" 3 | export * from "./LinkButton" 4 | -------------------------------------------------------------------------------- /frontend/src/filters/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./DateRangeFilter" 2 | export * from "./Filter" 3 | export * from "./SearchFilter" 4 | -------------------------------------------------------------------------------- /frontend/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useAction" 2 | export * from "./useMuffinAdminOpts" 3 | export * from "./useMuffinResourceOpts" 4 | -------------------------------------------------------------------------------- /frontend/src/inputs/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./FKInput" 2 | export * from "./ImgInput" 3 | export * from "./JsonInput" 4 | export * from "./TimestampInput" 5 | -------------------------------------------------------------------------------- /frontend/src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./AdminModal" 2 | export * from "./ConfirmationProvider" 3 | export * from "./HelpLink" 4 | export * from "./PayloadButtons" 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | 3 | contact_links: 4 | - name: Email 5 | url: mailto:horneds@gmail.com 6 | about: Send me an email. 7 | -------------------------------------------------------------------------------- /frontend/src/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react" 2 | import { AdminOpts } from "./types" 3 | 4 | export const MuffinAdminContext = createContext(null) 5 | -------------------------------------------------------------------------------- /muffin_admin/peewee/schemas.py: -------------------------------------------------------------------------------- 1 | from marshmallow_peewee import ModelSchema 2 | 3 | 4 | class PeeweeModelSchema(ModelSchema): 5 | class Meta: 6 | datetimeformat = "timestamp_ms" 7 | -------------------------------------------------------------------------------- /.github/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | If you discover a security issue in this repo, please contact me via horneds@gmail.com 4 | 5 | Thanks for helping make this project safe for everyone. 6 | -------------------------------------------------------------------------------- /frontend/src/fields/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./AvatarField" 2 | export * from "./CopyField" 3 | export * from "./EditableBooleanField" 4 | export * from "./FKField" 5 | export * from "./JsonField" 6 | -------------------------------------------------------------------------------- /frontend/src/fields/EditableBooleanField.tsx: -------------------------------------------------------------------------------- 1 | import { BooleanInput } from "ra-ui-materialui" 2 | 3 | export function EditableBooleanField({ source }) { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/hooks/useMuffinAdminOpts.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react" 2 | import { MuffinAdminContext } from "../context" 3 | 4 | export function useMuffinAdminOpts() { 5 | return useContext(MuffinAdminContext) 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/hooks/useMuffinResourceOpts.ts: -------------------------------------------------------------------------------- 1 | import { useResourceContext } from "react-admin" 2 | import { useMuffinAdminOpts } from "./useMuffinAdminOpts" 3 | 4 | export function useMuffinResourceOpts(resource?) { 5 | resource = useResourceContext({ resource }) 6 | const { resources } = useMuffinAdminOpts() 7 | return resources.find((r) => r.name === resource) 8 | } 9 | -------------------------------------------------------------------------------- /muffin_admin/utils.py: -------------------------------------------------------------------------------- 1 | def deepmerge(dest: dict, source: dict): 2 | """Deep merge two dictionaries.""" 3 | for key, value in source.items(): 4 | if isinstance(value, dict): 5 | # get node or create one 6 | node = dest.setdefault(key, {}) 7 | deepmerge(node, value) 8 | else: 9 | dest[key] = value 10 | 11 | return dest 12 | -------------------------------------------------------------------------------- /example/Makefile: -------------------------------------------------------------------------------- 1 | db.sqlite db: 2 | MUFFIN_AIOLIB=asyncio muffin peewee_orm db 3 | MUFFIN_AIOLIB=asyncio muffin peewee_orm devdata 4 | 5 | BACKEND_PORT ?= 8080 6 | peewee: db.sqlite 7 | MUFFIN_AIOLIB=asyncio uvicorn peewee_orm:app --reload --port=$(BACKEND_PORT) --http=httptools 8 | 9 | sqlalchemy: db.sqlite 10 | MUFFIN_AIOLIB=asyncio uvicorn sqlalchemy_core:app --reload --port=8080 --http=httptools 11 | -------------------------------------------------------------------------------- /example/sqlalchemy_core/__init__.py: -------------------------------------------------------------------------------- 1 | """Setup the application and plugins.""" 2 | 3 | from muffin import Application 4 | 5 | from example import views 6 | 7 | # Create Muffin Application named 'example' 8 | app = Application(name="example", debug=True) 9 | 10 | app.route("/")(views.index) 11 | app.route("/admin.css")(views.admin_css) 12 | 13 | # Import the app's components 14 | app.import_submodules() 15 | -------------------------------------------------------------------------------- /frontend/src/i18n/en.ts: -------------------------------------------------------------------------------- 1 | import { TranslationMessages } from "ra-core" 2 | import raEnglishMessages from "ra-language-english" 3 | 4 | const muffinEnglishMessages: TranslationMessages = { 5 | ...raEnglishMessages, 6 | muffin: { 7 | instructions: "Instructions", 8 | how_to_use_admin: "How to use Admin?", 9 | action: { 10 | search: "Enter to search", 11 | }, 12 | }, 13 | } 14 | 15 | export default muffinEnglishMessages 16 | -------------------------------------------------------------------------------- /frontend/src/common/HelpLink.tsx: -------------------------------------------------------------------------------- 1 | import HelpIcon from "@mui/icons-material/Help" 2 | import Link from "@mui/material/Link" 3 | import { Button, useTranslate } from "react-admin" 4 | 5 | export function HelpLink({ label, ...props }: Parameters[0]) { 6 | const translate = useTranslate() 7 | label = label || translate("muffin.instructions") 8 | return ( 9 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /example/peewee_orm/__init__.py: -------------------------------------------------------------------------------- 1 | """Setup the application and plugins.""" 2 | 3 | from pathlib import Path 4 | 5 | from muffin import Application 6 | 7 | from example import views 8 | 9 | # Create Muffin Application named 'example' 10 | app = Application( 11 | name="example", debug=True, static_folders=[Path(__file__).parent.parent / "static"] 12 | ) 13 | 14 | app.route("/")(views.index) 15 | app.route("/admin.css")(views.admin_css) 16 | 17 | # Import the app's components 18 | app.import_submodules() 19 | -------------------------------------------------------------------------------- /frontend/src/i18n/ru.ts: -------------------------------------------------------------------------------- 1 | import { TranslationMessages } from "ra-core" 2 | import raRussianMessages from "ra-language-russian" 3 | 4 | const muffinRussianMessages = { 5 | ...raRussianMessages, 6 | muffin: { 7 | instructions: "Инструкции", 8 | how_to_use_admin: "Как использовать админку?", 9 | action: { 10 | search: "Enter для поиска", 11 | }, 12 | }, 13 | } as unknown as TranslationMessages 14 | 15 | muffinRussianMessages.ra.action.edit = "Изменить" 16 | 17 | export default muffinRussianMessages 18 | -------------------------------------------------------------------------------- /muffin_admin/__init__.py: -------------------------------------------------------------------------------- 1 | """Plugin meta information.""" 2 | 3 | from contextlib import suppress 4 | 5 | from .handler import AdminHandler 6 | from .plugin import Plugin 7 | 8 | with suppress(ImportError): 9 | from .peewee import PWAdminHandler, PWFilter 10 | 11 | with suppress(ImportError): 12 | from .sqlalchemy import SAAdminHandler, SAFilter 13 | 14 | 15 | __all__ = ( 16 | "AdminHandler", 17 | "PWAdminHandler", 18 | "PWFilter", 19 | "Plugin", 20 | "SAAdminHandler", 21 | "SAFilter", 22 | ) 23 | -------------------------------------------------------------------------------- /frontend/src/fields/CopyField.tsx: -------------------------------------------------------------------------------- 1 | import get from "lodash/get" 2 | 3 | import { useRecordContext } from "react-admin" 4 | import { CopyButton } from "../buttons" 5 | 6 | export function CopyField({ source, ...props }) { 7 | const record = useRecordContext(props) 8 | const value = get(record, source) 9 | 10 | if (!value) return null 11 | return ( 12 |
13 | {value} 14 | 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /example/peewee_orm/schemas.py: -------------------------------------------------------------------------------- 1 | import marshmallow as ma 2 | from marshmallow_peewee import ModelSchema 3 | 4 | from example.peewee_orm.database import User 5 | 6 | 7 | class GreetActionSchema(ma.Schema): 8 | name = ma.fields.String(required=True) 9 | 10 | 11 | class UserSchema(ModelSchema): 12 | name = ma.fields.Function(lambda u: f"{u.first_name} {u.last_name}") 13 | picture = ma.fields.Raw() 14 | 15 | class Meta: 16 | model = User 17 | dump_only = ("created",) 18 | load_only = ("password",) 19 | -------------------------------------------------------------------------------- /frontend/Makefile: -------------------------------------------------------------------------------- 1 | all: node_modules 2 | $(CURDIR)/node_modules/.bin/webpack 3 | 4 | node_modules: package.json 5 | yarn 6 | touch node_modules 7 | 8 | pypi: 9 | yarn install --imutable 10 | yarn build 11 | $(CURDIR)/node_modules/.bin/webpack 12 | 13 | watch: node_modules 14 | $(CURDIR)/node_modules/.bin/webpack --watch 15 | 16 | dev: node_modules 17 | NODE_ENV=development $(CURDIR)/node_modules/.bin/webpack serve 18 | 19 | clean: 20 | @rm -rf dist 21 | 22 | lint: 23 | # npx lint 24 | npx tsc --noEmit --pretty 25 | 26 | 27 | build: clean 28 | yarn build 29 | -------------------------------------------------------------------------------- /frontend/src/inputs/TimestampInput.tsx: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns" 2 | import { DateTimeInput } from "react-admin" 3 | 4 | export function TimestampInput({ source, ms, ...props }) { 5 | return ( 6 | { 10 | if (!v) return "" 11 | const value = new Date(ms ? v : v * 1000) 12 | return format(value, "yyyy-MM-dd'T'HH:mm:ss") 13 | }} 14 | parse={(v) => { 15 | if (!v) return null 16 | return new Date(v).valueOf() / (ms ? 1 : 1000) 17 | }} 18 | /> 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /tests/test_ra.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import marshmallow as ma 4 | 5 | from muffin_admin.handler import AdminHandler 6 | 7 | 8 | def test_ma_enum_to_ra(): 9 | class TestEnum(Enum): 10 | a = "a" 11 | b = "b" 12 | c = "c" 13 | 14 | res = AdminHandler.to_ra_input(ma.fields.Enum(TestEnum), source="test") 15 | assert res == ( 16 | "SelectInput", 17 | { 18 | "choices": [ 19 | {"id": "a", "name": "a"}, 20 | {"id": "b", "name": "b"}, 21 | {"id": "c", "name": "c"}, 22 | ] 23 | }, 24 | ) 25 | -------------------------------------------------------------------------------- /frontend/src/filters/DateRangeFilter.tsx: -------------------------------------------------------------------------------- 1 | import { useInput } from "react-admin" 2 | import { DateRangeInput } from "../inputs/DateRangeInput" 3 | 4 | export function DateRangeFilter(props) { 5 | const { field } = useInput(props) 6 | 7 | const onChange = (dates: [Date, Date]) => { 8 | field.onChange({ 9 | $between: dates.map((d) => d?.toISOString() || null), 10 | }) 11 | } 12 | let filterValue = field.value.$between ?? [null, null] 13 | if (!Array.isArray(filterValue)) filterValue = [null, null] 14 | const value = filterValue.map((d: string) => (d ? new Date(d) : d)) 15 | 16 | return 17 | } 18 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "ES2015", 5 | "module": "ES2020", 6 | "lib": [ 7 | "dom", 8 | "dom.iterable", 9 | "esnext" 10 | ], 11 | "allowJs": true, 12 | "skipLibCheck": true, 13 | "strict": false, 14 | "forceConsistentCasingInFileNames": true, 15 | "incremental": true, 16 | "esModuleInterop": true, 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "react-jsx", 21 | "outDir": "dist", 22 | "declaration": true 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/fields/AvatarField.tsx: -------------------------------------------------------------------------------- 1 | import Avatar from "@mui/material/Avatar" 2 | import get from "lodash/get" 3 | import { useRecordContext } from "ra-core" 4 | 5 | export function AvatarField({ source, alt, style, nameProp, ...props }) { 6 | const record = useRecordContext(props) 7 | const value = get(record, source), 8 | name = record[nameProp] 9 | 10 | const letters = name 11 | ? name 12 | .trim(" ") 13 | .split(/\s+/) 14 | .slice(0, 2) 15 | .map((n: string) => n[0]?.toUpperCase()) 16 | .join("") 17 | : "" 18 | 19 | return ( 20 | 21 | {letters} 22 | 23 | ) 24 | } 25 | 26 | AvatarField.displayName = "AvatarField" 27 | -------------------------------------------------------------------------------- /frontend/src/common/PayloadButtons.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "@mui/material" 2 | import { Button } from "react-admin" 3 | 4 | export function PayloadButtons({ 5 | onClose, 6 | onSubmit, 7 | isValid = true, 8 | }: { 9 | onClose: () => void 10 | onSubmit?: (e: any) => void 11 | isValid: boolean 12 | }) { 13 | return ( 14 | 15 | 31 | ) 32 | } 33 | 34 | export default LinkButton 35 | -------------------------------------------------------------------------------- /example/views.py: -------------------------------------------------------------------------------- 1 | """Common views.""" 2 | 3 | from pathlib import Path 4 | 5 | from muffin import ResponseFile 6 | 7 | 8 | async def index(request): 9 | """Just a main page.""" 10 | return """ 11 | 13 |
14 | 21 |
22 | 23 | """ 24 | 25 | 26 | async def admin_css(request): 27 | return ResponseFile(Path(__file__).parent / "admin.css") 28 | -------------------------------------------------------------------------------- /frontend/src/MuffinResource.tsx: -------------------------------------------------------------------------------- 1 | import { Resource, ResourceProps } from "react-admin" 2 | import { useMuffinAdminOpts } from "./hooks" 3 | import { findBuilder, findIcon, setupAdmin } from "./utils" 4 | 5 | export function MuffinResource({ name, ...props }: ResourceProps) { 6 | const adminOpts = useMuffinAdminOpts() 7 | const opts = adminOpts?.resources.find((r) => r.name == name) 8 | if (!opts) return null 9 | 10 | const Create = findBuilder(["create", name]) 11 | const Edit = findBuilder(["edit", name]) 12 | const List = findBuilder(["list", name]) 13 | const Show = findBuilder(["show", name]) 14 | 15 | return ( 16 | 26 | ) 27 | } 28 | 29 | setupAdmin(["resource"], MuffinResource) 30 | -------------------------------------------------------------------------------- /frontend/src/fields/FKField.tsx: -------------------------------------------------------------------------------- 1 | import { ReferenceField, TextField, useFieldValue, UseFieldValueOptions } from "react-admin" 2 | 3 | export function FKField({ reference, refSource, refKey, link, source, ...props }) { 4 | refKey = refKey || "id" 5 | return ( 6 | 13 | 14 | {refSource !== refKey && } 15 | 16 | ) 17 | } 18 | 19 | function FKFieldValue(props: UseFieldValueOptions) { 20 | const value = useFieldValue(props) 21 | if (value === undefined) return null 22 | return ( 23 | <> 24 | {" (#"} 25 | 26 | {")"} 27 | 28 | ) 29 | } 30 | 31 | FKField.displayName = "FKField" 32 | -------------------------------------------------------------------------------- /.git-commits.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | convention: 3 | commitTypes: 4 | - chore 5 | - feat 6 | - fix 7 | - perf 8 | - refactor 9 | - style 10 | - test 11 | - build 12 | - ops 13 | - docs 14 | - merge 15 | commitScopes: [] 16 | releaseTagGlobPattern: v[0-9]*.[0-9]*.[0-9]* 17 | 18 | changelog: 19 | commitTypes: 20 | - feat 21 | - fix 22 | - perf 23 | - merge 24 | includeInvalidCommits: true 25 | commitScopes: [] 26 | commitIgnoreRegexPattern: "^WIP " 27 | headlines: 28 | feat: Features 29 | fix: Bug Fixes 30 | perf: Performance Improvements 31 | merge: Merges 32 | breakingChange: BREAKING CHANGES 33 | commitUrl: https://github.com/klen/muffin-admin/commit/%commit% 34 | commitRangeUrl: https://github.com/klen/muffin-admin/compare/%from%...%to%?diff=split 35 | issueRegexPattern: "#[0-9]+" 36 | issueUrl: https://github.com/klen/muffin-admin/issues/%issue% 37 | -------------------------------------------------------------------------------- /frontend/src/actions/LinkAction.tsx: -------------------------------------------------------------------------------- 1 | import { useRecordContext, useResourceContext } from "react-admin" 2 | import LinkButton from "../buttons/LinkButton" 3 | import { useMuffinAdminOpts } from "../hooks" 4 | import { AdminShowLink } from "../types" 5 | 6 | export function LinkAction({ 7 | field, 8 | filter, 9 | icon, 10 | label, 11 | ...props 12 | }: AdminShowLink & { resource: string }) { 13 | const record = useRecordContext() 14 | const { resources } = useMuffinAdminOpts() 15 | const currentResource = useResourceContext() 16 | 17 | if (!record) return null 18 | 19 | const resource = resources.find((r) => r.name === props.resource) 20 | 21 | return ( 22 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/themes.ts: -------------------------------------------------------------------------------- 1 | import { defaultDarkTheme, defaultLightTheme } from "react-admin" 2 | 3 | export const muffinLightTheme: typeof defaultLightTheme = { 4 | ...defaultLightTheme, 5 | components: { 6 | ...defaultLightTheme.components, 7 | RaMenuItemLink: { 8 | styleOverrides: { 9 | root: { 10 | "&.RaMenuItemLink-active": { 11 | borderRadius: "4px", 12 | backgroundColor: "#f0f0f0", 13 | }, 14 | }, 15 | }, 16 | }, 17 | }, 18 | } 19 | 20 | export const muffinDarkTheme: typeof defaultDarkTheme = { 21 | ...defaultDarkTheme, 22 | components: { 23 | ...defaultDarkTheme.components, 24 | RaMenuItemLink: { 25 | styleOverrides: { 26 | root: { 27 | "&.RaMenuItemLink-active": { 28 | borderRadius: "4px", 29 | backgroundColor: "#424242", 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/fields/JsonField.tsx: -------------------------------------------------------------------------------- 1 | import get from "lodash/get" 2 | import { useState } from "react" 3 | import { Button, FunctionField, useRecordContext } from "react-admin" 4 | 5 | export function JsonField({ label, source }) { 6 | const [expand, setExpand] = useState(false) 7 | const record = useRecordContext() 8 | const value = get(record, source) 9 | if (!value || Object.keys(value).length == 0) return null 10 | 11 | const src = JSON.stringify(value, null, 2) 12 | const retval = ( 13 |
14 |

15 | {label} 16 |
28 | ) 29 | 30 | return retval} /> 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | about: "Create a bug report to help improve this project" 4 | title: "Bug report: [PLEASE DESCRIBE]" 5 | labels: ":bug: bug" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 1. Go to '...' 15 | 2. Click on '....' 16 | 3. Scroll down to '....' 17 | 4. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Desktop (please complete the following information):** 26 | - Version [e.g. 22] 27 | 28 | 29 | **Shell (please complete the following information):** 30 | - Type: [e.g. bash, zsh] 31 | - Terminal: [e.g. native, iTerm, hyper] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /muffin_admin/types.py: -------------------------------------------------------------------------------- 1 | """Custom types.""" 2 | 3 | from typing import Any, Callable, Literal, TypedDict 4 | 5 | from marshmallow.fields import Field 6 | 7 | try: 8 | from typing import NotRequired, TypeAlias # type: ignore[attr-defined] 9 | except ImportError: 10 | from typing_extensions import NotRequired, TypeAlias 11 | 12 | TActionView = Literal["show", "edit", "list", "bulk"] 13 | TRAProps = dict[str, Any] 14 | TRAInfo = tuple[str, TRAProps] 15 | TRAConverter = Callable[[Field], TRAInfo] 16 | TRAInputs: TypeAlias = tuple[tuple[str, TRAInfo], ...] 17 | 18 | 19 | class TRAReference(TypedDict): 20 | key: NotRequired[str] 21 | source: NotRequired[str] 22 | reference: NotRequired[str] 23 | searchKey: NotRequired[str] 24 | 25 | 26 | class TRAActionLink(TypedDict): 27 | label: NotRequired[str] 28 | icon: NotRequired[str] 29 | title: NotRequired[str] 30 | field: NotRequired[str] 31 | filter: NotRequired[str] 32 | 33 | 34 | TRALinks = tuple[tuple[str, TRAActionLink], ...] 35 | -------------------------------------------------------------------------------- /frontend/src/inputs/FKInput.tsx: -------------------------------------------------------------------------------- 1 | import { AutocompleteInput, ReferenceInput } from "react-admin" 2 | 3 | export function FKInput({ refKey, refSource, fullWidth, reference, source, ...props }) { 4 | // Bind to AutoCompleteInput if we want to customize the search 5 | // const filterToQuery = (search) => { 6 | // const filters = {} 7 | // filters[refKey] = search 8 | // return filters 9 | // } 10 | 11 | refKey = refKey || "id" 12 | const renderText = (record) => { 13 | const key = record[refKey] 14 | if (refKey == refSource) return `#${key}` 15 | return (key && `${record[refSource]} (#${key})`) || "" 16 | } 17 | 18 | return ( 19 | 26 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "prettier" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "parserOptions": { 11 | "ecmaFeatures": { 12 | "jsx": true 13 | }, 14 | "ecmaVersion": 2018, 15 | "sourceType": "module", 16 | "project": "./tsconfig.json" 17 | }, 18 | "plugins": [ 19 | "@typescript-eslint", 20 | "prettier", 21 | "react" 22 | ], 23 | "rules": { 24 | "no-console": "off", 25 | "no-debugger": "warn", 26 | "prettier/prettier": "warn", 27 | "react/prop-types": "off", 28 | "react/react-in-jsx-scope": "off", 29 | "@typescript-eslint/no-explicit-any": "off", 30 | "@typescript-eslint/ban-ts-comment": "off" 31 | }, 32 | "ignorePatterns": [ 33 | "node_modules", 34 | "build", 35 | "dist", 36 | "coverage", 37 | ".eslintrc.js", 38 | "jest.config.js", 39 | "babel.config.js", 40 | "webpack.config.js" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_run: 5 | workflows: [tests] 6 | branches: [master] 7 | types: [completed] 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | if: github.event.workflow_run.conclusion == 'success' 13 | steps: 14 | - uses: actions/checkout@main 15 | 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: lts/* 19 | registry-url: https://registry.npmjs.org 20 | cache: yarn 21 | cache-dependency-path: frontend/yarn.lock 22 | 23 | - name: Build Frontend 24 | run: | 25 | make pypi 26 | working-directory: frontend 27 | 28 | - name: Publish Frontend 29 | run: npm publish 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | working-directory: frontend 33 | 34 | - name: Install uv 35 | uses: astral-sh/setup-uv@v6 36 | 37 | - name: Build Package 38 | run: | 39 | uv build 40 | uv publish --token ${{ secrets.pypy }} 41 | -------------------------------------------------------------------------------- /frontend/src/MuffinResourceCreate.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react" 2 | import { Create, ListButton, SimpleForm, TopToolbar } from "react-admin" 3 | import { buildRA } from "./buildRA" 4 | import { useMuffinResourceOpts } from "./hooks" 5 | import { buildAdmin, findBuilder, setupAdmin } from "./utils" 6 | 7 | export function MuffinResourceCreate({ children }: PropsWithChildren) { 8 | const { name, create } = useMuffinResourceOpts() 9 | if (!create) return null 10 | 11 | const ActionsToolbar = findBuilder(["create-toolbar", name]) 12 | 13 | return ( 14 | }> 15 | {children} 16 | {buildAdmin(["create-inputs", name], create)} 17 | 18 | ) 19 | } 20 | 21 | setupAdmin(["create"], MuffinResourceCreate) 22 | setupAdmin(["create-inputs"], buildRA) 23 | 24 | export function MuffinCreateToolbar({ children }: PropsWithChildren) { 25 | return ( 26 | 27 | {children} 28 | 29 | 30 | ) 31 | } 32 | 33 | setupAdmin(["create-toolbar"], MuffinCreateToolbar) 34 | -------------------------------------------------------------------------------- /frontend/src/MuffinRecordList.tsx: -------------------------------------------------------------------------------- 1 | import Box from "@mui/material/Box" 2 | import Typography from "@mui/material/Typography" 3 | import { Datagrid, SortPayload, useGetList, useRecordContext, useTranslate } from "react-admin" 4 | import { useMuffinResourceOpts } from "./hooks" 5 | import { buildAdmin } from "./utils" 6 | 7 | type TProps = { 8 | resource: string 9 | filter: (record: any) => Record 10 | sort?: SortPayload 11 | } 12 | export function MuffinRecordList({ resource, filter, sort }: TProps) { 13 | const record = useRecordContext() 14 | const translate = useTranslate() 15 | const { 16 | list: { fields }, 17 | } = useMuffinResourceOpts(resource) 18 | const { data, isPending } = useGetList(resource, { sort, filter: filter(record) }) 19 | return ( 20 | 21 | 22 | {translate(`resources.${resource}.name`, { smart_count: 2 })} 23 | 24 | 25 | {buildAdmin(["list-fields", resource], fields)} 26 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: tests 5 | 6 | on: 7 | pull_request: 8 | branches: [master, develop] 9 | 10 | push: 11 | branches: [master, develop] 12 | 13 | jobs: 14 | tests: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.10", "3.11", "3.12", "3.13"] 20 | 21 | steps: 22 | - uses: actions/checkout@main 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v6 26 | with: 27 | enable-cache: true 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Setup dependencies 31 | run: uv sync --locked --all-extras --dev 32 | 33 | - name: Check code 34 | run: uv run ruff check muffin_admin 35 | 36 | - name: Check typing 37 | run: uv run mypy 38 | 39 | - name: Test with pytest 40 | run: uv run pytest tests 41 | -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"), 2 | webpack = require("webpack"), 3 | mode = process.env.NODE_ENV 4 | 5 | module.exports = { 6 | entry: "./src/web.ts", 7 | 8 | output: { 9 | filename: "main.js", 10 | path: path.resolve(__dirname, "../muffin_admin"), 11 | publicPath: "/admin", 12 | }, 13 | 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | use: "ts-loader", 19 | exclude: /node_modules/, 20 | }, 21 | { 22 | test: /\.s?css$/, 23 | use: [ 24 | "style-loader", 25 | "css-loader" 26 | ] 27 | } 28 | ], 29 | }, 30 | 31 | plugins: [ 32 | new webpack.EnvironmentPlugin({ NODE_ENV: "production" }), 33 | ], 34 | 35 | mode: mode || "production", 36 | devtool: mode == "development" && "inline-source-map", 37 | 38 | resolve: { 39 | extensions: [".js", ".tsx", ".ts"], 40 | }, 41 | 42 | devServer: { 43 | hot: true, 44 | open: true, 45 | proxy: [ 46 | { 47 | context: ["!*.js"], 48 | target: "http://127.0.0.1:5555", 49 | }, 50 | ], 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /muffin_admin/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {title} Admin UI 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 19 | 20 | 21 | 22 | 23 | {custom_css} 24 | 25 | 26 |
27 | 28 | 29 | 30 | {custom_js} 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /frontend/src/inputs/DateRangeInput.tsx: -------------------------------------------------------------------------------- 1 | import TextField from "@mui/material/TextField" 2 | import { enGB, ru } from "date-fns/locale" 3 | import { FieldTitle, InputProps, useLocale } from "react-admin" 4 | import DatePicker, { registerLocale } from "react-datepicker" 5 | import "react-datepicker/dist/react-datepicker.css" 6 | 7 | registerLocale("ru", ru) 8 | registerLocale("en", enGB) 9 | 10 | type TProps = InputProps & { 11 | value: [Date, Date] 12 | onChange: (value: [Date, Date]) => void 13 | } 14 | 15 | export function DateRangeInput({ value, onChange, ...props }: TProps) { 16 | const [startDate, endDate] = value 17 | const locale = useLocale() 18 | const { label, source, resource, isRequired } = props 19 | 20 | return ( 21 | 33 | } 34 | /> 35 | } 36 | /> 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/hooks/useAction.ts: -------------------------------------------------------------------------------- 1 | import { UseMutationOptions, useMutation } from "@tanstack/react-query" 2 | import { 3 | useDataProvider, 4 | useNotify, 5 | useRefresh, 6 | useResourceContext, 7 | useUnselectAll, 8 | } from "react-admin" 9 | import { MuffinDataprovider, TActionProps } from "../dataprovider" 10 | 11 | export function useAction( 12 | action: string, 13 | options?: UseMutationOptions<{ data: any }, any, TActionProps> 14 | ) { 15 | const notify = useNotify() 16 | const refresh = useRefresh() 17 | const resource = useResourceContext() 18 | const unselectAll = useUnselectAll(resource) 19 | const dataProvider = useDataProvider() as ReturnType 20 | 21 | return useMutation({ 22 | mutationFn: (params: TActionProps) => dataProvider.runAction(resource, action, params), 23 | onSuccess: ({ data }) => { 24 | if (data && data.message) notify(data.message, { type: "success" }) 25 | if (data && data.redirectTo) window.location = data.redirectTo 26 | else { 27 | unselectAll() 28 | refresh() 29 | } 30 | }, 31 | onError: (err) => { 32 | notify(typeof err === "string" ? err : err.message, { type: "error" }) 33 | }, 34 | ...options, 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: /fork 4 | [pr]: /compare 5 | 6 | Hi there! I'm thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 7 | 8 | ## Submitting a pull request 9 | 10 | 1. [Fork][fork] and clone the repository 11 | 1. Create a new branch: `git checkout -b my-branch-name` 12 | 1. Make your change 13 | 1. Push to your fork and [submit a pull request][pr] 14 | 1. Pat your self on the back and wait for your pull request to be reviewed and merged. 15 | 16 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 17 | 18 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 19 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 20 | 21 | Work in Progress pull request are also welcome to get feedback early on, or if there is something blocked you. 22 | 23 | ## Resources 24 | 25 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 26 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 27 | - [GitHub Help](https://help.github.com) 28 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client" 2 | import { MuffinAdmin } from "./MuffinAdmin" 3 | import { MuffinAdminContext } from "./context" 4 | 5 | export * from "./utils" 6 | 7 | export * from "./MuffinAdmin" 8 | export * from "./MuffinDashboard" 9 | export * from "./MuffinMenu" 10 | export * from "./MuffinRecordList" 11 | export * from "./MuffinResource" 12 | export * from "./MuffinResourceCreate" 13 | export * from "./MuffinResourceEdit" 14 | export * from "./MuffinResourceList" 15 | export * from "./MuffinResourceShow" 16 | export * from "./actions" 17 | export * from "./authprovider" 18 | export * from "./buildRA" 19 | export * from "./buttons" 20 | export * from "./common" 21 | export * from "./context" 22 | export * from "./dataprovider" 23 | export * from "./fields" 24 | export * from "./hooks" 25 | export * from "./types" 26 | 27 | export async function initAdmin(prefix = "", containerId: string = "root") { 28 | const response = await fetch(`${prefix}/ra.json`) 29 | const adminOpts = await response.json() 30 | const container = document.getElementById(containerId) 31 | if (!container) throw new Error(`Container #${containerId} not found`) 32 | const root = createRoot(container) 33 | root.render( 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | export const VERSION = "11.3.0" 41 | -------------------------------------------------------------------------------- /frontend/src/filters/SearchFilter.tsx: -------------------------------------------------------------------------------- 1 | import SearchIcon from "@mui/icons-material/Search" 2 | import { InputAdornment } from "@mui/material" 3 | import clsx from "clsx" 4 | import debounce from "lodash/debounce" 5 | import { useTranslate } from "ra-core" 6 | import { useState } from "react" 7 | import { ResettableTextField, SearchInputProps, useInput, useResourceContext } from "react-admin" 8 | 9 | export function SearchFilter({ source, className, ...props }: SearchInputProps) { 10 | const translate = useTranslate() 11 | const { field } = useInput({ source, ...props }) 12 | const [searchValue, setSearchValue] = useState(field.value || "") 13 | const resource = useResourceContext() 14 | 15 | const onBlur = () => field.onChange(searchValue) 16 | const label = translate(`resources.${resource}.fields.${source}`) 17 | 18 | return ( 19 | setSearchValue(e.target?.value ?? e)} 27 | onKeyUp={(e) => { 28 | if (e.key === "Enter") field.onChange(searchValue) 29 | }} 30 | {...props} 31 | className={clsx("ra-input", `ra-input-${source}`, className)} 32 | InputProps={{ 33 | endAdornment: ( 34 | 35 | 36 | 37 | ), 38 | }} 39 | /> 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /example/sqlalchemy_core/database.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import enum 3 | from pathlib import Path 4 | 5 | import sqlalchemy as sa 6 | from muffin_databases import Plugin 7 | 8 | from . import app 9 | 10 | # We will use Peewee as ORM, so connect the related plugin 11 | db = Plugin(app, url=f"sqlite:///{ Path(__file__).parent.parent / 'db.sqlite' }") 12 | 13 | meta = sa.MetaData() 14 | 15 | 16 | class Roles(enum.Enum): 17 | user = "user" 18 | manager = "manager" 19 | admin = "admin" 20 | 21 | 22 | User = sa.Table( 23 | "user", 24 | meta, 25 | sa.Column("id", sa.Integer, primary_key=True), 26 | sa.Column("created", sa.DateTime, default=dt.datetime.utcnow, nullable=False), 27 | sa.Column("email", sa.String(255), nullable=False), 28 | sa.Column("first_name", sa.String(255)), 29 | sa.Column("last_name", sa.String(255)), 30 | sa.Column("password", sa.String(255)), 31 | sa.Column("picture", sa.String(255), default="https://picsum.photos/200"), 32 | sa.Column("is_active", sa.Boolean, default=True), 33 | sa.Column("meta", sa.JSON, default={}), 34 | sa.Column("role", sa.Enum(Roles), default=Roles.user, nullable=False), 35 | ) 36 | 37 | 38 | class Statuses(enum.Enum): 39 | new = "new" 40 | published = "published" 41 | 42 | 43 | Message = sa.Table( 44 | "message", 45 | meta, 46 | sa.Column("id", sa.Integer, primary_key=True), 47 | sa.Column("created", sa.DateTime, default=dt.datetime.utcnow, nullable=False), 48 | sa.Column("status", sa.Enum(Statuses), default=Statuses.new, nullable=False), 49 | sa.Column("title", sa.String(255), nullable=False), 50 | sa.Column("body", sa.Text(), nullable=False), 51 | sa.Column("user_id", sa.ForeignKey("user.id"), nullable=False), 52 | ) 53 | -------------------------------------------------------------------------------- /example/peewee_orm/database.py: -------------------------------------------------------------------------------- 1 | """Setup application's models.""" 2 | 3 | import datetime as dt 4 | from pathlib import Path 5 | 6 | import peewee as pw 7 | from muffin_peewee import JSONLikeField, Plugin 8 | from peewee_aio.model import AIOModel 9 | 10 | from . import app 11 | 12 | db = Plugin(app, connection=f"sqlite:///{ Path(__file__).parent.parent / 'db.sqlite' }") 13 | 14 | 15 | class BaseModel(AIOModel): 16 | """Automatically keep the model's creation time.""" 17 | 18 | created = pw.DateTimeField(default=dt.datetime.utcnow) 19 | 20 | 21 | @db.register 22 | class Group(BaseModel): 23 | """A group.""" 24 | 25 | name = pw.CharField(max_length=255, unique=True) 26 | 27 | 28 | @db.register 29 | class User(BaseModel): 30 | """A simple user model.""" 31 | 32 | email = pw.CharField(primary_key=True) 33 | first_name = pw.CharField(null=True, help_text="First name") 34 | last_name = pw.CharField(null=True) 35 | password = pw.CharField(null=True) # not secure only for the example 36 | picture = pw.CharField( 37 | default="https://picsum.photos/100", 38 | help_text="Full URL to the picture", 39 | ) 40 | meta = JSONLikeField(default={}) 41 | 42 | is_active = pw.BooleanField(default=True) 43 | role = pw.CharField( 44 | choices=(("user", "user"), ("manager", "manager"), ("admin", "admin")), 45 | ) 46 | 47 | # Relationships 48 | group = pw.ForeignKeyField(Group, backref="users", null=True) 49 | 50 | 51 | @db.register 52 | class Message(BaseModel): 53 | """Just a users' messages.""" 54 | 55 | status = pw.CharField(choices=(("new", "new"), ("published", "published"))) 56 | title = pw.CharField() 57 | body = pw.TextField() 58 | dtpublish = pw.DateTimeField(null=True) 59 | 60 | user = pw.ForeignKeyField(User, help_text="Choose a user") 61 | -------------------------------------------------------------------------------- /frontend/src/inputs/JsonInput.tsx: -------------------------------------------------------------------------------- 1 | import { TextInput } from "react-admin" 2 | import isJSON from "validator/lib/isJSON" 3 | 4 | const DEFAULT_ERRORTEXT = "Invalid JSON" 5 | 6 | const parseFunction = (json) => { 7 | let retval = json 8 | try { 9 | retval = JSON.parse(json) 10 | } finally { 11 | // eslint-disable-next-line no-unsafe-finally 12 | return retval 13 | } 14 | } 15 | 16 | /** 17 | * 18 | * `JsonInput` validates if the entered value is JSON or not. If entered value is not a invalid JSON, `JsonInput` will throw an error. 19 | * Default error message is: `Invalid JSON` and can be overridden using `errortext` prop. 20 | * 21 | * @example 22 | * 23 | * 24 | * or use translate function: 25 | * 26 | * @example 27 | * 28 | * 29 | * By default, `JsonInput` parses and returns the entered string as object. Instead, to send string directly, please pass `parse` prop as `false` 30 | * 31 | * @example 32 | * 33 | */ 34 | export function JsonInput(props) { 35 | const { 36 | validate = [], 37 | errortext = DEFAULT_ERRORTEXT, 38 | multiline = true, 39 | parse = true, 40 | ...rest 41 | } = props 42 | // const errorobj = { message: errortext } 43 | 44 | const validateJSON = (value) => { 45 | if (!value || typeof value === "object") return undefined 46 | return isJSON(value) ? undefined : errortext 47 | } 48 | validate.push(validateJSON) 49 | rest.validate = validate 50 | 51 | const formatJSON = (json) => { 52 | let retval = json 53 | if (retval && typeof retval === "object") retval = JSON.stringify(retval, null, 2) 54 | return retval 55 | } 56 | rest.format = formatJSON 57 | // 58 | if (parse) rest.parse = parseFunction 59 | if (multiline) rest.multiline = true 60 | return 61 | } 62 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "muffin-admin", 3 | "version": "11.3.0", 4 | "description": "NPM package for https://github.com/klen/muffin-admin", 5 | "files": [ 6 | "src", 7 | "dist" 8 | ], 9 | "main": "dist/index.js", 10 | "types": "dist/index.d.js", 11 | "scripts": { 12 | "build": "tsc", 13 | "test": "echo \"Error: no test specified\" && exit 1" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/klen/muffin-admin.git" 18 | }, 19 | "author": "Kirill Klenov ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/klen/muffin-admin/issues" 23 | }, 24 | "homepage": "https://github.com/klen/muffin-admin#readme", 25 | "dependencies": { 26 | "date-fns": "^4.1.0", 27 | "js-cookie": "^3.0.5", 28 | "ra-language-russian": "^5.4.4", 29 | "react": "^19.1.1", 30 | "react-admin": "^5.10.1", 31 | "react-datepicker": "^8.5.0", 32 | "react-dom": "^19.1.1", 33 | "validator": "^13.12.0" 34 | }, 35 | "devDependencies": { 36 | "@types/lodash": "^4.17.20", 37 | "@types/node": "24.1.0", 38 | "@types/react": "19.1.9", 39 | "@types/react-dom": "19.1.7", 40 | "@types/react-window": "^1.8.8", 41 | "@typescript-eslint/eslint-plugin": "^7.12.0", 42 | "@typescript-eslint/parser": "^7.12.0", 43 | "css-loader": "^7.1.2", 44 | "dotenv": "^16.4.5", 45 | "eslint": "^8", 46 | "eslint-config-prettier": "^9.1.0", 47 | "eslint-plugin-prettier": "^5.1.3", 48 | "eslint-plugin-react": "^7.34.2", 49 | "html-webpack-plugin": "^5.6.0", 50 | "husky": "^9.0.11", 51 | "prettier": "^3.2", 52 | "prettier-plugin-organize-imports": "^3.2", 53 | "style-loader": "^4.0.0", 54 | "ts-loader": "^9.5.1", 55 | "typescript": "^5.4.5", 56 | "webpack": "^5.91.0", 57 | "webpack-cli": "^5.1.4", 58 | "webpack-dev-server": "^5.0.4" 59 | }, 60 | "prettier": { 61 | "arrowParens": "always", 62 | "plugins": [ 63 | "prettier-plugin-organize-imports" 64 | ], 65 | "printWidth": 100, 66 | "semi": false, 67 | "tabWidth": 2, 68 | "trailingComma": "es5", 69 | "useTabs": false 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /frontend/src/filters/Filter.tsx: -------------------------------------------------------------------------------- 1 | import Button from "@mui/material/Button" 2 | import FormGroup from "@mui/material/FormGroup" 3 | import Menu from "@mui/material/Menu" 4 | import MenuItem from "@mui/material/MenuItem" 5 | import TextField from "@mui/material/TextField" 6 | import { useState } from "react" 7 | import { FieldTitle, InputProps, useInput } from "react-admin" 8 | 9 | const OPERATORS = { 10 | $eq: "=", 11 | $ne: "≠", 12 | $gt: ">", 13 | $ge: "≥", 14 | $lt: "<", 15 | $le: "≤", 16 | } 17 | 18 | export function Filter({ type = "text", ...props }: InputProps) { 19 | const { field } = useInput({ 20 | format: (value: any) => (value ? value[Object.keys(value)[0]] : ""), 21 | ...props, 22 | }) 23 | const [op, setOp] = useState((field.value && Object.keys(field.value)[0]) || "$eq") 24 | 25 | const onChange = function(e: any) { 26 | field.onChange({ [op]: e.target.value }) 27 | } 28 | 29 | const [menuEl, setMenuEl] = useState(null) 30 | const open = Boolean(menuEl) 31 | const { label, source, resource, isRequired } = props 32 | 33 | return ( 34 | <> 35 | 36 | 44 | 52 | } 53 | /> 54 | 55 | setMenuEl(null)} anchorEl={menuEl}> 56 | {Object.entries(OPERATORS).map(([key, value]) => ( 57 | { 61 | setOp(key) 62 | setMenuEl(null) 63 | field.onChange({ [key]: field.value }) 64 | }} 65 | > 66 | {value} 67 | 68 | ))} 69 | 70 | 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | 4 | fail_fast: true 5 | default_install_hook_types: [commit-msg, pre-commit, pre-push] 6 | 7 | repos: 8 | - repo: https://github.com/qoomon/git-conventional-commits 9 | rev: "v2.7.2" 10 | hooks: 11 | - id: conventional-commits 12 | args: ["-c", ".git-commits.yaml"] 13 | stages: ["commit-msg"] 14 | 15 | - repo: https://github.com/pre-commit/pre-commit-hooks 16 | rev: v5.0.0 17 | hooks: 18 | - id: check-case-conflict 19 | stages: ["pre-commit"] 20 | - id: check-merge-conflict 21 | stages: ["pre-commit"] 22 | - id: check-added-large-files 23 | stages: ["pre-commit"] 24 | - id: check-ast 25 | stages: ["pre-commit"] 26 | - id: check-executables-have-shebangs 27 | stages: ["pre-commit"] 28 | - id: check-symlinks 29 | stages: ["pre-commit"] 30 | - id: check-toml 31 | stages: ["pre-commit"] 32 | - id: check-yaml 33 | stages: ["pre-commit"] 34 | - id: debug-statements 35 | stages: ["pre-commit"] 36 | - id: end-of-file-fixer 37 | stages: ["pre-commit"] 38 | - id: trailing-whitespace 39 | stages: ["pre-commit"] 40 | 41 | - repo: https://github.com/psf/black 42 | rev: 25.1.0 43 | hooks: 44 | - id: black 45 | stages: ["pre-commit"] 46 | 47 | - repo: https://github.com/astral-sh/uv-pre-commit 48 | rev: 0.8.9 49 | hooks: 50 | - id: uv-lock 51 | args: ["--check"] 52 | stages: ["pre-commit"] 53 | 54 | - repo: local 55 | hooks: 56 | - id: ruff 57 | name: ruff 58 | entry: uv run ruff check muffin_admin 59 | language: system 60 | pass_filenames: false 61 | files: \.py$ 62 | stages: ["pre-commit"] 63 | 64 | - id: mypy 65 | name: mypy 66 | entry: uv run mypy 67 | language: system 68 | pass_filenames: false 69 | files: \.py$ 70 | stages: ["pre-push"] 71 | 72 | - id: pytest 73 | name: pytest 74 | entry: uv run pytest tests 75 | language: system 76 | pass_filenames: false 77 | files: \.py$ 78 | stages: ["pre-push"] 79 | -------------------------------------------------------------------------------- /frontend/src/MuffinResourceShow.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react" 2 | import { EditButton, ListButton, Show, SimpleShowLayout, TopToolbar } from "react-admin" 3 | import { LinkAction } from "./actions" 4 | import { buildRA } from "./buildRA" 5 | import { ActionButton } from "./buttons" 6 | import { useMuffinResourceOpts } from "./hooks" 7 | import { buildAdmin, findBuilder, setupAdmin } from "./utils" 8 | 9 | export function MuffinShow({ children }: PropsWithChildren) { 10 | const { show, name, key } = useMuffinResourceOpts() 11 | const ShowToolbar = findBuilder(["show-toolbar", name]) 12 | 13 | return ( 14 | } queryOptions={{ meta: { key } }}> 15 | {buildAdmin(["show-fields", name], show)} 16 | {children} 17 | 18 | ) 19 | } 20 | 21 | setupAdmin(["show"], MuffinShow) 22 | setupAdmin(["show-fields"], ({ fields }) => buildRA(fields)) 23 | 24 | export function MuffinShowToolbar({ children }: PropsWithChildren) { 25 | const { show, name } = useMuffinResourceOpts() 26 | const { edit } = show 27 | 28 | const Links = findBuilder(["show-links", name]) 29 | const Actions = findBuilder(["show-actions", name]) 30 | 31 | return ( 32 | 33 | 34 | 35 | {children} 36 | 37 | {edit && } 38 | 39 | ) 40 | } 41 | 42 | setupAdmin(["show-toolbar"], MuffinShowToolbar) 43 | 44 | export function MuffinShowActions({ children }: PropsWithChildren) { 45 | const { actions: baseActions = [] } = useMuffinResourceOpts() 46 | const actions = baseActions.filter((a) => a.view?.includes("show")) 47 | return ( 48 | <> 49 | {children} 50 | {actions.map((props) => ( 51 | 52 | ))} 53 | 54 | ) 55 | } 56 | 57 | setupAdmin(["show-actions"], MuffinShowActions) 58 | 59 | export function MuffinShowLinks({ children }: PropsWithChildren) { 60 | const { show } = useMuffinResourceOpts() 61 | const { links } = show 62 | return ( 63 |
64 | {links.map(([key, props]) => ( 65 | 66 | ))} 67 | {children} 68 |
69 | ) 70 | } 71 | 72 | setupAdmin(["show-links"], MuffinShowLinks) 73 | -------------------------------------------------------------------------------- /frontend/src/common/ConfirmationProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Typography } from "@mui/material" 2 | import { createContext, PropsWithChildren, useContext, useState } from "react" 3 | import { useTranslate } from "react-admin" 4 | import { AdminModal } from "./AdminModal" 5 | 6 | type TConfirmProps = { 7 | title?: string 8 | message: string 9 | actions?: React.ReactNode 10 | } 11 | 12 | type TConfirmContext = { 13 | confirm: (props: TConfirmProps) => Promise 14 | } 15 | 16 | export const ConfirmContext = createContext({} as TConfirmContext) 17 | 18 | export function ConfirmationProvider({ children }: PropsWithChildren) { 19 | const translate = useTranslate() 20 | const [open, setOpen] = useState(false) 21 | const [title, setTitle] = useState(translate("ra.message.are_you_sure")) 22 | const [message, setMessage] = useState(null) 23 | const [resolver, setResolve] = useState void)>(null) 24 | 25 | function handleConfirm({ title, message }: TConfirmProps) { 26 | setOpen(true) 27 | if (title) setTitle(title) 28 | if (message) setMessage(message) 29 | return new Promise((resolve) => { 30 | setResolve(() => resolve) 31 | }) 32 | } 33 | const confirmContext = { 34 | confirm: handleConfirm, 35 | } 36 | return ( 37 | 38 | {children} 39 | setOpen(false)} 42 | title={title} 43 | maxWidth="xs" 44 | actions={ 45 | <> 46 | 55 | 64 | 65 | } 66 | > 67 | {message} 68 | 69 | 70 | ) 71 | } 72 | 73 | export const useConfirmation = () => { 74 | return useContext(ConfirmContext) 75 | } 76 | -------------------------------------------------------------------------------- /example/peewee_orm/manage.py: -------------------------------------------------------------------------------- 1 | """Custom CLI commands for the application.""" 2 | 3 | from random import choice 4 | 5 | from mixer.backend.peewee import Mixer 6 | 7 | from . import app 8 | from .database import Message, User 9 | from .database import db as database 10 | 11 | 12 | @app.manage.shell 13 | def shell(): 14 | """Open an interactive shell with the application context.""" 15 | ctx = {"app": app} 16 | ctx.update(app.plugins) 17 | for Model in database.manager.models: 18 | ctx[Model.__name__] = Model 19 | 20 | return ctx 21 | 22 | 23 | @app.manage 24 | async def db(): 25 | """Simple DB schema creation. For real case use a migration engine.""" 26 | async with database: 27 | await database.create_tables() 28 | 29 | 30 | @app.manage 31 | async def devdata(): 32 | """Generate some fake data.""" 33 | mixer = Mixer(commit=True) 34 | 35 | async with database.connection(): 36 | await User.get_or_create( 37 | email="admin@admin.com", 38 | defaults={ 39 | "password": "admin", 40 | "role": "admin", 41 | "first_name": "Admin", 42 | "last_name": "General", 43 | "picture": "https://picsum.photos/id/10/100", 44 | }, 45 | ) 46 | 47 | await User.get_or_create( 48 | email="manager@admin.com", 49 | defaults={ 50 | "password": "manager", 51 | "role": "manager", 52 | "first_name": "Manager", 53 | "last_name": "Throw", 54 | "picture": "https://picsum.photos/id/20/100", 55 | }, 56 | ) 57 | 58 | # Generate 100 users 59 | num_users = await User.select().count() 60 | for n in range(100 - num_users): 61 | await User.create( 62 | email=mixer.faker.email(), 63 | role="user", 64 | picture=f"https://picsum.photos/id/2{n}/100", 65 | first_name=mixer.faker.first_name(), 66 | last_name=mixer.faker.last_name(), 67 | ) 68 | 69 | # Generate 100 messages 70 | statuses = [choice[0] for choice in Message.status.choices] 71 | users = await User.select() 72 | for n in range(100): 73 | await Message.create( 74 | body=mixer.faker.text(), 75 | title=mixer.faker.title(), 76 | user=choice(users), # noqa: S311 77 | status=mixer.faker.random.choice(statuses), 78 | ) 79 | -------------------------------------------------------------------------------- /frontend/src/MuffinResourceEdit.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react" 2 | import { 3 | DeleteButton, 4 | Edit, 5 | ListButton, 6 | SaveButton, 7 | ShowButton, 8 | SimpleForm, 9 | Toolbar, 10 | TopToolbar, 11 | useResourceContext, 12 | } from "react-admin" 13 | import { buildRA } from "./buildRA" 14 | import { ActionButton } from "./buttons" 15 | import { useMuffinAdminOpts, useMuffinResourceOpts } from "./hooks" 16 | import { findBuilder, setupAdmin } from "./utils" 17 | 18 | export function MuffinEdit({ children }: PropsWithChildren) { 19 | const { 20 | adminProps: { mutationMode = "optimistic" }, 21 | } = useMuffinAdminOpts() 22 | 23 | const { edit, name, key } = useMuffinResourceOpts() 24 | if (!edit) return null 25 | 26 | const Actions = findBuilder(["edit-toolbar", name]) 27 | const Inputs = findBuilder(["edit-inputs", name]) 28 | const FormToolbar = findBuilder(["edit-form-toolbar", name]) 29 | 30 | return ( 31 | } mutationMode={mutationMode} queryOptions={{ meta: { key } }}> 32 | {children} 33 | }> 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | setupAdmin(["edit"], MuffinEdit) 41 | 42 | export function MuffinEditFormToolbar() { 43 | const { edit } = useMuffinResourceOpts() 44 | if (!edit) return null 45 | 46 | const { remove } = edit 47 | return ( 48 | 49 | 50 | {remove && } 51 | 52 | ) 53 | } 54 | 55 | setupAdmin(["edit-form-toolbar"], MuffinEditFormToolbar) 56 | 57 | export function MuffinEditInputs() { 58 | const { edit } = useMuffinResourceOpts() 59 | if (!edit) return null 60 | 61 | const { inputs } = edit 62 | 63 | return <>{buildRA(inputs)} 64 | } 65 | setupAdmin(["edit-inputs"], MuffinEditInputs) 66 | 67 | export function MuffinEditToolbar({ children }: PropsWithChildren) { 68 | const resource = useResourceContext() 69 | const Actions = findBuilder(["edit-actions", resource]) 70 | return ( 71 | 72 | 73 | {children} 74 | 75 | 76 | 77 | ) 78 | } 79 | 80 | setupAdmin(["edit-toolbar"], MuffinEditToolbar) 81 | 82 | export function MuffinEditActions({ children }: PropsWithChildren) { 83 | const { edit, actions: baseActions = [] } = useMuffinResourceOpts() 84 | if (!edit) return null 85 | const actions = baseActions.filter((a) => a.view?.includes("edit")) 86 | return ( 87 | <> 88 | {children} 89 | {actions.map((props, idx) => ( 90 | 91 | ))} 92 | 93 | ) 94 | } 95 | 96 | setupAdmin(["edit-actions"], MuffinEditActions) 97 | -------------------------------------------------------------------------------- /frontend/src/authprovider.ts: -------------------------------------------------------------------------------- 1 | import Cookies from "js-cookie" 2 | import { findBuilder, makeRequest, requestHeaders, setupAdmin } from "." 3 | import { AdminOpts } from "./types" 4 | 5 | setupAdmin( 6 | ["auth-storage-get"], 7 | ({ storage, storageName }: AdminOpts["auth"]) => 8 | () => 9 | storage == "localstorage" ? localStorage.getItem(storageName) : Cookies.get(storageName) 10 | ) 11 | 12 | setupAdmin(["auth-storage-set"], ({ storage }) => (name, value) => { 13 | if (storage == "localstorage") { 14 | localStorage.setItem(name, value) 15 | requestHeaders["Authorization"] = value 16 | } else Cookies.set(name, value) 17 | }) 18 | 19 | export function MuffinAuthProvider(props: AdminOpts["auth"]) { 20 | const { identityURL, authorizeURL, logoutURL, required, storageName } = props 21 | 22 | const authGet = findBuilder(["auth-storage-get"])(props) 23 | const authSet = findBuilder(["auth-storage-set"])(props) 24 | 25 | // Initialize request headers 26 | authSet(storageName, authGet(storageName)) 27 | 28 | const getIdentity = async () => { 29 | if (identityURL) { 30 | const { json } = await makeRequest(identityURL) 31 | return json 32 | } 33 | } 34 | 35 | if (required) 36 | return { 37 | login: async (data) => { 38 | if (!authorizeURL) throw { message: "Authorization is not supported" } 39 | 40 | const { json } = await makeRequest(authorizeURL, { 41 | data, 42 | method: "POST", 43 | }) 44 | authSet(storageName, json) 45 | }, 46 | 47 | checkError: async (error) => { 48 | const { status } = error || {} 49 | 50 | if (status == 401 || status == 403) { 51 | // Clean storage 52 | authSet(storageName, "") 53 | 54 | throw { 55 | message: "Invalid authorization", 56 | redirectTo: logoutURL, 57 | logoutUser: !logoutURL, 58 | } 59 | } 60 | }, 61 | 62 | checkAuth: async () => { 63 | const auth = authGet(storageName) 64 | if (!auth) throw { message: "Authorization required" } 65 | 66 | if (!identityURL) return auth 67 | 68 | const user = await getIdentity() 69 | if (!user) throw { message: "Authorization required" } 70 | 71 | return user 72 | }, 73 | 74 | getAuthorization: () => authGet(storageName), 75 | 76 | logout: async () => { 77 | // Clean storage 78 | authSet(storageName, "") 79 | 80 | if (logoutURL) window.location.href = logoutURL 81 | }, 82 | 83 | getIdentity, 84 | 85 | getPermissions: async () => { 86 | const role = authGet(storageName + "_role") 87 | return role || "" 88 | }, 89 | } 90 | } 91 | 92 | setupAdmin(["auth-provider"], MuffinAuthProvider) 93 | -------------------------------------------------------------------------------- /frontend/src/buildRA.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ArrayField, 3 | ArrayInput, 4 | AutocompleteArrayInput, 5 | AutocompleteInput, 6 | BooleanField, 7 | BooleanInput, 8 | CheckboxGroupInput, 9 | ChipField, 10 | DateField, 11 | DateInput, 12 | DateTimeInput, 13 | EmailField, 14 | FileField, 15 | FileInput, 16 | ImageField, 17 | ImageInput, 18 | NullableBooleanInput, 19 | NumberField, 20 | NumberInput, 21 | PasswordInput, 22 | RadioButtonGroupInput, 23 | ReferenceArrayField, 24 | ReferenceArrayInput, 25 | ReferenceField, 26 | ReferenceInput, 27 | ReferenceManyField, 28 | required, 29 | RichTextField, 30 | SearchInput, 31 | SelectArrayInput, 32 | SelectField, 33 | SelectInput, 34 | TextField, 35 | TextInput, 36 | UrlField, 37 | } from "react-admin" 38 | 39 | import { AvatarField, CopyField, EditableBooleanField, FKField, JsonField } from "./fields" 40 | import { DateRangeFilter, Filter, SearchFilter } from "./filters" 41 | import { FKInput, ImgInput, JsonInput, TimestampInput } from "./inputs" 42 | 43 | const UI: Record = { 44 | // Fields 45 | BooleanField, 46 | ChipField, 47 | DateField, 48 | EmailField, 49 | ImageField, 50 | FileField, 51 | NumberField, 52 | RichTextField, 53 | TextField, 54 | UrlField, 55 | SelectField, 56 | ArrayField, 57 | ReferenceField, 58 | ReferenceManyField, 59 | ReferenceArrayField, 60 | AvatarField, 61 | EditableBooleanField, 62 | FKField, 63 | JsonField, 64 | CopyField, 65 | 66 | // Inputs 67 | ArrayInput, 68 | AutocompleteArrayInput, 69 | AutocompleteInput, 70 | BooleanInput, 71 | CheckboxGroupInput, 72 | DateInput, 73 | DateTimeInput, 74 | FKInput, 75 | FileInput, 76 | ImageInput, 77 | ImgInput, 78 | JsonInput, 79 | NullableBooleanInput, 80 | NumberInput, 81 | PasswordInput, 82 | RadioButtonGroupInput, 83 | ReferenceArrayInput, 84 | ReferenceInput, 85 | SearchInput, 86 | SelectArrayInput, 87 | SelectInput, 88 | TextInput, 89 | TimestampInput, 90 | 91 | // Filters 92 | Filter, 93 | DateRangeFilter, 94 | SearchFilter, 95 | } 96 | 97 | export function buildRAComponent(name: string, props: any) { 98 | const Item = UI[name] 99 | if (!Item) return null 100 | 101 | if (props.required) { 102 | props.validate = required() 103 | delete props.required 104 | } 105 | 106 | if (props.children) props.children = buildRA(props.children)[0] 107 | 108 | return 109 | } 110 | 111 | export function buildRA(items: [string, Record][]) { 112 | return items.map((item) => buildRAComponent(item[0], item[1])) 113 | } 114 | 115 | export function registerUI(name: string, component: JSX.ElementType) { 116 | UI[name] = component 117 | } 118 | -------------------------------------------------------------------------------- /frontend/src/MuffinDashboard.tsx: -------------------------------------------------------------------------------- 1 | import Card from "@mui/material/Card" 2 | import CardContent from "@mui/material/CardContent" 3 | import Grid from "@mui/material/Grid" 4 | import Stack from "@mui/material/Stack" 5 | import Table from "@mui/material/Table" 6 | import TableBody from "@mui/material/TableBody" 7 | import TableCell from "@mui/material/TableCell" 8 | import TableRow from "@mui/material/TableRow" 9 | import Typography from "@mui/material/Typography" 10 | import { useTranslate } from "react-admin" 11 | import { VERSION } from "." 12 | import { HelpLink } from "./common/HelpLink" 13 | import { useMuffinAdminOpts } from "./hooks" 14 | import { AdminDashboardBlock } from "./types" 15 | import { buildAdmin, setupAdmin } from "./utils" 16 | 17 | export function MuffinDashboard() { 18 | const { dashboard, help } = useMuffinAdminOpts() 19 | const translate = useTranslate() 20 | return ( 21 | 22 | 23 | {help && } 24 | {buildAdmin(["dashboard-actions"])} 25 | 26 | 27 | {buildAdmin(["dashboard-content"])} 28 | 29 | 30 | {VERSION && ( 31 | 32 | Muffin Admin v.{VERSION} 33 | 34 | )} 35 | 36 | ) 37 | } 38 | 39 | setupAdmin(["dashboard"], MuffinDashboard) 40 | setupAdmin(["dashboard-actions"], () => null) 41 | setupAdmin(["dashboard-content"], () => null) 42 | 43 | function AdminCards({ src }: { src: AdminDashboardBlock | AdminDashboardBlock[] }) { 44 | if (Array.isArray(src)) 45 | return ( 46 | 47 | {src.map((card, idx) => ( 48 | 49 | ))} 50 | 51 | ) 52 | 53 | return 54 | } 55 | 56 | function DashboardCard({ title, value }: AdminDashboardBlock) { 57 | return ( 58 | 59 | 60 | 61 | {title} 62 | 63 | {(Array.isArray(value) && ) || ( 64 |
{JSON.stringify(value, null, 2)}
65 | )} 66 |
67 |
68 | ) 69 | } 70 | 71 | const AdminTableView = ({ src }) => ( 72 | 73 | 74 | {src.map((row: any, idx: number) => ( 75 | 76 | {row.map((cell: any, idx: number) => ( 77 | {cell} 78 | ))} 79 | 80 | ))} 81 | 82 |
83 | ) 84 | -------------------------------------------------------------------------------- /frontend/src/utils.tsx: -------------------------------------------------------------------------------- 1 | import * as icons from "@mui/icons-material" 2 | import { stringify } from "query-string" 3 | import { fetchUtils } from "ra-core" 4 | import { TAdminPath } from "./types" 5 | 6 | const builders = new Map any>() 7 | 8 | export function setupAdmin(adminType: TAdminPath, fc: (props: any) => T) { 9 | const key = adminType.join("/") 10 | builders.set(key, fc) 11 | } 12 | 13 | export function buildAdmin(adminType: TAdminPath, props = {}) { 14 | const builder = findBuilder(adminType) 15 | if (process.env.NODE_ENV == "development") console.log(props) 16 | if (builder) return builder(props) 17 | 18 | console.warn(`No admin renderer found for ${adminType.join("/")}`) 19 | return null 20 | } 21 | 22 | export function findBuilder(adminType: TAdminPath): (props: any) => any { 23 | if (process.env.NODE_ENV == "development") console.log(adminType) 24 | const key = [...adminType] 25 | while (key.length) { 26 | const builder = builders.get(key.join("/")) 27 | if (builder) return builder 28 | key.pop() 29 | } 30 | } 31 | 32 | export type APIParams = { 33 | method?: string 34 | query?: { 35 | where?: string 36 | limit?: number 37 | offset?: number 38 | sort?: string 39 | ids?: string[] 40 | } 41 | data?: any 42 | headers?: any 43 | body?: any 44 | } 45 | 46 | export const requestHeaders: Record = {} 47 | 48 | export function makeRequest(url: string, params: APIParams = {}) { 49 | const { data, query, ...opts } = params || {} 50 | 51 | if (data) opts.body = JSON.stringify(data) 52 | if (query) url = `${url}?${stringify(query)}` 53 | 54 | return fetchUtils.fetchJson(url, { 55 | ...opts, 56 | headers: new Headers({ ...requestHeaders, ...opts.headers }), 57 | }) 58 | } 59 | 60 | export function findIcon(icon?: string) { 61 | return icon ? icons[icon] : undefined 62 | } 63 | 64 | export function buildIcon(icon?: string) { 65 | const Icon = findIcon(icon) 66 | if (Icon) return 67 | } 68 | 69 | function isObject(item: any) { 70 | return item && typeof item === "object" && !Array.isArray(item) 71 | } 72 | 73 | export function deepMerge(target: any, ...sources: any[]): any { 74 | if (!sources.length) return target 75 | const source = sources.shift() 76 | 77 | if (isObject(target) && isObject(source)) { 78 | for (const key in source) { 79 | if (isObject(source[key])) { 80 | if (!target[key]) Object.assign(target, { [key]: {} }) 81 | deepMerge(target[key], source[key]) 82 | } else { 83 | Object.assign(target, { [key]: source[key] }) 84 | } 85 | } 86 | } 87 | return deepMerge(target, ...sources) 88 | } 89 | 90 | export function prepareFilters(filter: any) { 91 | return JSON.stringify( 92 | Object.entries(filter).reduce((acc, [key, value]) => { 93 | acc[key] = Array.isArray(value) ? { $in: value } : value 94 | return acc 95 | }, {}) 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VIRTUAL_ENV ?= .venv 2 | EXAMPLE = example 3 | PACKAGE = muffin_admin 4 | 5 | all: $(VIRTUAL_ENV) 6 | 7 | .PHONY: help 8 | # target: help - Display callable targets 9 | help: 10 | @egrep "^# target:" [Mm]akefile 11 | 12 | .PHONY: clean 13 | # target: clean - Display callable targets 14 | clean: 15 | rm -rf build/ dist/ docs/_build *.egg-info 16 | find $(CURDIR) -name "*.py[co]" -delete 17 | find $(CURDIR) -name "*.orig" -delete 18 | find $(CURDIR)/$(MODULE) -name "__pycache__" | xargs rm -rf 19 | 20 | # ============= 21 | # Development 22 | # ============= 23 | 24 | $(VIRTUAL_ENV): pyproject.toml .pre-commit-config.yaml 25 | @uv sync 26 | @uv run pre-commit install 27 | @touch $(VIRTUAL_ENV) 28 | 29 | .PHONY: test t 30 | # target: test - Runs tests 31 | test t: $(VIRTUAL_ENV) 32 | @uv run pytest tests 33 | 34 | example/db.sqlite: $(VIRTUAL_ENV) 35 | @uv run muffin $(EXAMPLE) db 36 | @uv run muffin $(EXAMPLE) devdata 37 | 38 | sqlite: example/db.sqlite 39 | sqlite3 example/db.sqlite 40 | 41 | .PHONY: locales 42 | LOCALE ?= ru 43 | locales: $(VIRTUAL_ENV)/bin/py.test db.sqlite 44 | @uv run muffin $(EXAMPLE) extract_messages $(PACKAGE) --locale $(LOCALE) 45 | @uv run muffin $(EXAMPLE) compile_messages 46 | 47 | .PHONY: front 48 | front: 49 | make -C frontend 50 | 51 | .PHONY: front-watch 52 | front-watch: 53 | make -C frontend watch 54 | 55 | .PHONY: front-dev 56 | front-dev: 57 | make -C frontend dev 58 | 59 | .PHONY: dev 60 | dev: 61 | BACKEND_PORT=5555 make -j example-peewee front-dev 62 | 63 | .PHONY: lint 64 | lint: $(VIRTUAL_ENV) 65 | @uv run mypy $(PACKAGE) 66 | @uv run ruff check $(PACKAGE) 67 | 68 | 69 | BACKEND_PORT ?= 8080 70 | .PHONY: example-peewee 71 | # target: example-peewee - Run example 72 | example-peewee: $(VIRTUAL_ENV) front 73 | @MUFFIN_AIOLIB=asyncio uv run muffin example.peewee_orm db 74 | @MUFFIN_AIOLIB=asyncio uv run muffin example.peewee_orm devdata 75 | @MUFFIN_AIOLIB=asyncio uv run uvicorn example.peewee_orm:app --reload --port=$(BACKEND_PORT) 76 | 77 | 78 | shell: $(VIRTUAL_ENV) 79 | @uv run muffin example.peewee_orm shell 80 | 81 | .PHONY: example-sqlalchemy 82 | # target: example-sqlalchemy - Run example 83 | example-sqlalchemy: $(VIRTUAL_ENV) front 84 | @uv run uvicorn example.sqlalchemy_core:app --reload --port=8080 85 | 86 | # ============== 87 | # Bump version 88 | # ============== 89 | 90 | .PHONY: release 91 | VERSION?=minor 92 | # target: release - Bump version 93 | release: $(VIRTUAL_ENV) 94 | @git checkout develop 95 | @git pull 96 | @git checkout master 97 | @git merge develop 98 | @git pull 99 | @uvx bump-my-version bump $(VERSION) 100 | @uv lock 101 | @git commit -am "build(release): `uv version --short`" 102 | @git tag `uv version --short` 103 | @git checkout develop 104 | @git merge master 105 | @git push --tags origin develop master 106 | 107 | .PHONY: minor 108 | minor: release 109 | 110 | .PHONY: patch 111 | patch: 112 | make release VERSION=patch 113 | 114 | .PHONY: major 115 | major: 116 | make release VERSION=major 117 | 118 | v: 119 | @echo `uv version --short` 120 | -------------------------------------------------------------------------------- /frontend/src/MuffinMenu.tsx: -------------------------------------------------------------------------------- 1 | import ExpandLess from "@mui/icons-material/ExpandLess" 2 | import ExpandMore from "@mui/icons-material/ExpandMore" 3 | import Collapse from "@mui/material/Collapse" 4 | import ListItemButton from "@mui/material/ListItemButton" 5 | import ListItemIcon from "@mui/material/ListItemIcon" 6 | import ListItemText from "@mui/material/ListItemText" 7 | import { matchPath, useLocation } from "react-router-dom" 8 | 9 | import { Menu, MenuProps, useBasename, useTheme, useTranslate } from "react-admin" 10 | 11 | import find from "lodash/find" 12 | import groupBy from "lodash/groupBy" 13 | import { useContext, useState } from "react" 14 | import { MuffinAdminContext } from "./context" 15 | import { AdminResourceProps } from "./types" 16 | import { findIcon, setupAdmin } from "./utils" 17 | 18 | export function MuffinMenu(props: MenuProps) { 19 | const { resources } = useContext(MuffinAdminContext) 20 | 21 | const groups = groupBy( 22 | resources.filter((r) => r.group), 23 | "group" 24 | ) 25 | const groupRendered = [] 26 | 27 | return ( 28 | 29 | 30 | {resources.map(({ name, group }) => { 31 | if (!group) return 32 | if (groupRendered.includes(group)) return null 33 | groupRendered.push(group) 34 | const groupResources = groups[group] 35 | return 36 | })} 37 | 38 | ) 39 | } 40 | 41 | setupAdmin(["menu"], MuffinMenu) 42 | 43 | function MenuGroup({ name, resources }: { name: string; resources: AdminResourceProps[] }) { 44 | const iconRes = find(resources, "icon") 45 | const Icon = iconRes ? findIcon(iconRes.icon) : null 46 | const translate = useTranslate() 47 | const basename = useBasename() 48 | const { pathname } = useLocation() 49 | const match = resources.some( 50 | (resource) => !!matchPath({ path: `${basename}/${resource.name}/*` }, pathname) 51 | ) 52 | const [groupOpen, setGroupOpen] = useState(match) 53 | const theme = useTheme()[0] 54 | const colors = 55 | theme == "dark" 56 | ? { 57 | text: "rgba(255, 255, 255, 0.87)", 58 | textSecondary: "rgba(255, 255, 255, 0.6)", 59 | } 60 | : { 61 | text: "rgba(0, 0, 0, 0.87)", 62 | textSecondary: "rgba(0, 0, 0, 0.6)", 63 | } 64 | 65 | return ( 66 |
67 | setGroupOpen(!groupOpen)} 69 | sx={{ 70 | color: match ? colors.text : colors.textSecondary, 71 | }} 72 | > 73 | {Icon && ( 74 | 75 | 76 | 77 | )} 78 | 79 | {groupOpen ? : } 80 | 81 | 82 | {resources.map(({ name }) => ( 83 | 84 | ))} 85 | 86 |
87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /example/sqlalchemy_core/admin.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | import marshmallow as ma 4 | from muffin import ResponseJSON 5 | 6 | from muffin_admin import Plugin, SAAdminHandler, SAFilter 7 | 8 | from . import app 9 | from .database import Message, User, db 10 | 11 | admin = Plugin(app, custom_css_url="/admin.css") 12 | 13 | 14 | @admin.dashboard 15 | async def dashboard(request): 16 | """Render dashboard cards.""" 17 | return [ 18 | [ 19 | {"title": "App config (Table view)", "value": [(k, str(v)) for k, v in app.cfg]}, 20 | { 21 | "title": "Request headers (JSON view)", 22 | "value": {k: v for k, v in request.headers.items() if k != "cookie"}, 23 | }, 24 | ], 25 | ] 26 | 27 | 28 | # Setup authorization 29 | # ------------------- 30 | 31 | 32 | @admin.check_auth 33 | async def auth(request): 34 | """Fake authorization method. Do not use in production.""" 35 | user_id = request.headers.get("authorization") 36 | qs = User.select().where(User.columns.id == user_id) 37 | user = await db.fetch_one(qs) 38 | return user 39 | 40 | 41 | @admin.get_identity 42 | async def ident(request): 43 | """Get current user information.""" 44 | user = await auth(request) 45 | if user: 46 | return {"id": user.id, "fullName": user.email} 47 | 48 | 49 | @admin.login 50 | async def login(request): 51 | """Login an user.""" 52 | data = await request.data() 53 | qs = User.select().where( 54 | (User.columns.email == data["username"]) & (User.columns.password == data["password"]), 55 | ) 56 | user = await db.fetch_one(qs) 57 | return ResponseJSON(user and user.id) 58 | 59 | 60 | # Setup handlers 61 | # -------------- 62 | 63 | 64 | @admin.route 65 | class UserResource(SAAdminHandler): 66 | """Create Admin Resource for the User model.""" 67 | 68 | class Meta(SAAdminHandler.Meta): 69 | """Tune the resource.""" 70 | 71 | database = db 72 | table = User 73 | filters = "created", "is_active", "role", SAFilter("email", operator="$contains") 74 | sorting = "id", "created", "email", "is_active", "role" 75 | schema_meta: ClassVar = { 76 | "load_only": ("password",), 77 | "dump_only": ("created",), 78 | } 79 | schema_fields: ClassVar = { 80 | "name": ma.fields.Function( 81 | lambda user: "{first_name} {last_name}".format(**user), 82 | ), 83 | } 84 | 85 | icon = "People" 86 | columns = "id", "picture", "email", "name", "is_active", "role" 87 | ra_fields: ClassVar = { 88 | "picture": ("AvatarField", {"alt": "picture", "nameProp": "name", "sortable": False}) 89 | } 90 | 91 | 92 | @admin.route 93 | class MessageResource(SAAdminHandler): 94 | """Create Admin Resource for the Message model.""" 95 | 96 | class Meta(SAAdminHandler.Meta): 97 | """Tune the resource.""" 98 | 99 | database = db 100 | table = Message 101 | filters = "status", "user_id" 102 | 103 | icon = "Message" 104 | ra_refs: ClassVar = {"user_id": {"reference": "user", "source": "email"}} 105 | -------------------------------------------------------------------------------- /.github/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at stefan@stoelzle.me. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | export type TID = string | number 2 | 3 | export type TProps = { 4 | [key: string]: any 5 | } 6 | 7 | export type TAdminType = 8 | | "action-form" 9 | | "admin" 10 | | "appbar" 11 | | "auth-provider" 12 | | "auth-storage-get" 13 | | "auth-storage-set" 14 | | "create" 15 | | "create-toolbar" 16 | | "create-inputs" 17 | | "dashboard" 18 | | "dashboard-actions" 19 | | "dashboard-content" 20 | | "data-provider" 21 | | "edit" 22 | | "edit-actions" 23 | | "edit-toolbar" 24 | | "edit-form-toolbar" 25 | | "edit-inputs" 26 | | "layout" 27 | | "list" 28 | | "list-actions" 29 | | "list-toolbar" 30 | | "list-grid" 31 | | "list-grid-buttons" 32 | | "list-filters" 33 | | "list-fields" 34 | | "locale" 35 | | "loginpage" 36 | | "menu" 37 | | "resource" 38 | | "show" 39 | | "show-actions" 40 | | "show-toolbar" 41 | | "show-fields" 42 | | "show-links" 43 | export type TAdminPath = [type: TAdminType, ...ids: string[]] 44 | 45 | export type AdminAction = { 46 | view?: ("show" | "edit" | "list" | "bulk")[] 47 | help?: string 48 | id: string 49 | label: string 50 | title: string 51 | icon: string 52 | paths: string[] 53 | confirm?: string | false 54 | schema?: AdminInput[] 55 | file?: boolean 56 | } 57 | 58 | export type AdminField = [string, { source: string }] 59 | export type AdminInput = [string, { required: boolean; source: string; [key: string]: any }] 60 | 61 | export type AdminShowLink = { 62 | label: string 63 | title?: string 64 | icon?: string 65 | field?: string 66 | filter?: string 67 | } 68 | export type AdminShowProps = { 69 | fields: AdminField[] 70 | links: [string, AdminShowLink][] 71 | edit?: boolean 72 | } 73 | 74 | export type AdminPayloadProps = { 75 | active: boolean 76 | onClose: () => void 77 | onHandle: (payload?: any) => void 78 | title?: string 79 | schema?: AdminInput[] 80 | help?: string 81 | } 82 | 83 | export type AdminResourceProps = { 84 | key: string 85 | name: string 86 | group?: string 87 | icon?: string 88 | label: string 89 | help?: string 90 | actions: AdminAction[] 91 | create: AdminInput[] | false 92 | edit: 93 | | { 94 | inputs: AdminInput[] 95 | remove?: boolean 96 | } 97 | | false 98 | list: { 99 | limit: number 100 | limitMax: number 101 | limitTotal: boolean 102 | create: boolean 103 | filters: AdminInput[] 104 | fields: AdminField[] 105 | // Enable show button 106 | show: boolean 107 | // Permissions 108 | edit?: boolean 109 | remove?: boolean 110 | // Default sorting 111 | sort?: { 112 | field: string 113 | order: "ASC" | "DESC" 114 | } 115 | } 116 | show: AdminShowProps 117 | } 118 | 119 | export type AdminDashboardBlock = { 120 | title: string 121 | value: any 122 | } 123 | 124 | export type AdminOpts = { 125 | adminProps: { 126 | title: string 127 | disableTelemetry?: boolean 128 | mutationMode?: "optimistic" | "pessimistic" | "undoable" 129 | } 130 | apiUrl: string 131 | appBarLinks: [{ url: string; title: string; icon?: string }] 132 | auth: { 133 | identityURL: string 134 | authorizeURL: string 135 | loginURL?: string 136 | logoutURL?: string 137 | required: boolean 138 | storage: "localstorage" | "cookie" 139 | storageName: string 140 | } 141 | dashboard: AdminDashboardBlock[] | AdminDashboardBlock 142 | resources: AdminResourceProps[] 143 | help?: string 144 | locales: Record> 145 | version: string 146 | } 147 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "muffin-admin" 3 | version = "11.3.0" 4 | description = "Admin interface for Muffin Framework" 5 | authors = [{ name = "Kirill Klenov", email = "horneds@gmail.com" }] 6 | requires-python = ">3.10" 7 | readme = "README.rst" 8 | license = "MIT" 9 | keywords = ["admin", "api", "muffin", "asgi", "asyncio", "trio"] 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Topic :: Internet :: WWW/HTTP", 21 | "Framework :: AsyncIO", 22 | "Framework :: Trio", 23 | ] 24 | dependencies = ["muffin", "muffin-rest", "typing-extensions ; python_version <= '3.10'"] 25 | 26 | [project.optional-dependencies] 27 | yaml = ["pyyaml"] 28 | peewee = ["muffin-peewee-aio", "marshmallow-peewee"] 29 | sqlalchemy = ["muffin-databases", "marshmallow-sqlalchemy", "sqlalchemy"] 30 | 31 | [project.urls] 32 | Homepage = "https://github.com/klen/muffin-admin" 33 | Repository = "https://github.com/klen/muffin-admin" 34 | 35 | [dependency-groups] 36 | dev = [ 37 | "aiofile", 38 | "aiosqlite", 39 | "marshmallow-peewee", 40 | "marshmallow-sqlalchemy", 41 | "muffin-databases", 42 | "muffin-peewee-aio", 43 | "pytest", 44 | "pytest-aio[curio, trio]", 45 | "pytest-mypy", 46 | "pyyaml", 47 | "ruff", 48 | "pre-commit", 49 | "black", 50 | ] 51 | example = ["uvicorn", "mixer", "peewee", "muffin-peewee-aio", "muffin-databases", "httptools"] 52 | 53 | [tool.uv] 54 | default-groups = ["dev", "example"] 55 | 56 | [tool.hatch.build.targets.sdist] 57 | ignore-vcs = true 58 | include = ["muffin_admin", "muffin_admin/main.js", "muffin_admin/main.js.LICENSE.txt"] 59 | 60 | [tool.hatch.build.targets.wheel] 61 | ignore-vcs = true 62 | include = ["muffin_admin", "muffin_admin/main.js", "muffin_admin/main.js.LICENSE.txt"] 63 | 64 | [build-system] 65 | requires = ["hatchling"] 66 | build-backend = "hatchling.build" 67 | 68 | [tool.pytest.ini_options] 69 | addopts = "-xsv" 70 | 71 | [tool.mypy] 72 | packages = ["muffin_admin"] 73 | ignore_missing_imports = true 74 | 75 | [tool.tox] 76 | legacy_tox_ini = """ 77 | [tox] 78 | envlist = py310,py311,py312,py313,pypy310 79 | 80 | [testenv] 81 | deps = -e .[dev] 82 | allowlist_externals = 83 | uv 84 | commands = 85 | uv sync 86 | uv run pytest --mypy tests 87 | 88 | [testenv:pypy310] 89 | deps = -e .[dev] 90 | commands = 91 | pytest tests 92 | """ 93 | 94 | [tool.ruff] 95 | fix = false 96 | line-length = 100 97 | target-version = "py310" 98 | exclude = [".venv", "docs", "examples"] 99 | 100 | [tool.ruff.lint] 101 | select = ["ALL"] 102 | ignore = [ 103 | "A003", 104 | "ARG002", 105 | "ARG003", 106 | "ANN", 107 | "COM", 108 | "D", 109 | "DJ", 110 | "EM", 111 | "N804", 112 | "PLR0912", 113 | "PLR2004", 114 | "RET", 115 | "RSE", 116 | "S101", 117 | "SLF", 118 | "TRY003", 119 | "UP", 120 | ] 121 | 122 | [tool.black] 123 | line-length = 100 124 | target-version = ["py310", "py311", "py312", "py313"] 125 | preview = true 126 | 127 | [tool.bumpversion] 128 | current_version = "11.3.0" 129 | commit = false 130 | tag = false 131 | 132 | [[tool.bumpversion.files]] 133 | filename = "pyproject.toml" 134 | search = 'version = "{current_version}"' 135 | replace = 'version = "{new_version}"' 136 | 137 | [[tool.bumpversion.files]] 138 | filename = "frontend/package.json" 139 | search = '"version": "{current_version}"' 140 | replace = '"version": "{new_version}"' 141 | 142 | [[tool.bumpversion.files]] 143 | filename = "frontend/src/index.tsx" 144 | search = 'export const VERSION = "{current_version}"' 145 | replace = 'export const VERSION = "{new_version}"' 146 | -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | async def test_plugin(app): 2 | import muffin_admin 3 | 4 | assert app 5 | 6 | admin = muffin_admin.Plugin(app) 7 | assert admin 8 | 9 | data = admin.to_ra() 10 | assert data["apiUrl"] 11 | assert data["auth"] == { 12 | "storage": "localstorage", 13 | "storageName": "muffin_admin_auth", 14 | "logoutURL": None, 15 | "loginURL": None, 16 | } 17 | 18 | 19 | async def test_basic_files(app, client): 20 | import muffin_admin 21 | 22 | muffin_admin.Plugin(app) 23 | 24 | res = await client.get("/admin") 25 | assert res.status_code == 200 26 | text = await res.text() 27 | assert "Muffin-Admin Admin UI" in text 28 | 29 | res = await client.get("/admin/main.js") 30 | assert res.status_code == 200 31 | text = await res.text() 32 | assert "muffin-admin js files" in text 33 | 34 | 35 | async def test_root_prefix(app, client): 36 | import muffin_admin 37 | 38 | admin = muffin_admin.Plugin(app, prefix="/") 39 | assert admin 40 | 41 | res = await client.get("/") 42 | assert res.status_code == 200 43 | text = await res.text() 44 | assert "Muffin-Admin Admin UI" in text 45 | 46 | res = await client.get("/main.js") 47 | assert res.status_code == 200 48 | text = await res.text() 49 | assert "muffin-admin js files" in text 50 | 51 | 52 | async def test_auth(app, client): 53 | from muffin_rest import APIError 54 | 55 | from muffin_admin import Plugin 56 | 57 | admin = Plugin(app) 58 | 59 | res = await client.get("/admin/login") 60 | assert res.status_code == 404 61 | 62 | res = await client.get("/admin/ident") 63 | assert res.status_code == 404 64 | 65 | # Setup fake authorization process 66 | # -------------------------------- 67 | 68 | @admin.check_auth 69 | async def authorize(request): 70 | auth = request.headers.get("authorization") 71 | if not auth: 72 | raise APIError.FORBIDDEN() 73 | 74 | return auth 75 | 76 | @admin.get_identity 77 | async def ident(request): 78 | user = request.headers.get("authorization") 79 | return {"id": user, "fullName": f"User-{user}"} 80 | 81 | @admin.login 82 | async def login(request): 83 | data = await request.data() 84 | return data.get("username", False) 85 | 86 | auth = admin.to_ra()["auth"] 87 | assert auth 88 | assert auth == { 89 | "authorizeURL": "/admin/login", 90 | "identityURL": "/admin/ident", 91 | "loginURL": None, 92 | "logoutURL": None, 93 | "required": True, 94 | "storage": "localstorage", 95 | "storageName": "muffin_admin_auth", 96 | } 97 | 98 | res = await client.get("/admin/login", data={"username": "user", "password": "pass"}) 99 | assert res.status_code == 200 100 | assert await res.text() == "user" 101 | 102 | res = await client.get("/admin/ident", headers={"authorization": "user"}) 103 | assert res.status_code == 200 104 | assert await res.json() == {"id": "user", "fullName": "User-user"} 105 | 106 | res = await client.get("/admin") 107 | assert res.status_code == 403 108 | 109 | 110 | async def test_dashboard(app, client): 111 | import muffin_admin 112 | 113 | admin = muffin_admin.Plugin(app) 114 | 115 | @admin.dashboard 116 | async def dashboard(request): 117 | """Render admin dashboard cards.""" 118 | return [ 119 | {"name": "application config", "value": {k: str(v) for k, v in app.cfg}}, 120 | {"name": "request headers", "value": dict(request.headers)}, 121 | ] 122 | 123 | res = await client.get("/admin/ra.json") 124 | assert res.status_code == 200 125 | data = await res.json() 126 | assert data["dashboard"] 127 | assert data["dashboard"][0]["name"] == "application config" 128 | assert data["dashboard"][1]["name"] == "request headers" 129 | -------------------------------------------------------------------------------- /muffin_admin/sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | """SQLAlchemy core support.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from muffin_rest.filters import Filter 8 | from muffin_rest.sqlalchemy import SARESTHandler, SARESTOptions 9 | from muffin_rest.sqlalchemy.filters import SAFilter 10 | from sqlalchemy import JSON, Enum, Text 11 | 12 | from muffin_admin.handler import AdminHandler, AdminOptions 13 | 14 | if TYPE_CHECKING: 15 | import marshmallow as ma 16 | from muffin import Request 17 | 18 | from muffin_admin.types import TRAInfo 19 | 20 | 21 | class SAAdminOptions(AdminOptions, SARESTOptions): 22 | """Keep SAAdmin options.""" 23 | 24 | def setup(self, cls): 25 | """Auto insert filter by id.""" 26 | super(SAAdminOptions, self).setup(cls) 27 | self.id = self.table_pk.name 28 | 29 | for f in self.filters: 30 | if f == self.id or (isinstance(f, Filter) and f.name == self.id): 31 | break 32 | 33 | else: 34 | self.filters = [SAFilter(self.id, field=self.table_pk), *self.filters] 35 | 36 | 37 | class SAAdminHandler(AdminHandler, SARESTHandler): 38 | """Work with SQLAlchemy Core.""" 39 | 40 | meta_class: type[SAAdminOptions] = SAAdminOptions 41 | meta: SAAdminOptions 42 | 43 | def get_selected(self, request: Request): 44 | keys = request.query.getall("ids") 45 | qs = self.collection 46 | if keys: 47 | qs = qs.where(self.meta.table_pk.in_(keys)) 48 | 49 | return qs 50 | 51 | @classmethod 52 | def to_ra_field(cls, field: ma.fields.Field, source: str) -> TRAInfo: 53 | """Setup RA fields.""" 54 | column = getattr(cls.meta.table.c, field.attribute or source, None) 55 | refs = dict(cls.meta.ra_refs) 56 | if column is not None: 57 | if column.foreign_keys and column.name in refs: 58 | ref_data = refs[column.name] 59 | fk = next(iter(column.foreign_keys)) 60 | return "FKField", { 61 | "source": source, 62 | "refKey": ref_data.get("key") or fk.column.name, 63 | "refSource": ref_data.get("source") or fk.column.name, 64 | "reference": ref_data.get("reference") or fk.column.table.name, 65 | } 66 | 67 | if isinstance(column.type, JSON): 68 | return "JsonField", {} 69 | 70 | return super(SAAdminHandler, cls).to_ra_field(field, source) 71 | 72 | @classmethod 73 | def to_ra_input(cls, field: ma.fields.Field, source: str, *, resource: bool = True) -> TRAInfo: 74 | """Setup RA inputs.""" 75 | column = getattr(cls.meta.table.c, field.attribute or source, None) 76 | ra_type, props = super(SAAdminHandler, cls).to_ra_input(field, source) 77 | refs = dict(cls.meta.ra_refs) 78 | if column is not None: 79 | if column.foreign_keys and (source in refs): 80 | ref_data = refs[source] 81 | fk = next(iter(column.foreign_keys)) 82 | return "FKInput", dict( 83 | props, 84 | emptyValue=None if column.nullable else "", 85 | refSource=ref_data.get("source") or fk.column.name, 86 | refKey=ref_data.get("key") or fk.column.name, 87 | reference=ref_data.get("reference") or fk.column.table.name, 88 | ) 89 | 90 | if isinstance(column.type, Enum): 91 | return "SelectInput", dict( 92 | props, 93 | choices=[{"id": c.value, "name": c.name} for c in column.type.enum_class], # type: ignore[union-attr] 94 | ) 95 | 96 | if isinstance(column.type, Text): 97 | return "TextInput", dict(props, multiline=True) 98 | 99 | if isinstance(column.type, JSON): 100 | return "JsonInput", props 101 | 102 | return ra_type, props 103 | -------------------------------------------------------------------------------- /frontend/src/MuffinResourceList.tsx: -------------------------------------------------------------------------------- 1 | import sortBy from "lodash/sortBy" 2 | import uniq from "lodash/uniq" 3 | import { PropsWithChildren } from "react" 4 | import { 5 | BulkDeleteButton, 6 | BulkExportButton, 7 | CreateButton, 8 | DatagridConfigurable, 9 | EditButton, 10 | ExportButton, 11 | FilterButton, 12 | InfiniteList, 13 | List, 14 | Pagination, 15 | SelectColumnsButton, 16 | TopToolbar, 17 | } from "react-admin" 18 | import { buildRA, buildRAComponent } from "./buildRA" 19 | import { BulkActionButton, ListActionButton } from "./buttons" 20 | import { HelpLink } from "./common/HelpLink" 21 | import { useMuffinResourceOpts } from "./hooks" 22 | import { AdminInput } from "./types" 23 | import { buildAdmin, findBuilder, setupAdmin } from "./utils" 24 | 25 | export function MuffinList({ children }: PropsWithChildren) { 26 | const { name, list, key } = useMuffinResourceOpts() 27 | const { limit, limitMax, limitTotal, sort, filters } = list 28 | 29 | const DataGrid = findBuilder(["list-grid", name]) 30 | const Toolbar = findBuilder(["list-toolbar", name]) 31 | const raFilters = buildAdmin(["list-filters", name], filters) 32 | 33 | return limitTotal ? ( 34 | } 39 | queryOptions={{ meta: { key } }} 40 | pagination={ 41 | 42 | } 43 | > 44 | {children} 45 | 46 | 47 | ) : ( 48 | } 53 | queryOptions={{ meta: { key } }} 54 | > 55 | {children} 56 | 57 | 58 | ) 59 | } 60 | 61 | setupAdmin(["list"], MuffinList) 62 | setupAdmin(["list-fields"], buildRA) 63 | 64 | function muffinListFilters(filters: AdminInput[]) { 65 | return filters.map((props) => { 66 | const [rtype, opts] = props 67 | return buildRAComponent(rtype, { 68 | ...opts, 69 | variant: "outlined", 70 | }) 71 | }) 72 | } 73 | setupAdmin(["list-filters"], muffinListFilters) 74 | 75 | function MuffinListDatagrid() { 76 | const { name, list } = useMuffinResourceOpts() 77 | const { fields, edit, show } = list 78 | const BulkActions = findBuilder(["list-actions", name]) 79 | return ( 80 | } 83 | > 84 | {buildAdmin(["list-fields", name], fields)} 85 | {buildAdmin(["list-grid-buttons", name])} 86 | 87 | ) 88 | } 89 | 90 | setupAdmin(["list-grid"], MuffinListDatagrid) 91 | 92 | function MuffinListGridButtons() { 93 | const { list } = useMuffinResourceOpts() 94 | const { edit } = list 95 | if (!edit) return null 96 | return 97 | } 98 | 99 | setupAdmin(["list-grid-buttons"], MuffinListGridButtons) 100 | 101 | function MuffinListToolbar() { 102 | const { 103 | actions: baseActions = [], 104 | help, 105 | list: { create }, 106 | } = useMuffinResourceOpts() 107 | const actions = baseActions.filter((a) => a.view?.includes("list")) 108 | const hasExport = actions.some((a) => a.id === "export") 109 | return ( 110 | 111 | {help && } 112 | 113 | 114 | {create && } 115 | {actions.length 116 | ? actions.map((props) => ) 117 | : null} 118 | {!hasExport && } 119 | 120 | ) 121 | } 122 | setupAdmin(["list-toolbar"], MuffinListToolbar) 123 | 124 | function MuffinListActions() { 125 | const { 126 | actions: baseActions = [], 127 | list: { remove }, 128 | } = useMuffinResourceOpts() 129 | const actions = baseActions.filter((a) => a.view?.includes("bulk")) 130 | return ( 131 | <> 132 | {actions.map((props) => ( 133 | 134 | ))} 135 | 136 | {remove && } 137 | 138 | ) 139 | } 140 | 141 | setupAdmin(["list-actions"], MuffinListActions) 142 | -------------------------------------------------------------------------------- /frontend/src/MuffinAdmin.tsx: -------------------------------------------------------------------------------- 1 | import IconButton from "@mui/material/IconButton" 2 | import SvgIcon from "@mui/material/SvgIcon" 3 | import Tooltip from "@mui/material/Tooltip" 4 | 5 | import Typography from "@mui/material/Typography" 6 | import { 7 | AdminContext, 8 | AdminProps, 9 | AdminUI, 10 | AppBar, 11 | AppBarProps, 12 | Layout, 13 | LayoutProps, 14 | localStorageStore, 15 | Login, 16 | LoginProps, 17 | } from "react-admin" 18 | 19 | import { useEffect } from "react" 20 | import { ConfirmationProvider } from "./common" 21 | import { useMuffinAdminOpts } from "./hooks" 22 | import { buildProvider, muffinTranslations } from "./i18n" 23 | import { muffinDarkTheme, muffinLightTheme } from "./themes" 24 | import { buildAdmin, deepMerge, findBuilder, findIcon, setupAdmin } from "./utils" 25 | 26 | export function MuffinAdmin({ 27 | lightTheme = muffinLightTheme, 28 | darkTheme = muffinDarkTheme, 29 | ...props 30 | }: AdminProps) { 31 | const opts = useMuffinAdminOpts() 32 | const { resources = [], auth, adminProps, apiUrl, locales: backendLocales } = opts 33 | 34 | document.title = adminProps?.title || "Muffin Admin" 35 | const muffinI18nProvider = buildProvider( 36 | backendLocales 37 | ? Object.fromEntries( 38 | Object.entries(muffinTranslations).map(([locale, messages]) => [ 39 | locale, 40 | deepMerge({}, messages, backendLocales[locale], buildAdmin(["locale", locale]) || {}), 41 | ]) 42 | ) 43 | : muffinTranslations 44 | ) 45 | 46 | const { 47 | basename, 48 | authProvider = findBuilder(["auth-provider"])(auth), 49 | dataProvider = findBuilder(["data-provider"])(apiUrl), 50 | catchAll, 51 | dashboard = findBuilder(["dashboard"]), 52 | disableTelemetry, 53 | error, 54 | i18nProvider = muffinI18nProvider, 55 | layout = findBuilder(["layout"]), 56 | loading, 57 | loginPage = findBuilder(["loginpage"]), 58 | authCallbackPage, 59 | notification, 60 | queryClient, 61 | requireAuth = auth.required, 62 | store = localStorageStore(), 63 | ready, 64 | theme, 65 | defaultTheme, 66 | title = "React Admin", 67 | } = props 68 | 69 | return ( 70 | 82 | 83 | 97 | {resources?.map(({ name }) => buildAdmin(["resource", name], { name }))} 98 | 99 | 100 | 101 | ) 102 | } 103 | 104 | setupAdmin(["admin"], MuffinAdmin) 105 | 106 | export function MuffinAdminLayout(props: LayoutProps) { 107 | return 108 | } 109 | 110 | setupAdmin(["layout"], MuffinAdminLayout) 111 | 112 | export function MuffinAppBar(props: AppBarProps) { 113 | const { appBarLinks } = useMuffinAdminOpts() 114 | return ( 115 | 116 | 127 | {appBarLinks.map((info) => ( 128 | 129 | 130 | 131 | 132 | 133 | ))} 134 | 135 | ) 136 | } 137 | 138 | setupAdmin(["appbar"], MuffinAppBar) 139 | 140 | export function MuffinLogin(props: LoginProps) { 141 | const { 142 | auth: { loginURL }, 143 | } = useMuffinAdminOpts() 144 | useEffect(() => { 145 | if (loginURL) window.location.replace(loginURL) 146 | }, [loginURL]) 147 | return 148 | } 149 | 150 | // Initialize login page 151 | setupAdmin(["loginpage"], MuffinLogin) 152 | -------------------------------------------------------------------------------- /frontend/src/dataprovider.ts: -------------------------------------------------------------------------------- 1 | import { TID } from "./types" 2 | import { APIParams, makeRequest, prepareFilters, setupAdmin } from "./utils" 3 | 4 | type TQueryMeta = { 5 | key?: string 6 | } 7 | 8 | export function MuffinDataprovider(apiUrl: string) { 9 | async function request(url: string, options?: APIParams) { 10 | if (!url.startsWith("/")) url = `${apiUrl}/${url}` 11 | const { json, headers } = await makeRequest(url, options) 12 | return { data: json, headers } 13 | } 14 | 15 | const methods = { 16 | request, 17 | getList: async (resource: string, { meta, ...params }: any) => { 18 | const { filter, pagination, sort } = params 19 | const query: APIParams["query"] = {} 20 | if (filter) query.where = prepareFilters(filter) 21 | if (pagination) { 22 | const { page, perPage: limit } = pagination 23 | query.limit = limit 24 | query.offset = limit * (page - 1) 25 | } 26 | if (sort) { 27 | const { field, order } = sort 28 | query.sort = order == "ASC" ? field : `-${field}` 29 | } 30 | const { headers, data } = await request(resource, { query }) 31 | 32 | // React-admin requires item to have an `id` field 33 | if (meta?.key) { 34 | for (const item of data) { 35 | item.id = item.id ?? item[meta.key] 36 | } 37 | } 38 | 39 | if (pagination) 40 | return { 41 | data, 42 | total: parseInt(headers.get("x-total"), 10), 43 | pageInfo: { 44 | hasPreviousPage: pagination.page > 1, 45 | hasNextPage: data.length === pagination.perPage, 46 | }, 47 | } 48 | 49 | return { data } 50 | }, 51 | 52 | getOne: async (resource: string, { id, meta }: { id: TID; meta?: TQueryMeta }) => { 53 | const response = await request(`${resource}/${id}`) 54 | const { data } = response 55 | if (meta?.key) { 56 | data.id = data.id ?? data[meta.key] 57 | } 58 | return response 59 | }, 60 | 61 | create: async (resource: string, { data }) => { 62 | return await request(resource, { 63 | method: "POST", 64 | ...prepareDataParams(data), 65 | }) 66 | }, 67 | 68 | update: async (resource: string, { id, data }: { id: TID; data: any }) => { 69 | return await makeRequest(`${apiUrl}/${resource}/${id}`, { 70 | method: "PUT", 71 | ...prepareDataParams(data), 72 | }).then(({ json }) => ({ data: json })) 73 | }, 74 | 75 | updateMany: async (resource: string, { ids, data }: { ids: TID[]; data: any }) => { 76 | await Promise.all(ids.map((id) => methods.update(resource, { id, data }))) 77 | return { data: ids } 78 | }, 79 | 80 | delete: async (resource: string, { id }: { id: TID }) => { 81 | await request(`${resource}/${id}`, { method: "DELETE" }) 82 | return { data: { id } } 83 | }, 84 | 85 | deleteMany: async (resource: string, { ids }: { ids: TID[][] }) => { 86 | await request(resource, { 87 | data: ids, 88 | method: "DELETE", 89 | }) 90 | return { data: ids } 91 | }, 92 | 93 | getMany: (resource: string, props: { ids: TID[]; meta: any }) => { 94 | const { ids, meta } = props 95 | const key = meta?.key || "id" 96 | return methods.getList(resource, { filter: { [key]: { $in: ids } }, meta }) 97 | }, 98 | 99 | getManyReference: async (resource: string, { target, id, filter, ...opts }) => { 100 | filter = filter || {} 101 | filter[target] = id 102 | return await methods.getList(resource, { filter, ...opts }) 103 | }, 104 | 105 | runAction: async (_: string, action: string, props: TActionProps) => { 106 | const { payload, ids, record } = props 107 | action = action.replace(/^\/+/, "") 108 | if (record) { 109 | action = action.replace(/\{([^}]+)\}/, (_, field) => record[field]) 110 | } 111 | const { json } = await makeRequest(`${apiUrl}/${action}`, { 112 | query: { ids }, 113 | method: "POST", 114 | data: payload, 115 | }) 116 | return { data: json } 117 | }, 118 | } 119 | return methods 120 | } 121 | 122 | export type TActionProps = { 123 | payload?: any 124 | ids?: string[] 125 | record?: T 126 | } 127 | setupAdmin(["data-provider"], MuffinDataprovider) 128 | 129 | function isFileValue(value: any): boolean { 130 | return value && typeof value === "object" && value.rawFile instanceof File 131 | } 132 | 133 | function prepareDataParams(data: Record) { 134 | const hasFiles = Object.values(data).some(isFileValue) 135 | 136 | if (!hasFiles) return { data } 137 | 138 | const formData = new FormData() 139 | for (const key in data) { 140 | const value = data[key] 141 | if (isFileValue(value)) { 142 | formData.append(key, value.rawFile) 143 | } else if (typeof value === "object" && value !== null) { 144 | formData.append(key, JSON.stringify(value)) 145 | } else if (value !== undefined && value !== null) { 146 | formData.append(key, value) 147 | } 148 | } 149 | return { body: formData } 150 | } 151 | -------------------------------------------------------------------------------- /frontend/src/buttons/ActionButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | FormGroupsProvider, 4 | useAugmentedForm, 5 | useListContext, 6 | useRecordContext, 7 | useResourceContext, 8 | useTranslate, 9 | } from "react-admin" 10 | import { FormProvider } from "react-hook-form" 11 | 12 | import { Link, Stack } from "@mui/material" 13 | import { useState } from "react" 14 | import { buildRA } from "../buildRA" 15 | import { AdminModal, PayloadButtons, useConfirmation } from "../common" 16 | import { HelpLink } from "../common/HelpLink" 17 | import { useMuffinAdminOpts } from "../hooks" 18 | import { useAction } from "../hooks/useAction" 19 | import { AdminAction, AdminPayloadProps } from "../types" 20 | import { buildIcon, findBuilder, prepareFilters, requestHeaders } from "../utils" 21 | 22 | export type ActionPayloadProps = { 23 | active: boolean 24 | onClose: () => void 25 | onHandle: (payload: any) => void 26 | } 27 | 28 | export function ActionButton({ paths, confirm, file, ...props }: AdminAction) { 29 | const translate = useTranslate() 30 | const record = useRecordContext() 31 | const confirmation = useConfirmation() 32 | 33 | const path = paths.find((p) => p.includes("{id}")) || paths[paths.length - 1] 34 | const { mutate, isPending } = useAction(path) 35 | 36 | if (file) return 37 | 38 | const confirmMessage = 39 | typeof confirm === "string" ? translate(confirm) : "Do you confirm this action?" 40 | 41 | const onHandle = async (payload?: any) => { 42 | const process = confirm ? await confirmation.confirm({ message: confirmMessage }) : true 43 | if (process) mutate({ record, payload }) 44 | } 45 | 46 | return 47 | } 48 | 49 | export function ListActionButton({ paths, confirm, file, ...props }: AdminAction) { 50 | const translate = useTranslate() 51 | const confirmation = useConfirmation() 52 | const path = paths.find((p) => !p.includes("{id}")) || paths[paths.length - 1] 53 | const { mutate, isPending } = useAction(path) 54 | 55 | const { filterValues } = useListContext() 56 | if (file) return 57 | 58 | const confirmMessage = 59 | typeof confirm === "string" ? translate(confirm) : "Do you confirm this action?" 60 | 61 | const onHandle = async (payload?) => { 62 | const process = confirm ? await confirmation.confirm({ message: confirmMessage }) : true 63 | if (process) await mutate({ payload }) 64 | } 65 | 66 | return 67 | } 68 | 69 | export function BulkActionButton({ paths, confirm, ...props }: AdminAction) { 70 | const translate = useTranslate() 71 | const { selectedIds } = useListContext() 72 | const path = paths.find((p) => !p.includes("{id}")) || paths[0] 73 | const { mutate, isPending } = useAction(path) 74 | const confirmation = useConfirmation() 75 | 76 | const confirmMessage = 77 | typeof confirm === "string" ? translate(confirm) : "Do you confirm this action?" 78 | 79 | const onHandle = async (payload?: any) => { 80 | const process = confirm ? await confirmation.confirm({ message: confirmMessage }) : true 81 | if (process) await mutate({ ids: selectedIds, payload }) 82 | } 83 | 84 | return 85 | } 86 | 87 | function ActionButtonBase({ 88 | label, 89 | title, 90 | onHandle, 91 | isPending, 92 | icon, 93 | schema, 94 | id, 95 | help, 96 | }: Omit & { 97 | isPending?: boolean 98 | onHandle: (payload?: any) => void 99 | }) { 100 | const resource = useResourceContext() 101 | const [payloadActive, setPayloadActive] = useState(false) 102 | 103 | const PayloadForm = findBuilder(["action-form", id, resource]) || (schema && CommonPayload) 104 | if (!PayloadForm) 105 | return ( 106 | 109 | ) 110 | 111 | return ( 112 | <> 113 | 121 | setPayloadActive(false)} 125 | title={title || label} 126 | schema={schema} 127 | help={help} 128 | /> 129 | 130 | ) 131 | } 132 | 133 | export function CommonPayload({ 134 | active, 135 | onClose, 136 | onHandle, 137 | schema, 138 | title, 139 | help, 140 | }: AdminPayloadProps) { 141 | schema[0][1] = { ...schema[0][1], autoFocus: true } 142 | const inputs = buildRA(schema) 143 | const translate = useTranslate() 144 | const { form, formHandleSubmit } = useAugmentedForm({ 145 | onSubmit: (data) => { 146 | onClose() 147 | onHandle(data) 148 | form.reset() 149 | }, 150 | record: {}, 151 | }) 152 | 153 | return ( 154 | 155 | 156 | 164 | {translate(title, { _: title })} 165 | 166 |
167 | ) as any) 168 | : translate(title, { _: title }) 169 | } 170 | actions={ 171 | 176 | } 177 | > 178 | {inputs} 179 | 180 | 181 | 182 | ) 183 | } 184 | 185 | function FileButton({ 186 | path, 187 | record, 188 | label, 189 | icon, 190 | filterValues, 191 | }: { 192 | path: string 193 | label: string 194 | icon: string 195 | record?: any 196 | filterValues?: any 197 | }) { 198 | const { apiUrl } = useMuffinAdminOpts() 199 | let url = `${apiUrl}${path}?f` 200 | if (record) url = url.replace("{id}", record.id as string) 201 | const authorization = requestHeaders["Authorization"] 202 | if (authorization) url += `&t=${authorization}` 203 | if (Object.keys(filterValues).length) url += `&where=${prepareFilters(filterValues)}` 204 | 205 | return ( 206 | 209 | ) 210 | } 211 | -------------------------------------------------------------------------------- /muffin_admin/peewee/__init__.py: -------------------------------------------------------------------------------- 1 | """Peewee ORM Support.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, cast 6 | 7 | import marshmallow as ma 8 | import peewee as pw 9 | from muffin_peewee import JSONLikeField 10 | from muffin_rest.peewee.filters import PWFilter 11 | from muffin_rest.peewee.handler import PWRESTBase 12 | from muffin_rest.peewee.options import PWRESTOptions 13 | 14 | from muffin_admin.handler import AdminHandler, AdminOptions 15 | from muffin_admin.peewee.schemas import PeeweeModelSchema 16 | 17 | if TYPE_CHECKING: 18 | from muffin import Request 19 | from muffin_rest.filters import Filter 20 | 21 | from muffin_admin.types import TRAInfo 22 | 23 | 24 | class PWAdminOptions(AdminOptions, PWRESTOptions): 25 | """Keep PWAdmin options.""" 26 | 27 | def setup(self, cls): 28 | """Auto insert filter by id.""" 29 | super(PWAdminOptions, self).setup(cls) 30 | 31 | self.id = self.model_pk.name 32 | for flt in self.filters: 33 | name = flt 34 | 35 | if isinstance(flt, PWFilter): 36 | name = flt.name 37 | 38 | elif isinstance(flt, tuple): 39 | name = flt[0] 40 | 41 | if name == self.id: 42 | break 43 | 44 | else: 45 | self.filters = [PWFilter(self.id, field=self.model_pk), *self.filters] # type: ignore[] 46 | 47 | def default_sort(self: PWAdminOptions): 48 | """Default sorting.""" 49 | fields = self.model._meta.fields # type: ignore[] 50 | sorting: list = [name for name in self.columns if name in fields] 51 | if sorting: 52 | sorting[0] = (sorting[0], {"default": "desc"}) 53 | self.sorting = sorting # type: ignore[assignment] 54 | 55 | 56 | class PWAdminHandler(AdminHandler, PWRESTBase): 57 | """Work with Peewee Models.""" 58 | 59 | meta_class: type[PWAdminOptions] = PWAdminOptions 60 | meta: PWAdminOptions 61 | 62 | class Meta(AdminHandler.Meta): 63 | schema_base = PeeweeModelSchema 64 | 65 | def get_selected(self, request: Request): 66 | """Get selected objects.""" 67 | keys = request.query.getall("ids") 68 | qs = self.collection 69 | if keys: 70 | qs = qs.where(self.meta.model_pk.in_(keys)) # type: ignore[] 71 | 72 | return qs 73 | 74 | @classmethod 75 | def to_ra_field(cls, field: ma.fields.Field, source: str) -> TRAInfo: 76 | """Setup RA fields.""" 77 | model_field = getattr(cls.meta.model, field.attribute or source, None) 78 | ra_type, props = super(PWAdminHandler, cls).to_ra_field(field, source) 79 | refs = cls.meta.ra_refs 80 | 81 | if model_field and isinstance(model_field, pw.Field): 82 | if model_field.choices: 83 | ra_type, props = "SelectField", { 84 | "choices": [{"id": c[0], "name": c[1]} for c in model_field.choices], 85 | **props, 86 | } 87 | 88 | elif isinstance(model_field, JSONLikeField) or model_field.field_type.lower() == "json": 89 | ra_type = "JsonField" 90 | 91 | elif isinstance(model_field, pw.ForeignKeyField) and source in refs: 92 | ref_data = refs[source] 93 | rel_model = model_field.rel_model 94 | return "FKField", dict( 95 | props, 96 | refSource=ref_data.get("source") or model_field.rel_field.name, 97 | refKey=ref_data.get("key") or rel_model._meta.primary_key.name, 98 | reference=ref_data.get("reference") or rel_model._meta.table_name, 99 | ) 100 | 101 | return ra_type, props 102 | 103 | @classmethod 104 | def to_ra_input( # noqa: PLR0911 105 | cls, field: ma.fields.Field, source: str, *, resource: bool = True 106 | ) -> TRAInfo: 107 | """Setup RA inputs.""" 108 | model_field = resource and getattr( 109 | cls.meta.model, field.attribute or field.metadata.get("name") or source, None 110 | ) 111 | ra_type, props = super(PWAdminHandler, cls).to_ra_input(field, source) 112 | refs = cls.meta.ra_refs 113 | 114 | if model_field and isinstance(model_field, pw.Field): 115 | if model_field.choices: 116 | return "SelectInput", dict( 117 | props, 118 | choices=[{"id": c[0], "name": c[1]} for c in model_field.choices], 119 | ) 120 | 121 | if isinstance(model_field, pw.TextField): 122 | return "TextInput", dict(props, multiline=True) 123 | 124 | if isinstance(model_field, pw.DateTimeField) and isinstance(field, ma.fields.DateTime): 125 | dtformat = field.format or cls.meta.Schema.opts.datetimeformat 126 | if dtformat == "timestamp_ms": 127 | return "TimestampInput", dict(props, ms=True) 128 | elif dtformat == "timestamp": 129 | return "TimestampInput", dict(props, ms=False) 130 | 131 | return ra_type, props 132 | 133 | if isinstance(model_field, JSONLikeField) or model_field.field_type.lower() == "json": 134 | return "JsonInput", props 135 | 136 | if isinstance(model_field, pw.ForeignKeyField) and source in refs: 137 | ref_data = refs[source] 138 | rel_model = model_field.rel_model 139 | return "FKInput", dict( 140 | props, 141 | refSource=ref_data.get("source") or model_field.rel_field.name, 142 | refKey=ref_data.get("key") or rel_model._meta.primary_key.name, 143 | reference=ref_data.get("reference") or rel_model._meta.table_name, 144 | ) 145 | 146 | return ra_type, props 147 | 148 | @classmethod 149 | def to_ra_filter(cls, flt: Filter) -> TRAInfo: 150 | meta = cls.meta 151 | field = flt.field 152 | 153 | if isinstance(field, pw.Field) and field.choices: 154 | source = flt.name 155 | ra_type, props = "SelectArrayInput", { 156 | "source": source, 157 | "choices": [{"id": c[0], "name": c[1]} for c in field.choices], 158 | } 159 | custom = meta.ra_filters.get(source) 160 | if custom: 161 | ra_type, props = custom[0], {**props, **custom[1]} 162 | 163 | if source in meta.ra_filters_always_on: 164 | props["alwaysOn"] = True # type: ignore[assignment] 165 | props["resettable"] = True # type: ignore[assignment] 166 | 167 | return ra_type, props 168 | 169 | return super(PWAdminHandler, cls).to_ra_filter(flt) 170 | 171 | 172 | class PWSearchFilter(PWFilter): 173 | """Search in query by value.""" 174 | 175 | async def filter(self, collection: pw.ModelSelect, *ops: tuple, **_) -> pw.ModelSelect: 176 | """Apply the filters to Peewee QuerySet..""" 177 | _, value = ops[0] 178 | column = self.field 179 | return cast("pw.ModelSelect", collection.where(column.contains(value))) 180 | -------------------------------------------------------------------------------- /muffin_admin/plugin.py: -------------------------------------------------------------------------------- 1 | """Setup the plugin.""" 2 | 3 | from __future__ import annotations 4 | 5 | from importlib import metadata 6 | from inspect import isclass 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING, Any, Callable, ClassVar, cast 9 | 10 | from muffin import Application, Request, ResponseError, ResponseFile, ResponseRedirect 11 | from muffin.plugins import BasePlugin 12 | from muffin_rest.api import API 13 | 14 | from muffin_admin.utils import deepmerge 15 | 16 | from .handler import AdminHandler 17 | 18 | if TYPE_CHECKING: 19 | from muffin_rest.types import TAuth 20 | 21 | PACKAGE_DIR: Path = Path(__file__).parent 22 | TEMPLATE: str = (PACKAGE_DIR / "admin.html").read_text() 23 | VERSION = metadata.version("muffin-admin") 24 | 25 | 26 | async def page404(_: Request) -> ResponseError: 27 | """Default 404 for authorization methods.""" 28 | return ResponseError.NOT_FOUND() 29 | 30 | 31 | class Plugin(BasePlugin): 32 | """Admin interface for Muffin Framework.""" 33 | 34 | name = "admin" 35 | defaults: ClassVar = { 36 | "prefix": "/admin", 37 | "title": "Muffin-Admin", 38 | "main_js_url": "{prefix}/main.js", 39 | "custom_js_url": "", 40 | "custom_css_url": "", 41 | "login_url": None, 42 | "logout_url": None, 43 | "menu_sort": True, 44 | "auth_storage": "localstorage", # localstorage|cookies 45 | "auth_storage_name": "muffin_admin_auth", 46 | "app_bar_links": [ 47 | {"url": "/", "icon": "Home", "title": "Home"}, 48 | ], 49 | "mutation_mode": "optimistic", 50 | "help": None, 51 | "locales": None, 52 | "secret": None, 53 | } 54 | 55 | def __init__(self, app: Application | None = None, **kwargs): 56 | self.api: API = API() 57 | self.auth: dict = {} 58 | self.handlers: list[type[AdminHandler]] = [] 59 | self.__login__ = self.__ident__ = cast("TAuth", page404) 60 | self.__dashboard__: TAuth | None = None 61 | super(Plugin, self).__init__(app, **kwargs) 62 | 63 | def setup(self, app: Application, **options): # noqa: C901 64 | """Initialize the application.""" 65 | super().setup(app, **options) 66 | self.cfg.update(prefix=self.cfg.prefix.rstrip("/")) 67 | self.api.setup(app, prefix=f"{self.cfg.prefix}/api", openapi=False) 68 | 69 | self.auth["storage"] = self.cfg.auth_storage 70 | self.auth["storageName"] = self.cfg.auth_storage_name 71 | self.auth["loginURL"] = self.cfg.login_url 72 | self.auth["logoutURL"] = self.cfg.logout_url 73 | 74 | custom_css = self.cfg.custom_css_url 75 | custom_js = self.cfg.custom_js_url 76 | login_url = self.cfg.login_url 77 | prefix = self.cfg.prefix 78 | title = self.cfg.title 79 | api = self.api 80 | 81 | def authorize(view): 82 | """Authorization.""" 83 | 84 | async def decorator(request): 85 | """Authorize an user.""" 86 | authorize = api.authorize 87 | if authorize: 88 | auth = await authorize(request) 89 | if not auth and login_url: 90 | return ResponseRedirect(login_url) 91 | 92 | return await view(request) 93 | 94 | return decorator 95 | 96 | @app.route(f"/{prefix.lstrip('/')}") 97 | @authorize 98 | async def render_admin(_): 99 | """Render admin page.""" 100 | return TEMPLATE.format( 101 | prefix=prefix, 102 | title=title, 103 | main_js_url=self.cfg.main_js_url.format(prefix=prefix), 104 | custom_js=f"" if custom_js else "", 105 | custom_css=f"" if custom_css else "", 106 | ) 107 | 108 | @app.route(f"{prefix}/ra.json") 109 | @authorize 110 | async def ra(request): 111 | data = self.to_ra() 112 | 113 | if self.__dashboard__: 114 | data["dashboard"] = await self.__dashboard__(request) 115 | 116 | return data 117 | 118 | @app.route(f"{prefix}/main.js") 119 | async def render_admin_static(_): 120 | return ResponseFile(PACKAGE_DIR / "main.js") 121 | 122 | @app.route(f"{prefix}/login") 123 | async def login(request): 124 | return await self.__login__(request) 125 | 126 | @app.route(f"{prefix}/ident") 127 | async def ident(request): 128 | return await self.__ident__(request) 129 | 130 | def route(self, path: Any, *paths: str, **params) -> Callable: 131 | """Route an handler.""" 132 | if not isinstance(path, str): 133 | self.register_handler(path) 134 | return self.api.route(path) 135 | 136 | paths = (path, *paths) 137 | 138 | def wrapper(cb): 139 | self.register_handler(cb) 140 | return self.api.route(*paths, **params)(cb) 141 | 142 | return wrapper 143 | 144 | def register_handler(self, handler: Any): 145 | """Register an handler.""" 146 | if isclass(handler) and issubclass(handler, AdminHandler): 147 | self.handlers.append(handler) 148 | 149 | # Authorization flow 150 | # ------------------ 151 | 152 | def check_auth(self, fn: TAuth) -> TAuth: 153 | """Register a function to authorize current user.""" 154 | self.auth["required"] = True 155 | self.api.authorize = fn 156 | return fn 157 | 158 | def login(self, fn: TAuth) -> TAuth: 159 | """Register a function to login current user.""" 160 | self.auth["authorizeURL"] = f"{self.cfg.prefix}/login" 161 | self.__login__ = fn 162 | return fn 163 | 164 | def dashboard(self, fn: TAuth) -> TAuth: 165 | """Register a function to render dashboard.""" 166 | self.__dashboard__ = fn 167 | return fn 168 | 169 | def get_identity(self, fn: TAuth) -> TAuth: 170 | """Register a function to identificate current user. 171 | 172 | User data: {id, fullName, avatar} 173 | """ 174 | self.auth["identityURL"] = f"{self.cfg.prefix}/ident" 175 | self.__ident__ = fn 176 | return fn 177 | 178 | # Serialize to react-admin 179 | # ------------------------- 180 | 181 | def to_ra(self) -> dict[str, Any]: 182 | """Prepare params for react-admin.""" 183 | handlers = self.handlers 184 | cfg = self.cfg 185 | if cfg.menu_sort: 186 | handlers = sorted(handlers, key=lambda r: (1000000 - r.meta.ra_order, r.meta.name)) 187 | 188 | locales = cfg.locales or {} 189 | for res in handlers: 190 | deepmerge(locales, res.meta.locales or {}) 191 | 192 | return { 193 | "apiUrl": f"{cfg.prefix}/api", 194 | "auth": self.auth, 195 | "adminProps": { 196 | "title": cfg.title, 197 | "disableTelemetry": True, 198 | "mutationMode": cfg.mutation_mode, 199 | }, 200 | "appBarLinks": cfg.app_bar_links, 201 | "version": VERSION, 202 | "help": cfg.help, 203 | "resources": [res.to_ra() for res in handlers], 204 | "locales": locales, 205 | } 206 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | import marshmallow as ma 4 | from marshmallow import validate 5 | 6 | 7 | def test_endpoint(app): 8 | from muffin_admin import AdminHandler, Plugin 9 | 10 | admin = Plugin(app) 11 | assert admin 12 | 13 | @admin.route 14 | class BaseHandler(AdminHandler): 15 | class Meta: # type: ignore[] 16 | name = "base" 17 | filters = "id", "name" 18 | sorting = "id", "name" 19 | locales = { 20 | "en": {"test": "Test"}, 21 | } 22 | 23 | class Schema(ma.Schema): 24 | id = ma.fields.String() 25 | name = ma.fields.String(validate=validate.Length(3, 100)) 26 | active = ma.fields.Boolean(metadata={"description": "Is active?"}) 27 | 28 | columns = "id", "active", "name", "unknown" 29 | 30 | assert admin.api.router.routes() 31 | assert admin.handlers 32 | 33 | assert BaseHandler.meta.limit == 25 34 | assert BaseHandler.meta.label == "base" 35 | assert BaseHandler.meta.columns == ("id", "active", "name", "unknown") 36 | assert BaseHandler.meta.sorting 37 | assert "id" in BaseHandler.meta.sorting.mutations 38 | assert "name" in BaseHandler.meta.sorting.mutations 39 | 40 | ra = BaseHandler.to_ra() 41 | assert ra["name"] == "base" 42 | assert ra["label"] == "base" 43 | assert ra["actions"] == [] 44 | assert not ra["icon"] 45 | assert ra["delete"] is True 46 | assert ra["create"] == [ 47 | ("TextInput", {"source": "id"}), 48 | ("TextInput", {"source": "name"}), 49 | ("BooleanInput", {"source": "active", "helperText": "Is active?"}), 50 | ] 51 | assert ra["edit"] == { 52 | "remove": True, 53 | "inputs": [ 54 | ("TextInput", {"source": "id"}), 55 | ("TextInput", {"source": "name"}), 56 | ("BooleanInput", {"source": "active", "helperText": "Is active?"}), 57 | ], 58 | } 59 | assert ra["show"] == { 60 | "links": (), 61 | "edit": True, 62 | "fields": [ 63 | ("TextField", {"source": "id"}), 64 | ("TextField", {"source": "name"}), 65 | ("BooleanField", {"source": "active"}), 66 | ], 67 | } 68 | assert ra["list"] == { 69 | "fields": [ 70 | ("TextField", {"source": "id", "sortable": True}), 71 | ("BooleanField", {"source": "active", "sortable": False}), 72 | ("TextField", {"source": "name", "sortable": True}), 73 | ], 74 | "create": True, 75 | "filters": [("TextInput", {"source": "id"}), ("TextInput", {"source": "name"})], 76 | "limit": 25, 77 | "limitMax": 100, 78 | "limitTotal": False, 79 | "show": True, 80 | "edit": True, 81 | "remove": True, 82 | } 83 | 84 | ra = admin.to_ra() 85 | assert ra["locales"] == {"en": {"test": "Test"}} 86 | 87 | 88 | async def test_endpoint_action(app): 89 | from muffin_admin import AdminHandler, Plugin 90 | 91 | admin = Plugin(app) 92 | assert admin 93 | 94 | class ActionSchema(ma.Schema): 95 | name = ma.fields.String() 96 | 97 | @admin.route 98 | class Handler(AdminHandler): 99 | class Meta: 100 | name = "handler" 101 | filters = "id", "name" 102 | sorting = "id", "name" 103 | 104 | @AdminHandler.action("/base", view="show", schema=ActionSchema) 105 | async def base_action(self, request, response=None): 106 | pass 107 | 108 | ra = Handler.to_ra() 109 | assert ra["actions"] == [ 110 | { 111 | "view": ["show"], 112 | "icon": None, 113 | "paths": ("/base",), 114 | "title": None, 115 | "label": "Base action", 116 | "id": "base_action", 117 | "schema": [("TextInput", {"source": "name"})], 118 | }, 119 | ] 120 | assert ra["edit"]["inputs"] == [] 121 | 122 | 123 | def test_custom_fields_inputs(): 124 | from muffin_admin import AdminHandler 125 | 126 | class BaseHandler(AdminHandler): 127 | class Meta(AdminHandler.Meta): 128 | name = "name" 129 | filters = "id", "name" 130 | sorting = "id", "name" 131 | 132 | class Schema(ma.Schema): 133 | id = ma.fields.String() 134 | name = ma.fields.String(validate=validate.Length(3, 100)) 135 | active = ma.fields.Boolean() 136 | 137 | columns = "id", "active", "name", "unknown" 138 | ra_inputs: ClassVar = {"id": "NumberInput"} 139 | 140 | ra = BaseHandler.to_ra() 141 | assert ra["create"] == [ 142 | ("NumberInput", {"source": "id"}), 143 | ("TextInput", {"source": "name"}), 144 | ("BooleanInput", {"source": "active"}), 145 | ] 146 | 147 | 148 | def test_schema_opts(): 149 | from muffin_admin import AdminHandler 150 | 151 | class BaseHandler(AdminHandler): 152 | class Meta(AdminHandler.Meta): 153 | name = "name" 154 | filters = "id", "name" 155 | sorting = "id", "name" 156 | 157 | class Schema(ma.Schema): 158 | id = ma.fields.String() 159 | name = ma.fields.String(validate=validate.Length(3, 100)) 160 | active = ma.fields.Boolean() 161 | 162 | class Meta(ma.Schema.Meta): 163 | fields = "name", "id" 164 | 165 | ra = BaseHandler.to_ra() 166 | assert ra 167 | assert ra["edit"] == { 168 | "remove": True, 169 | "inputs": [ 170 | ("TextInput", {"source": "name"}), 171 | ("TextInput", {"source": "id"}), 172 | ], 173 | } 174 | 175 | 176 | def test_disable_edit(): 177 | from muffin_admin import AdminHandler 178 | 179 | class BaseHandler(AdminHandler): 180 | class Meta(AdminHandler.Meta): 181 | name = "name" 182 | filters = "id", "name" 183 | sorting = "id", "name" 184 | edit = False 185 | create = False 186 | delete = False 187 | 188 | class Schema(ma.Schema): 189 | id = ma.fields.String() 190 | name = ma.fields.String(validate=validate.Length(3, 100)) 191 | active = ma.fields.Boolean() 192 | 193 | class Meta(ma.Schema.Meta): 194 | fields = "name", "id" 195 | 196 | ra = BaseHandler.to_ra() 197 | assert ra 198 | assert ra["edit"] is False 199 | assert ra["create"] is False 200 | assert ra["delete"] is False 201 | 202 | 203 | def test_disable_delete(): 204 | from muffin_admin import AdminHandler 205 | 206 | class BaseHandler(AdminHandler): 207 | class Meta(AdminHandler.Meta): 208 | name = "name" 209 | filters = "id", "name" 210 | sorting = "id", "name" 211 | delete = False 212 | 213 | class Schema(ma.Schema): 214 | id = ma.fields.String() 215 | name = ma.fields.String(validate=validate.Length(3, 100)) 216 | active = ma.fields.Boolean() 217 | 218 | class Meta(ma.Schema.Meta): 219 | fields = "name", "id" 220 | 221 | ra = BaseHandler.to_ra() 222 | assert ra 223 | assert ra["delete"] is False 224 | assert ra["list"]["remove"] is False 225 | assert ra["edit"]["remove"] is False 226 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Muffin-Admin 2 | ############# 3 | 4 | .. _description: 5 | 6 | **Muffin-Admin** -- an extension to Muffin_ that implements admin-interfaces 7 | 8 | .. _badges: 9 | 10 | .. image:: https://github.com/klen/muffin-admin/workflows/tests/badge.svg 11 | :target: https://github.com/klen/muffin-admin/actions 12 | :alt: Tests Status 13 | 14 | .. image:: https://img.shields.io/pypi/v/muffin-admin 15 | :target: https://pypi.org/project/muffin-admin/ 16 | :alt: PYPI Version 17 | 18 | .. image:: https://img.shields.io/pypi/pyversions/muffin-admin 19 | :target: https://pypi.org/project/muffin-admin/ 20 | :alt: Python Versions 21 | 22 | ---------- 23 | 24 | .. image:: https://raw.github.com/klen/muffin-admin/develop/.github/muffin-admin.png 25 | :height: 200px 26 | 27 | .. _features: 28 | 29 | Features 30 | -------- 31 | 32 | - Support for `Peewee ORM`_, Mongo_, `SQLAlchemy Core`_ through `Muffin-Rest`_; 33 | - Automatic filtering and sorting for items; 34 | 35 | .. _contents: 36 | 37 | .. contents:: 38 | 39 | .. _requirements: 40 | 41 | Requirements 42 | ============= 43 | 44 | - python >= 3.9 45 | 46 | .. _installation: 47 | 48 | Installation 49 | ============= 50 | 51 | **Muffin-Admin** should be installed using pip: :: 52 | 53 | pip install muffin-admin 54 | 55 | With `SQLAlchemy Core`_ support: :: 56 | 57 | pip install muffin-admin[sqlalchemy] 58 | 59 | With `Peewee ORM`_ support: :: 60 | 61 | pip install muffin-admin[peewee] 62 | 63 | .. _usage: 64 | 65 | Usage 66 | ===== 67 | 68 | Initialize the admin: 69 | 70 | .. code-block:: python 71 | 72 | from muffin_admin import Plugin 73 | 74 | admin = Plugin(**options) 75 | 76 | Initialize admin handlers (example for `Peewee ORM`_): 77 | 78 | .. code-block:: python 79 | 80 | from muffin_admin import PWAdminHandler 81 | 82 | @admin.route 83 | class UserResource(PWAdminHandler): 84 | 85 | """Create Admin Resource for the User model.""" 86 | 87 | class Meta: 88 | 89 | """Tune the resource.""" 90 | 91 | # Peewee Model for the admin resource 92 | model = User 93 | 94 | # Filters 95 | filters = 'email', 'created', 'is_active', 'role' 96 | 97 | # Tune serialization/deserialization schemas 98 | schema_meta = { 99 | 'load_only': ('password',), 100 | 'dump_only': ('created',), 101 | } 102 | 103 | # Columns to show 104 | columns = 'id', 'email', 'is_active', 'role', 'created' 105 | 106 | # Custom Material-UI icon 107 | icon = 'People' 108 | 109 | Connect admin to an Muffin_ application: 110 | 111 | .. code-block:: python 112 | 113 | admin.setup(app, **options) 114 | 115 | 116 | Authentication 117 | -------------- 118 | 119 | Decorate an authentication function with ``admin.check_auth``: 120 | 121 | .. code-block:: python 122 | 123 | @admin.check_auth 124 | async def auth(request): 125 | """Fake authorization method. Just checks for an auth token exists in request.""" 126 | return request.headers.get('authorization') 127 | 128 | 129 | Register a function to return user's information: 130 | 131 | .. code-block:: python 132 | 133 | @admin.get_identity 134 | async def ident(request): 135 | """Get current user information. Just an example.""" 136 | user_id = request.headers.get('authorization') 137 | user = User.select().where(User.id == user_id).first() 138 | if user: 139 | return {"id": user.id, "fullName": user.email} 140 | 141 | Implement a login handler for standart react-admin auth page: 142 | 143 | .. code-block:: python 144 | 145 | @admin.login 146 | async def login(request): 147 | """Login a user.""" 148 | data = await request.data() 149 | user = User.select().where( 150 | User.email == data['username'], User.password == data['password']).first() 151 | return ResponseJSON(user and user.id) 152 | 153 | 154 | For futher reference check `https://github.com/klen/muffin-admin/tree/develop/examples ` in the repository. 155 | 156 | Custom Actions 157 | --------------- 158 | 159 | .. code-block:: python 160 | 161 | from muffin_admin import PWAdminHandler 162 | 163 | @admin.route 164 | class UserResource(PWAdminHandler): 165 | 166 | # ... 167 | 168 | @PWAdminHandler.action('users/disable', view='list') 169 | async def disable_users(self, request, resource=None): 170 | ids = request.query.getall('ids') 171 | # ... 172 | 173 | @PWAdminHandler.action('users/{id}/admin', view='show') 174 | async def mark_admin(self, request, resource=None): 175 | # ... 176 | 177 | 178 | Configuration options 179 | ---------------------- 180 | 181 | =========================== ==================================================== =========================== 182 | Name Default value Description 183 | --------------------------- ---------------------------------------------------- --------------------------- 184 | **prefix** ``"/admin"`` Admin's HTTP URL prefix 185 | **title** ``"Muffin Admin"`` Admin's title 186 | **main_js_url** ``"{prefix}/main.js"`` A link to main JS file 187 | **custom_js_url** ``""`` A link to custom JS file 188 | **custom_css_url** ``""`` A link to custom CSS file 189 | **login_url** ``None`` An HTTP URL for your custom login page 190 | **logout_url** ``None`` An HTTP URL for your custom logout page 191 | **menu_sort** ``True`` Sort menu items 192 | **auth_storage** ``"localstorage"`` Where to keep authorization information (localstorage|cookies) 193 | **auth_storage_name** ``muffin_admin_auth`` Localstorage/Cookie name for authentication info 194 | **app_bar_links** ``[{'url': '/', 'icon': 'Home', 'title': 'Home'}]`` Appbar links 195 | **mutation_mode** ``"optimistic"`` React-Admin edit mutation mode (pessimistic|optimistic|undoable) 196 | =========================== ==================================================== =========================== 197 | 198 | .. _bugtracker: 199 | 200 | Bug tracker 201 | =========== 202 | 203 | If you have any suggestions, bug reports or 204 | annoyances please report them to the issue tracker 205 | at https://github.com/klen/muffin-admin/issues 206 | 207 | .. _contributing: 208 | 209 | Contributing 210 | ============ 211 | 212 | Development of Muffin-Admin happens at: https://github.com/klen/muffin-admin 213 | 214 | 215 | Contributors 216 | ============= 217 | 218 | * klen_ (Kirill Klenov) 219 | 220 | .. _license: 221 | 222 | License 223 | ======== 224 | 225 | Licensed under a `MIT license`_. 226 | 227 | .. _links: 228 | 229 | .. _klen: https://github.com/klen 230 | .. _Muffin: https://github.com/klen/muffin 231 | .. _MIT license: http://opensource.org/licenses/MIT 232 | .. _Mongo: https://www.mongodb.com/ 233 | .. _Peewee ORM: http://docs.peewee-orm.com/en/latest/ 234 | .. _SqlAlchemy Core: https://docs.sqlalchemy.org/en/14/core/ 235 | .. _Muffin-Rest: https://github.com/klen/muffin-rest 236 | -------------------------------------------------------------------------------- /tests/test_sqlalchemy.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import enum 3 | from typing import ClassVar 4 | 5 | import muffin_databases 6 | import pytest 7 | import sqlalchemy as sa 8 | 9 | db = muffin_databases.Plugin(url="sqlite:///:memory:") 10 | 11 | 12 | meta = sa.MetaData() 13 | 14 | Role = sa.Table( 15 | "role", 16 | meta, 17 | sa.Column("id", sa.Integer, primary_key=True), 18 | sa.Column("name", sa.String(255), nullable=False), 19 | ) 20 | 21 | 22 | class UserStatus(enum.Enum): 23 | new = 1 24 | old = 2 25 | 26 | 27 | User = sa.Table( 28 | "user", 29 | meta, 30 | sa.Column("id", sa.Integer, primary_key=True), 31 | sa.Column("name", sa.String(255), nullable=False), 32 | sa.Column("password", sa.String(255), nullable=True), 33 | sa.Column("is_active", sa.Boolean, default=True), 34 | sa.Column("status", sa.Enum(UserStatus), default=UserStatus.new, nullable=False), 35 | sa.Column("created", sa.DateTime, default=dt.datetime.utcnow, nullable=False), 36 | sa.Column("is_super", sa.Boolean, default=False), 37 | sa.Column("meta", sa.JSON, default={}), 38 | sa.Column("role_id", sa.ForeignKey("role.id"), nullable=False), 39 | ) 40 | 41 | Message = sa.Table( 42 | "message", 43 | meta, 44 | sa.Column("id", sa.Integer, primary_key=True), 45 | sa.Column("body", sa.Text(), nullable=False), 46 | sa.Column("user_id", sa.ForeignKey("user.id"), nullable=False), 47 | ) 48 | 49 | 50 | # @pytest.fixture(autouse=True) 51 | # async def setup_db(app): 52 | 53 | 54 | @pytest.fixture(autouse=True) 55 | def setup_admin(app): 56 | from muffin_admin import Plugin, SAAdminHandler 57 | 58 | admin = Plugin(app) 59 | 60 | @admin.route 61 | class UserAdmin(SAAdminHandler): 62 | class Meta(SAAdminHandler.Meta): 63 | table = User 64 | database = db 65 | schema_meta: ClassVar = { 66 | "dump_only": ("is_super",), 67 | "load_only": ("password",), 68 | "exclude": ("created",), 69 | } 70 | filters = ("status",) 71 | ra_refs: ClassVar = {"role_id": {"source": "name"}} 72 | 73 | @admin.route 74 | class RoleAdmin(SAAdminHandler): 75 | class Meta(SAAdminHandler.Meta): 76 | table = Role 77 | database = db 78 | 79 | @admin.route 80 | class MessageAdmin(SAAdminHandler): 81 | class Meta(SAAdminHandler.Meta): 82 | table = Message 83 | database = db 84 | ra_refs: ClassVar = {"user_id": {"source": "email"}} 85 | 86 | 87 | def test_admin(app): 88 | admin = app.plugins["admin"] 89 | assert admin.to_ra() 90 | 91 | assert admin.api.router.routes() 92 | assert admin.handlers 93 | 94 | 95 | def test_admin_schemas(app): 96 | admin = app.plugins["admin"] 97 | UserResource = admin.handlers[0] 98 | assert UserResource.meta.limit 99 | assert UserResource.meta.columns 100 | assert UserResource.meta.sorting 101 | 102 | ra = UserResource.to_ra() 103 | assert ra["name"] == "user" 104 | assert ra["label"] == "user" 105 | assert ra["icon"] == "" 106 | assert ra["delete"] is True 107 | assert ra["create"] == [ 108 | ("NumberInput", {"source": "id"}), 109 | ("TextInput", {"required": True, "source": "name"}), 110 | ("TextInput", {"source": "password"}), 111 | ("BooleanInput", {"source": "is_active"}), 112 | ( 113 | "SelectInput", 114 | { 115 | "choices": [{"id": 1, "name": "new"}, {"id": 2, "name": "old"}], 116 | "source": "status", 117 | }, 118 | ), 119 | ("JsonInput", {"source": "meta"}), 120 | ( 121 | "FKInput", 122 | { 123 | "required": True, 124 | "reference": "role", 125 | "emptyValue": "", 126 | "refSource": "name", 127 | "refKey": "id", 128 | "source": "role_id", 129 | }, 130 | ), 131 | ] 132 | assert ra["edit"] == { 133 | "remove": True, 134 | "inputs": [ 135 | ("NumberInput", {"source": "id"}), 136 | ("TextInput", {"required": True, "source": "name"}), 137 | ("TextInput", {"source": "password"}), 138 | ("BooleanInput", {"source": "is_active"}), 139 | ( 140 | "SelectInput", 141 | { 142 | "choices": [{"id": 1, "name": "new"}, {"id": 2, "name": "old"}], 143 | "source": "status", 144 | }, 145 | ), 146 | ("JsonInput", {"source": "meta"}), 147 | ( 148 | "FKInput", 149 | { 150 | "required": True, 151 | "reference": "role", 152 | "emptyValue": "", 153 | "refSource": "name", 154 | "refKey": "id", 155 | "source": "role_id", 156 | }, 157 | ), 158 | ], 159 | } 160 | assert ra["show"] == { 161 | "edit": True, 162 | "links": (), 163 | "fields": [ 164 | ("NumberField", {"source": "id"}), 165 | ("TextField", {"source": "name"}), 166 | ("BooleanField", {"source": "is_active"}), 167 | ( 168 | "SelectField", 169 | { 170 | "source": "status", 171 | "choices": [{"id": 1, "name": "new"}, {"id": 2, "name": "old"}], 172 | }, 173 | ), 174 | ("BooleanField", {"source": "is_super"}), 175 | ("JsonField", {"source": "meta"}), 176 | ( 177 | "FKField", 178 | { 179 | "source": "role_id", 180 | "refSource": "name", 181 | "refKey": "id", 182 | "reference": "role", 183 | }, 184 | ), 185 | ], 186 | } 187 | 188 | assert ra["list"] 189 | assert ra["list"]["sort"] == {"field": "id", "order": "DESC"} 190 | assert ra["list"]["limit"] == 25 191 | assert ra["list"]["limitMax"] == 100 192 | assert ra["list"]["show"] is True 193 | assert ra["list"]["edit"] is True 194 | assert ra["list"]["filters"] == [ 195 | ("TextInput", {"source": "id"}), 196 | ( 197 | "SelectArrayInput", 198 | { 199 | "source": "status", 200 | "choices": [{"id": 1, "name": "new"}, {"id": 2, "name": "old"}], 201 | }, 202 | ), 203 | ] 204 | assert ra["list"]["fields"] == [ 205 | ("NumberField", {"source": "id", "sortable": True}), 206 | ("TextField", {"source": "name", "sortable": True}), 207 | ("BooleanField", {"source": "is_active", "sortable": True}), 208 | ( 209 | "SelectField", 210 | { 211 | "source": "status", 212 | "sortable": True, 213 | "choices": [{"id": 1, "name": "new"}, {"id": 2, "name": "old"}], 214 | }, 215 | ), 216 | ("BooleanField", {"source": "is_super", "sortable": True}), 217 | ("JsonField", {"source": "meta", "sortable": True}), 218 | ( 219 | "FKField", 220 | { 221 | "source": "role_id", 222 | "refSource": "name", 223 | "refKey": "id", 224 | "reference": "role", 225 | "sortable": True, 226 | }, 227 | ), 228 | ] 229 | 230 | 231 | def test_admin_schemas2(app): 232 | admin = app.plugins["admin"] 233 | MessageResource = admin.handlers[2] 234 | assert MessageResource.to_ra()["edit"] == { 235 | "remove": True, 236 | "inputs": [ 237 | ("TextInput", {"source": "body", "required": True, "multiline": True}), 238 | ( 239 | "FKInput", 240 | { 241 | "required": True, 242 | "reference": "user", 243 | "emptyValue": "", 244 | "refSource": "email", 245 | "refKey": "id", 246 | "source": "user_id", 247 | }, 248 | ), 249 | ], 250 | } 251 | -------------------------------------------------------------------------------- /tests/test_peewee.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import ClassVar 3 | 4 | import muffin_peewee 5 | import peewee as pw 6 | import pytest 7 | from marshmallow import fields 8 | 9 | db = muffin_peewee.Plugin( 10 | connection="sqlite:///:memory:", manage_connections=False, auto_connection=False 11 | ) 12 | 13 | 14 | @db.register 15 | class Role(pw.Model): 16 | name = pw.CharField() 17 | 18 | 19 | @db.register 20 | class User(pw.Model): 21 | name = pw.CharField() 22 | password = pw.CharField() 23 | is_active = pw.BooleanField(default=True, help_text="Disable to block the user") 24 | status = pw.IntegerField(default=1, choices=[(1, "new"), (2, "old")]) 25 | meta: muffin_peewee.JSONLikeField = muffin_peewee.JSONLikeField(default={}) 26 | 27 | created = pw.DateTimeField(default=dt.datetime.utcnow) 28 | is_super = pw.BooleanField(default=True) 29 | 30 | role = pw.ForeignKeyField(Role, null=True) 31 | 32 | 33 | @db.register 34 | class Message(pw.Model): 35 | body = pw.TextField() 36 | user = pw.ForeignKeyField(Role) 37 | 38 | 39 | @pytest.fixture(params=[pytest.param(("asyncio", {"use_uvloop": False}), id="asyncio")]) 40 | def aiolib(request): 41 | return request.param 42 | 43 | 44 | @pytest.fixture(autouse=True) 45 | async def setup_db(app): 46 | db.setup(app) 47 | async with db, db.connection(): 48 | await db.create_tables() 49 | yield db 50 | await db.drop_tables() 51 | 52 | 53 | @pytest.fixture(autouse=True) 54 | def admin(app): 55 | from muffin_admin import Plugin, PWAdminHandler 56 | 57 | admin = Plugin(app) 58 | 59 | @admin.route 60 | class UserAdmin(PWAdminHandler): 61 | class Meta(PWAdminHandler.Meta): 62 | model = User 63 | schema_meta: ClassVar = { 64 | "dump_only": ("is_super",), 65 | "load_only": ("password",), 66 | "exclude": ("created",), 67 | } 68 | schema_fields: ClassVar = { 69 | "name": fields.String(metadata={"description": "User name"}), 70 | } 71 | ra_refs: ClassVar = {"role": {"source": "name"}} 72 | filters = ("status",) 73 | 74 | @admin.route 75 | class RoleAdmin(PWAdminHandler): 76 | class Meta(PWAdminHandler.Meta): 77 | model = Role 78 | 79 | @admin.route 80 | class MessageAdmin(PWAdminHandler): 81 | class Meta(PWAdminHandler.Meta): 82 | model = Message 83 | ra_refs: ClassVar = {"user": {"source": "email"}} 84 | 85 | return admin 86 | 87 | 88 | async def test_admin(app): 89 | admin = app.plugins["admin"] 90 | assert admin.to_ra() 91 | 92 | assert admin.api.router.routes() 93 | assert admin.handlers 94 | 95 | 96 | async def test_user_resource(app): 97 | admin = app.plugins["admin"] 98 | 99 | user_resource_type = admin.handlers[0] 100 | assert user_resource_type.meta.limit 101 | assert user_resource_type.meta.columns 102 | assert user_resource_type.meta.sorting 103 | 104 | assert user_resource_type.meta.Schema 105 | assert user_resource_type.meta.Schema._declared_fields["is_active"].load_default is True 106 | 107 | ra = user_resource_type.to_ra() 108 | assert ra["delete"] is True 109 | assert not ra["icon"] 110 | assert ra["name"] == "user" 111 | assert ra["label"] == "user" 112 | assert ra["create"] == [ 113 | ("TextInput", {"helperText": "User name", "source": "name"}), 114 | ("TextInput", {"required": True, "source": "password"}), 115 | ( 116 | "BooleanInput", 117 | { 118 | "defaultValue": True, 119 | "source": "is_active", 120 | "helperText": "Disable to block the user", 121 | }, 122 | ), 123 | ( 124 | "SelectInput", 125 | { 126 | "choices": [{"id": 1, "name": "new"}, {"id": 2, "name": "old"}], 127 | "defaultValue": 1, 128 | "source": "status", 129 | }, 130 | ), 131 | ("JsonInput", {"source": "meta"}), 132 | ( 133 | "FKInput", 134 | { 135 | "source": "role", 136 | "reference": "role", 137 | "refKey": "id", 138 | "refSource": "name", 139 | }, 140 | ), 141 | ] 142 | assert ra["edit"] == { 143 | "remove": True, 144 | "inputs": [ 145 | ("TextInput", {"helperText": "User name", "source": "name"}), 146 | ("TextInput", {"required": True, "source": "password"}), 147 | ( 148 | "BooleanInput", 149 | { 150 | "defaultValue": True, 151 | "source": "is_active", 152 | "helperText": "Disable to block the user", 153 | }, 154 | ), 155 | ( 156 | "SelectInput", 157 | { 158 | "choices": [{"id": 1, "name": "new"}, {"id": 2, "name": "old"}], 159 | "defaultValue": 1, 160 | "source": "status", 161 | }, 162 | ), 163 | ("JsonInput", {"source": "meta"}), 164 | ( 165 | "FKInput", 166 | { 167 | "source": "role", 168 | "reference": "role", 169 | "refKey": "id", 170 | "refSource": "name", 171 | }, 172 | ), 173 | ], 174 | } 175 | assert ra["show"] == { 176 | "links": (), 177 | "edit": True, 178 | "fields": [ 179 | ("TextField", {"source": "id"}), 180 | ("TextField", {"source": "name"}), 181 | ("BooleanField", {"source": "is_active"}), 182 | ( 183 | "SelectField", 184 | { 185 | "source": "status", 186 | "choices": [{"id": 1, "name": "new"}, {"id": 2, "name": "old"}], 187 | }, 188 | ), 189 | ("JsonField", {"source": "meta"}), 190 | ("BooleanField", {"source": "is_super"}), 191 | ( 192 | "FKField", 193 | { 194 | "source": "role", 195 | "refSource": "name", 196 | "refKey": "id", 197 | "reference": "role", 198 | }, 199 | ), 200 | ], 201 | } 202 | assert ra["list"] 203 | assert ra["list"]["create"] 204 | assert ra["list"]["sort"] == {"field": "id", "order": "DESC"} 205 | assert ra["list"]["limit"] == 25 206 | assert ra["list"]["limitMax"] == 100 207 | assert ra["list"]["limitTotal"] is False 208 | assert ra["list"]["show"] is True 209 | assert ra["list"]["edit"] is True 210 | assert ra["list"]["filters"] == [ 211 | ("TextInput", {"source": "id"}), 212 | ( 213 | "SelectArrayInput", 214 | { 215 | "choices": [{"id": 1, "name": "new"}, {"id": 2, "name": "old"}], 216 | "source": "status", 217 | }, 218 | ), 219 | ] 220 | 221 | assert ra["list"]["fields"] == [ 222 | ("TextField", {"source": "id", "sortable": True}), 223 | ("TextField", {"source": "name", "sortable": True}), 224 | ("BooleanField", {"source": "is_active", "sortable": True}), 225 | ( 226 | "SelectField", 227 | { 228 | "source": "status", 229 | "sortable": True, 230 | "choices": [{"id": 1, "name": "new"}, {"id": 2, "name": "old"}], 231 | }, 232 | ), 233 | ("JsonField", {"source": "meta", "sortable": True}), 234 | ("BooleanField", {"source": "is_super", "sortable": True}), 235 | ( 236 | "FKField", 237 | { 238 | "source": "role", 239 | "refSource": "name", 240 | "refKey": "id", 241 | "reference": "role", 242 | "sortable": True, 243 | }, 244 | ), 245 | ] 246 | 247 | 248 | async def test_msg_resource(app): 249 | admin = app.plugins["admin"] 250 | 251 | message_resource_type = admin.handlers[2] 252 | ra = message_resource_type.to_ra() 253 | assert ra["edit"] == { 254 | "remove": True, 255 | "inputs": [ 256 | ("TextInput", {"source": "body", "required": True, "multiline": True}), 257 | ( 258 | "FKInput", 259 | { 260 | "source": "user", 261 | "required": True, 262 | "refSource": "email", 263 | "refKey": "id", 264 | "reference": "role", 265 | }, 266 | ), 267 | ], 268 | } 269 | 270 | 271 | async def test_client(client, admin, setup_db): 272 | response = await client.get(admin.api.prefix + "/user") 273 | assert response.status_code == 200 274 | -------------------------------------------------------------------------------- /example/peewee_orm/admin.py: -------------------------------------------------------------------------------- 1 | """Setup admin UI.""" 2 | 3 | import asyncio 4 | from pathlib import Path 5 | from typing import ClassVar 6 | 7 | import marshmallow as ma 8 | from muffin import Response, ResponseJSON 9 | from muffin_rest import APIError 10 | 11 | from example.peewee_orm.schemas import GreetActionSchema 12 | from muffin_admin import Plugin, PWAdminHandler 13 | 14 | from . import app 15 | from .database import Group, Message, User 16 | 17 | admin = Plugin( 18 | app, 19 | custom_css_url="/admin.css", 20 | help="https://fakeHelpLink.com", 21 | locales={ 22 | "en": { 23 | "resources": { 24 | "user": { 25 | "fields": { 26 | "name": "Full Name", 27 | "picture": "Avatar", 28 | "is_active": "Active", 29 | "role": "Role", 30 | }, 31 | "actions": { 32 | "disable": "Disable Users", 33 | "greet": "Greeter", 34 | "error": "Broken Action", 35 | }, 36 | }, 37 | "group": { 38 | "fields": { 39 | "name": "Group Name", 40 | }, 41 | }, 42 | "message": { 43 | "fields": { 44 | "status": "Status", 45 | "user": "User", 46 | }, 47 | "actions": { 48 | "publish": "Publish", 49 | }, 50 | }, 51 | }, 52 | }, 53 | "ru": { 54 | "resources": { 55 | "user": { 56 | "name": "Пользователи", 57 | "fields": { 58 | "name": "Полное имя", 59 | "picture": "Аватар", 60 | "email": "Электронная почта", 61 | "is_active": "Активный", 62 | "role": "Роль", 63 | }, 64 | "actions": { 65 | "disable": "Отключить пользователей", 66 | "greet": "Приветствие", 67 | "error": "Сломанное действие", 68 | }, 69 | }, 70 | "group": { 71 | "name": "Группы", 72 | "fields": { 73 | "name": "Имя группы", 74 | }, 75 | }, 76 | "message": { 77 | "name": "Сообщения", 78 | "fields": { 79 | "status": "Статус", 80 | "user": "Пользователь", 81 | }, 82 | "actions": { 83 | "publish": "Опубликовать", 84 | }, 85 | }, 86 | }, 87 | }, 88 | }, 89 | ) 90 | 91 | 92 | # Setup authorization 93 | # ------------------- 94 | 95 | 96 | @admin.check_auth 97 | async def auth(request): 98 | """Fake authorization method. Do not use in production.""" 99 | email = request.headers.get("authorization") or request.query.get("t") 100 | return await User.select().where(User.email == email).first() 101 | 102 | 103 | @admin.get_identity 104 | async def ident(request): 105 | """Get current user information.""" 106 | user = await auth(request) 107 | if user: 108 | return {"email": user.email, "fullName": user.email} 109 | 110 | 111 | @admin.dashboard 112 | async def dashboard(request): 113 | """Render dashboard cards.""" 114 | return [ 115 | [ 116 | { 117 | "title": "App config (Table view)", 118 | "value": [(k, str(v)) for k, v in app.cfg], 119 | }, 120 | { 121 | "title": "Request headers (JSON view)", 122 | "value": {k: v for k, v in request.headers.items() if k != "cookie"}, 123 | }, 124 | ], 125 | ] 126 | 127 | 128 | @admin.login 129 | async def login(request): 130 | """Login an user.""" 131 | data = await request.data() 132 | user = ( 133 | await User.select() 134 | .where(User.email == data["username"], User.password == data["password"]) 135 | .first() 136 | ) 137 | return ResponseJSON(user and user.email) # type: ignore[] 138 | 139 | 140 | # Setup handlers 141 | # -------------- 142 | 143 | 144 | @admin.route 145 | class UserResource(PWAdminHandler): 146 | """Create Admin Resource for the User model.""" 147 | 148 | class Meta(PWAdminHandler.Meta): 149 | """Tune the resource.""" 150 | 151 | model = User 152 | filters = "created", "is_active", "role", ("email", {"operator": "$contains"}) # type: ignore[] 153 | sorting = ("email", {"default": "desc"}), "created", "is_active", "role" 154 | schema_meta: ClassVar = { 155 | "load_only": ("password",), 156 | "dump_only": ("created",), 157 | } 158 | schema_fields: ClassVar = { 159 | "name": ma.fields.Function( 160 | lambda user: f"{user.first_name} {user.last_name}", 161 | ), 162 | "picture": ma.fields.Raw(), 163 | } 164 | 165 | icon = "Person" 166 | help = "https://fakeHelpLink.com" 167 | columns = "email", "picture", "name", "is_active", "role" 168 | 169 | ra_fields: ClassVar = { 170 | "picture": ("AvatarField", {"alt": "picture", "nameProp": "name", "sortable": False}), 171 | } 172 | ra_links = (("message", {"label": "Messages", "title": "Show user messages"}),) 173 | ra_refs: ClassVar = {"group": {"source": "name"}} 174 | ra_filters: ClassVar = { 175 | "email": ("SearchFilter", {}), 176 | "created": ("DateRangeFilter", {"type": "datetime-local"}), 177 | } 178 | delete = False 179 | group = "People" 180 | locales: ClassVar = { 181 | "en": { 182 | "groups": { 183 | "People": "Users", 184 | }, 185 | }, 186 | "ru": { 187 | "groups": { 188 | "People": "Пользователи", 189 | }, 190 | }, 191 | } 192 | ra_inputs: ClassVar = { 193 | "picture": ("ImgInput", {}), 194 | } 195 | 196 | async def save(self, request, resource, *, update=False): 197 | picture = resource.picture 198 | if picture and not isinstance(picture, str): 199 | filename = f"avatar-{resource.email}" 200 | with Path.open(app.cfg.STATIC_FOLDERS[0] / filename, "wb") as f: 201 | f.write(picture.read()) 202 | 203 | resource.picture = f"/static/{filename}" 204 | 205 | return await super().save(request, resource, update=update) 206 | 207 | @PWAdminHandler.action("/user/error", label="Broken Action", icon="Error", view=["bulk"]) 208 | async def just_raise_an_error(self, request, resource=None): 209 | """Just show an error.""" 210 | raise APIError.BAD_REQUEST(message="The action is broken") 211 | 212 | @PWAdminHandler.action("/user/disable", label="Disable Users", icon="Clear", view=["bulk"]) 213 | async def disable_users(self, request, resource=None): 214 | """Mark selected users as inactive.""" 215 | keys = request.query.getall("ids") 216 | await User.update(is_active=False).where(User.email << keys) # type: ignore[] 217 | await asyncio.sleep(1) 218 | return {"status": True, "ids": keys, "message": "Users is disabled"} 219 | 220 | @PWAdminHandler.action( 221 | "/user/greet", 222 | "/user/{id}/greet", 223 | label="Greeter", 224 | view=["list", "show"], 225 | schema=GreetActionSchema, 226 | help="http://fakeHelpLink.com", 227 | ) 228 | async def greet(self, request, resource=None, data=None): 229 | """Mark selected users as inactive.""" 230 | if not data: 231 | return {"status": False, "message": "No data provided"} 232 | 233 | return {"status": True, "message": f"Hello {data.name}"} 234 | 235 | @PWAdminHandler.action( 236 | "/user/export", label="ra.action.export", icon="Download", view=["list"], file=True 237 | ) 238 | async def export(self, request, **_): 239 | pass 240 | 241 | 242 | @admin.route 243 | class GroupResource(PWAdminHandler): 244 | """Create Admin Resource for the Group model.""" 245 | 246 | class Meta(PWAdminHandler.Meta): 247 | model = Group 248 | schema_meta: ClassVar = {"dump_only": ("created",)} 249 | icon = "People" 250 | group = "People" 251 | 252 | 253 | @admin.route 254 | class MessageResource(PWAdminHandler): 255 | """Create Admin Resource for the Message model.""" 256 | 257 | class Meta(PWAdminHandler.Meta): 258 | """Tune the resource.""" 259 | 260 | model = Message 261 | filters = "status", "user", "dtpublish" 262 | schema_meta: ClassVar = {"dump_only": ("created",)} 263 | 264 | icon = "Message" 265 | ra_refs: ClassVar = {"user": {"source": "email"}} 266 | ra_fields: ClassVar = {"status": ("ChipField", {})} 267 | columns = "id", "created", "title", "status", "dtpublish", "user" 268 | 269 | @PWAdminHandler.action( 270 | "/message/{id}/publish", label="Publish", icon="Publish", view="show", confirm=True 271 | ) 272 | async def publish_message(self, _, resource=None): 273 | if resource is None: 274 | raise APIError.NOT_FOUND() 275 | 276 | resource.status = "published" 277 | await resource.save() 278 | 279 | return {"status": True, "message": "Message is published"} 280 | 281 | @PWAdminHandler.action( 282 | "/message/data.csv", label="Download CSV", icon="Download", view="list", file=True 283 | ) 284 | async def csv(self, _, **__): 285 | """Download CSV file.""" 286 | return Response( 287 | content="id,created,text,user\n" 288 | + "\n".join(f"{m.id},{m.created},{m.title}" for m in await Message.select()), 289 | content_type="text/csv", 290 | headers={"Content-Disposition": "attachment; filename=messages.csv"}, 291 | ) 292 | -------------------------------------------------------------------------------- /muffin_admin/handler.py: -------------------------------------------------------------------------------- 1 | """Basic admin handler.""" 2 | 3 | from __future__ import annotations 4 | 5 | import inspect 6 | from functools import wraps 7 | from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Sequence, cast 8 | 9 | import marshmallow as ma 10 | from muffin_rest import APIError 11 | from muffin_rest.handler import RESTBase 12 | from muffin_rest.options import RESTOptions 13 | from muffin_rest.schemas import EnumField 14 | 15 | if TYPE_CHECKING: 16 | from http_router.types import TMethods 17 | from muffin import Request, Response 18 | from muffin_rest.filters import Filter 19 | 20 | from muffin_admin.types import TActionView, TRAInputs, TRALinks, TRAProps, TRAReference 21 | 22 | from .types import TRAConverter, TRAInfo 23 | 24 | 25 | class AdminOptions(RESTOptions): 26 | """Prepare admin handler.""" 27 | 28 | limit: int = 25 29 | limit_max: int = 100 30 | limit_total = False 31 | 32 | icon: str = "" 33 | label: str = "" 34 | group: str | None = None 35 | id: str = "id" 36 | 37 | create: bool = True 38 | delete: bool = True 39 | edit: bool = True 40 | show: bool = True 41 | 42 | actions: Sequence = () 43 | columns: tuple[str, ...] = () 44 | help: str | None = None 45 | locales: dict[str, dict[str, Any]] | None = None 46 | 47 | ra_order: int = 0 48 | ra_fields: ClassVar[dict[str, TRAInfo]] = {} 49 | ra_inputs: ClassVar[dict[str, TRAInfo]] = {} 50 | ra_filters: ClassVar[dict[str, TRAInfo]] = {} 51 | ra_refs: ClassVar[dict[str, TRAReference]] = {} 52 | ra_links: TRALinks = () 53 | 54 | ra_list_params: ClassVar[dict[str, Any]] = {} 55 | ra_filters_always_on: ClassVar[list[str]] = [] 56 | ra_show_params: ClassVar[dict[str, Any]] = {} 57 | 58 | def setup(self, cls: AdminHandler): 59 | """Check and build required options.""" 60 | if not self.limit: 61 | raise ValueError("`AdminHandler.Meta.limit` can't be nullable.") 62 | 63 | super(AdminOptions, self).setup(cls) 64 | 65 | self.actions = [ 66 | method.__action__ 67 | for _, method in inspect.getmembers(cls, lambda m: hasattr(m, "__action__")) 68 | ] 69 | 70 | if not self.label: 71 | self.label = cast("str", self.name) 72 | 73 | if not self.columns: 74 | self.columns = tuple( 75 | name 76 | for name, field in self.Schema._declared_fields.items() 77 | if field 78 | and not ( 79 | field.load_only 80 | or name in self.Schema.opts.load_only 81 | or name in self.Schema.opts.exclude 82 | ) 83 | ) 84 | 85 | if not self.sorting and self.columns: 86 | self.default_sort() 87 | 88 | def default_sort(self: AdminOptions): 89 | """Get default sorting.""" 90 | sorting: list[str | tuple] = list(self.columns) 91 | sorting[0] = (sorting[0], {"default": "desc"}) 92 | self.sorting = sorting # type: ignore[assignment] 93 | 94 | 95 | class AdminHandler(RESTBase): 96 | """Basic handler class for admin UI.""" 97 | 98 | meta_class: type[AdminOptions] = AdminOptions 99 | meta: AdminOptions 100 | 101 | async def __call__(self, request: Request, *, method_name=None, **_): 102 | """Handle the request.""" 103 | response = await super(AdminHandler, self).__call__(request, method_name=method_name) 104 | await self.log(request, response, method_name) 105 | return response 106 | 107 | async def log(self, request: Request, response: Response | None, action: str | None): 108 | """Log the request.""" 109 | 110 | def get_selected(self, request: Request) -> Iterable | None: 111 | """Get selected objects.""" 112 | return request.query.getall("ids", None) 113 | 114 | @classmethod 115 | def action( 116 | cls, 117 | *paths: str, 118 | methods: TMethods | None = None, 119 | icon: str | None = None, 120 | label: str | None = None, 121 | view: list[TActionView] | TActionView | None = None, 122 | schema: type[ma.Schema] | None = None, 123 | **opts, 124 | ): 125 | """Register an action for the handler. 126 | 127 | Decorate any function to use it as an action. 128 | 129 | :param path: Path to the action 130 | :param icon: Icon name 131 | :param label: Label for the action 132 | :param view: View name (list, show) 133 | """ 134 | 135 | def decorator(method): 136 | if schema is None: 137 | wrapper = method 138 | else: 139 | 140 | @wraps(method) 141 | async def wrapper(self, request: Request, **kwargs): 142 | try: 143 | raw_data = cast("dict", await request.json()) 144 | data = schema().load(raw_data) # type: ignore[] 145 | except ValueError: 146 | raise APIError.BAD_REQUEST("Invalid data") from None 147 | except ma.ValidationError as exc: 148 | raise APIError.BAD_REQUEST("Invalid data", errors=exc.messages) from None 149 | 150 | return await method(self, request, data=data, **kwargs) 151 | 152 | wrapper.__route__ = paths, methods # type: ignore[] 153 | wrapper.__action__ = { # type: ignore[] 154 | "view": [view] if isinstance(view, str) else view, 155 | "icon": icon, 156 | "paths": paths, 157 | "title": method.__doc__, 158 | "id": method.__name__, 159 | "label": label or " ".join(method.__name__.split("_")).capitalize(), 160 | **opts, 161 | } 162 | 163 | if schema: 164 | wrapper.__action__["schema"] = schema # type: ignore[] 165 | 166 | return wrapper 167 | 168 | return decorator 169 | 170 | @classmethod 171 | def to_ra(cls) -> dict[str, Any]: 172 | """Get JSON params for react-admin.""" 173 | meta = cls.meta 174 | 175 | actions = [] 176 | for source in meta.actions: 177 | info = dict(source) 178 | if info.get("schema"): 179 | _, inputs = cls.to_ra_schema(info["schema"], resource=False) 180 | info["schema"] = inputs 181 | actions.append(info) 182 | 183 | fields, inputs = cls.to_ra_schema(meta.Schema) # type: ignore[] 184 | fields_hash = { 185 | props["source"]: (ra_type, dict(props, sortable=props["source"] in meta.sorting)) 186 | for (ra_type, props) in fields 187 | } 188 | 189 | data = { 190 | "name": meta.name, 191 | "group": meta.group, 192 | "label": meta.label, 193 | "actions": actions, 194 | "help": meta.help, 195 | "icon": meta.icon, 196 | "delete": meta.delete, 197 | "create": meta.create and inputs, 198 | "key": meta.id, 199 | "list": { 200 | "create": meta.create, 201 | "remove": bool(meta.delete), 202 | "edit": bool(meta.edit), 203 | "limit": meta.limit, 204 | "limitMax": meta.limit_max, 205 | "limitTotal": meta.limit_total, 206 | "show": bool(meta.show), 207 | "fields": [fields_hash[name] for name in meta.columns if name in fields_hash], 208 | "filters": [cls.to_ra_filter(flt) for flt in meta.filters.mutations.values()], 209 | **meta.ra_list_params, 210 | }, 211 | "show": { 212 | "links": meta.ra_links, 213 | "edit": bool(meta.edit), 214 | "fields": fields, 215 | **meta.ra_show_params, 216 | }, 217 | "edit": ( 218 | meta.edit 219 | and { 220 | "inputs": inputs, 221 | "remove": meta.delete, 222 | } 223 | ), 224 | } 225 | 226 | default_sort = meta.sorting.default and meta.sorting.default[0] 227 | if default_sort: 228 | data["list"]["sort"] = { # type: ignore[call-overload, index] 229 | "field": default_sort.name, # type: ignore[] 230 | "order": default_sort.meta["default"].upper(), # type: ignore[] 231 | } 232 | 233 | return data 234 | 235 | @classmethod 236 | def to_ra_schema(cls, schema_cls: type[ma.Schema], *, resource: bool = True): 237 | meta = cls.meta 238 | schema_opts = schema_cls.opts 239 | schema_fields = schema_opts.fields 240 | schema_exclude = schema_opts.exclude 241 | schema_load_only = schema_opts.load_only 242 | schema_dump_only = schema_opts.dump_only 243 | 244 | fields_customize = meta.ra_fields 245 | inputs_customize = meta.ra_inputs 246 | fields = [] 247 | inputs = [] 248 | 249 | if not schema_fields: 250 | schema_fields = list(schema_cls._declared_fields.keys()) 251 | 252 | for name in schema_fields: 253 | field = schema_cls._declared_fields.get(name) 254 | 255 | if not field or name in schema_exclude: 256 | continue 257 | 258 | source = field.data_key or name 259 | if not field.load_only and name not in schema_load_only: 260 | field_info = ( 261 | fields_customize[source] 262 | if source in fields_customize 263 | else cls.to_ra_field(field, source) 264 | ) 265 | if isinstance(field_info, str): 266 | field_info = field_info, {} 267 | 268 | field_info[1].setdefault("source", source) 269 | fields.append(field_info) 270 | 271 | if not field.dump_only and name not in schema_dump_only: 272 | input_info = ( 273 | inputs_customize[source] 274 | if source in inputs_customize 275 | else cls.to_ra_input(field, source, resource=resource) 276 | ) 277 | if isinstance(input_info, str): 278 | input_info = input_info, {} 279 | 280 | input_info[1].setdefault("source", source) 281 | inputs.append(input_info) 282 | 283 | return cast("TRAInputs", fields), cast("TRAInputs", inputs) 284 | 285 | @classmethod 286 | def to_ra_field(cls, field: ma.fields.Field, source: str) -> TRAInfo: 287 | """Convert self schema field to ra field.""" 288 | refs = cls.meta.ra_refs 289 | if source in refs: 290 | ref_data = refs[source] 291 | return "FKField", { 292 | "refKey": ref_data.get("key") or "id", 293 | "refSource": ref_data.get("source") or source, 294 | "reference": ref_data.get("reference") or source, 295 | } 296 | 297 | return ma_to_ra(field, MA_TO_RAF) 298 | 299 | @classmethod 300 | def to_ra_filter(cls, flt: Filter) -> TRAInfo: 301 | props: TRAProps = {} 302 | 303 | meta = cls.meta 304 | source = flt.name 305 | 306 | if isinstance(flt.schema_field, ma.fields.Enum): 307 | ra_type, props = "SelectArrayInput", { 308 | "choices": [{"id": c.value, "name": c.name} for c in flt.schema_field.enum], 309 | } 310 | else: 311 | ra_type, props = cls.to_ra_input(flt.schema_field, source, resource=True) 312 | props.pop("required", None) 313 | 314 | props["source"] = source 315 | 316 | custom = meta.ra_filters 317 | if source in custom: 318 | ra_type, _props = custom[source] 319 | props = {**props, **_props} 320 | 321 | if source in meta.ra_filters_always_on: 322 | props["alwaysOn"] = True 323 | props["resettable"] = True 324 | 325 | return ra_type, props 326 | 327 | @classmethod 328 | def to_ra_input(cls, field: ma.fields.Field, source: str, *, resource: bool = True) -> TRAInfo: 329 | """Convert a field to react-admin.""" 330 | rtype, props = ma_to_ra(field, MA_TO_RAI) 331 | 332 | if isinstance(field.load_default, (bool, str, int)): 333 | props.setdefault("defaultValue", field.load_default) 334 | 335 | if field.required: 336 | props.setdefault("required", True) 337 | 338 | metadata = field.metadata 339 | desc = metadata.get("description") 340 | if desc: 341 | props.setdefault("helperText", desc) 342 | 343 | label = metadata.get("label") 344 | if label: 345 | props.setdefault("label", label) 346 | 347 | return rtype, props 348 | 349 | 350 | MA_TO_RAF: dict[type, TRAConverter] = { 351 | ma.fields.Boolean: lambda _: ("BooleanField", {}), 352 | ma.fields.Date: lambda _: ("DateField", {}), 353 | ma.fields.DateTime: lambda _: ("DateField", {"showTime": True}), 354 | ma.fields.Number: lambda _: ("NumberField", {}), 355 | ma.fields.Field: lambda _: ("TextField", {}), 356 | ma.fields.Email: lambda _: ("EmailField", {}), 357 | ma.fields.Url: lambda _: ("UrlField", {}), 358 | ma.fields.Enum: lambda field: ( 359 | "SelectField", 360 | {"choices": [{"id": choice.value, "name": choice.name} for choice in field.enum]}, # type: ignore[attr-defined] 361 | ), 362 | # Default 363 | object: lambda _: ("TextField", {}), 364 | } 365 | 366 | MA_TO_RAI: dict[type, TRAConverter] = { 367 | ma.fields.Boolean: lambda _: ("BooleanInput", {}), 368 | ma.fields.Date: lambda _: ("DateInput", {}), 369 | ma.fields.DateTime: lambda _: ("DateTimeInput", {}), 370 | ma.fields.Number: lambda _: ("NumberInput", {}), 371 | ma.fields.Field: lambda _: ("TextInput", {}), 372 | ma.fields.Enum: lambda field: ( 373 | "SelectInput", 374 | {"choices": [{"id": choice.value, "name": choice.name} for choice in field.enum]}, # type: ignore[attr-defined] 375 | ), 376 | # Default 377 | object: lambda _: ("TextField", {}), 378 | } 379 | 380 | # Support EnumField from muffin-rest 381 | MA_TO_RAI[EnumField] = MA_TO_RAI[ma.fields.Enum] 382 | 383 | 384 | def ma_to_ra(field: ma.fields.Field, types: dict[type, TRAConverter]) -> TRAInfo: 385 | """Convert a field to fa.""" 386 | converter = types[object] 387 | for fcls in type(field).mro(): 388 | if fcls in types: 389 | converter = types[fcls] 390 | break 391 | 392 | return converter(field) 393 | --------------------------------------------------------------------------------