├── .codecov.yml ├── .coveragerc ├── .flake8 ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── auto-merge.yml │ └── ci.yml ├── .gitignore ├── .mypy.ini ├── .pre-commit-config.yaml ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── admin-js ├── babel.config.js ├── jest.config.js ├── package.json ├── src │ ├── App.jsx │ └── admin.jsx ├── tests │ ├── permissions.test.js │ ├── relationships.test.js │ ├── setupTests.js │ └── simple.test.js └── vite.config.js ├── aiohttp_admin ├── __init__.py ├── backends │ ├── __init__.py │ ├── abc.py │ └── sqlalchemy.py ├── py.typed ├── routes.py ├── security.py ├── static │ └── favicon.svg ├── types.py └── views.py ├── docs ├── Makefile ├── README.md ├── admin_polls.png ├── api.rst ├── changelog.rst ├── conf.py ├── contents.rst.inc ├── contributing.rst ├── demo.gif ├── design.rst ├── diagram.svg ├── diagram2.svg └── index.rst ├── examples ├── demo │ ├── README │ ├── admin-js │ │ ├── craco.config.js │ │ ├── package.json │ │ ├── public │ │ │ └── index.html │ │ ├── shim │ │ │ ├── query-string │ │ │ │ └── index.js │ │ │ ├── react-admin │ │ │ │ └── index.js │ │ │ ├── react-dom │ │ │ │ └── index.js │ │ │ ├── react-router-dom │ │ │ │ └── index.js │ │ │ └── react │ │ │ │ ├── index.js │ │ │ │ └── jsx-runtime.js │ │ └── src │ │ │ └── index.js │ └── app.py ├── permissions.py ├── relationships.py ├── simple.py └── validators.py ├── pytest.ini ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── tests ├── _auth.py ├── _resources.py ├── conftest.py ├── test_admin.py ├── test_backends_abc.py ├── test_backends_sqlalchemy.py ├── test_security.py └── test_views.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | after_n_builds: 6 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | enable-extensions = G 3 | max-doc-length = 90 4 | max-line-length = 90 5 | select = A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,B901,B902,B903,B950 6 | # E226: Missing whitespace around arithmetic operators can help group things together. 7 | # E501: Superseeded by B950 (from Bugbear) 8 | # E722: Superseeded by B001 (from Bugbear) 9 | # W503: Mutually exclusive with W504. 10 | ignore = E226,E501,E722,W503 11 | per-file-ignores = 12 | # I900: Caused by awkward non-package imports. 13 | # S101: Pytest uses assert 14 | # S105: Examples, not real passwords 15 | tests/*:S101,I900,S105 16 | examples/*:I900,S105 17 | 18 | # flake8-import-order 19 | application-import-names = aiohttp_admin, conftest, _auth, _auth_helpers, _models, _resources 20 | import-order-style = pycharm 21 | 22 | # flake8-quotes 23 | inline-quotes = " 24 | # flake8-requirements 25 | requirements-file = requirements-dev.txt 26 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Dreamsorcerer 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | 8 | - package-ecosystem: npm 9 | directory: "/admin-js/" 10 | schedule: 11 | interval: daily 12 | groups: 13 | react-admin: 14 | patterns: 15 | - "create-react-admin" 16 | - "ra-*" 17 | - "react-admin" 18 | 19 | - package-ecosystem: "github-actions" 20 | directory: "/" 21 | schedule: 22 | interval: "monthly" 23 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request_target 3 | 4 | permissions: 5 | pull-requests: write 6 | contents: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2.4.0 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | run: gh pr merge --auto --squash "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - '[0-9].[0-9]+' # matches to backport branches, e.g. 3.6 8 | tags: [ 'v*' ] 9 | pull_request: 10 | branches: 11 | - master 12 | - '[0-9].[0-9]+' 13 | 14 | 15 | jobs: 16 | lint: 17 | name: Linter 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 5 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | - name: Setup Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.10' 27 | cache: 'pip' 28 | cache-dependency-path: '**/requirements*.txt' 29 | - name: Install dependencies 30 | uses: py-actions/py-dependency-install@v4 31 | with: 32 | path: requirements-dev.txt 33 | - name: Install itself 34 | run: | 35 | pip install . 36 | - name: Mypy 37 | run: mypy 38 | - name: Pre-Commit hooks 39 | uses: pre-commit/action@v3.0.1 40 | - name: Flake8 41 | run: flake8 42 | - name: Prepare twine checker 43 | run: | 44 | pip install -U build twine wheel 45 | python -m build 46 | - name: Run twine checker 47 | run: | 48 | twine check dist/* 49 | 50 | yarn_build: 51 | permissions: 52 | contents: read # to fetch code (actions/checkout) 53 | 54 | name: Build JS with Yarn 55 | runs-on: ubuntu-latest 56 | timeout-minutes: 5 57 | steps: 58 | - name: Checkout 59 | uses: actions/checkout@v4 60 | - name: Disable man-db to speed up apt 61 | run: | 62 | echo 'set man-db/auto-update false' | sudo debconf-communicate >/dev/null 63 | sudo dpkg-reconfigure man-db 64 | - name: Install yarn 65 | run: sudo apt install yarn -y 66 | - name: Get yarn cache dir 67 | run: echo "yarn_cache=$(yarn cache dir)" >> "$GITHUB_ENV" 68 | - name: Cache node modules 69 | uses: actions/cache@v4 70 | id: cache_node_modules 71 | with: 72 | key: node-${{ hashFiles('admin-js/package.json') }}-${{ github.run_id }} 73 | restore-keys: node-${{ hashFiles('admin-js/package.json') }} 74 | path: | 75 | ${{ env.yarn_cache }} 76 | admin-js/node_modules 77 | admin-js/yarn.lock 78 | - name: Yarn install 79 | if: steps.cache_node_modules.outputs.cache-hit != 'true' 80 | run: yarn install --production 81 | working-directory: admin-js/ 82 | - name: Cache output files 83 | uses: actions/cache@v4 84 | id: cache_admin_js 85 | with: 86 | key: yarn-${{ hashFiles('admin-js/src/*') }}-${{ hashFiles('admin-js/yarn.lock') }} 87 | path: | 88 | aiohttp_admin/static/admin.js 89 | aiohttp_admin/static/admin.js.map 90 | - name: Yarn build 91 | if: steps.cache_admin_js.outputs.cache-hit != 'true' 92 | run: yarn build --minify false 93 | working-directory: admin-js/ 94 | 95 | test: 96 | name: Test 97 | needs: yarn_build 98 | strategy: 99 | matrix: 100 | pyver: ['3.9', '3.10', '3.11', '3.12'] 101 | include: 102 | - pyver: pypy-3.9 103 | runs-on: ubuntu-latest 104 | timeout-minutes: 15 105 | steps: 106 | - name: Checkout 107 | uses: actions/checkout@v4 108 | - name: Setup Python ${{ matrix.pyver }} 109 | uses: actions/setup-python@v5 110 | with: 111 | allow-prereleases: true 112 | python-version: ${{ matrix.pyver }} 113 | cache: 'pip' 114 | cache-dependency-path: '**/requirements*.txt' 115 | - name: Restore cached JS files 116 | uses: actions/cache/restore@v4 117 | with: 118 | key: yarn-${{ hashFiles('admin-js/src/*') }} 119 | fail-on-cache-miss: true 120 | path: | 121 | aiohttp_admin/static/admin.js 122 | aiohttp_admin/static/admin.js.map 123 | - name: Install dependencies 124 | uses: py-actions/py-dependency-install@v4 125 | with: 126 | path: requirements.txt 127 | - name: Run unittests 128 | env: 129 | COLOR: 'yes' 130 | run: | 131 | pytest tests 132 | python -m coverage xml 133 | - name: Upload coverage 134 | uses: codecov/codecov-action@v5 135 | with: 136 | fail_ci_if_error: true 137 | files: ./coverage.xml 138 | flags: unit 139 | token: ${{ secrets.CODECOV_TOKEN }} 140 | 141 | test-integration: 142 | name: Integration Test 143 | needs: yarn_build 144 | runs-on: ubuntu-latest 145 | timeout-minutes: 15 146 | steps: 147 | - name: Checkout 148 | uses: actions/checkout@v4 149 | - name: Disable man-db to speed up apt 150 | run: | 151 | echo 'set man-db/auto-update false' | sudo debconf-communicate >/dev/null 152 | sudo dpkg-reconfigure man-db 153 | - name: Install yarn 154 | run: sudo apt install yarn -y 155 | - name: Get yarn cache dir 156 | run: echo "yarn_cache=$(yarn cache dir)" >> "$GITHUB_ENV" 157 | - name: Cache node modules 158 | uses: actions/cache@v4 159 | with: 160 | key: node-${{ hashFiles('admin-js/package.json') }}-${{ github.run_id }} 161 | restore-keys: node-${{ hashFiles('admin-js/package.json') }} 162 | path: | 163 | ${{ env.yarn_cache }} 164 | admin-js/node_modules 165 | - name: Yarn install 166 | run: yarn install 167 | working-directory: admin-js/ 168 | - name: Setup Python 169 | uses: actions/setup-python@v5 170 | with: 171 | python-version: 3.12 172 | cache: 'pip' 173 | cache-dependency-path: '**/requirements*.txt' 174 | - name: Restore cached JS files 175 | uses: actions/cache/restore@v4 176 | with: 177 | key: yarn-${{ hashFiles('admin-js/src/*') }} 178 | fail-on-cache-miss: true 179 | path: | 180 | aiohttp_admin/static/admin.js 181 | aiohttp_admin/static/admin.js.map 182 | - name: Install dependencies 183 | uses: py-actions/py-dependency-install@v4 184 | with: 185 | path: requirements.txt 186 | - name: Run tests 187 | run: yarn test --coverage 188 | working-directory: admin-js/ 189 | - name: Upload JS coverage 190 | uses: codecov/codecov-action@v5 191 | with: 192 | fail_ci_if_error: true 193 | directory: admin-js/coverage/ 194 | flags: js, integration 195 | token: ${{ secrets.CODECOV_TOKEN }} 196 | - name: Generate Python coverage 197 | run: python -m coverage xml 198 | - name: Upload Python coverage 199 | uses: codecov/codecov-action@v5 200 | with: 201 | fail_ci_if_error: true 202 | files: ./coverage.xml 203 | flags: integration 204 | token: ${{ secrets.CODECOV_TOKEN }} 205 | 206 | check: # This job does nothing and is only used for the branch protection 207 | if: always() 208 | 209 | needs: [lint, test, test-integration] 210 | 211 | runs-on: ubuntu-latest 212 | 213 | steps: 214 | - name: Decide whether the needed jobs succeeded or failed 215 | uses: re-actors/alls-green@release/v1 216 | with: 217 | jobs: ${{ toJSON(needs) }} 218 | 219 | deploy: 220 | name: Deploy 221 | environment: release 222 | runs-on: ubuntu-latest 223 | needs: [check] 224 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 225 | steps: 226 | - name: Checkout 227 | uses: actions/checkout@v4 228 | - name: Setup Python 229 | uses: actions/setup-python@v5 230 | with: 231 | python-version: 3.11 232 | - name: Disable man-db to speed up apt 233 | run: | 234 | echo 'set man-db/auto-update false' | sudo debconf-communicate >/dev/null 235 | sudo dpkg-reconfigure man-db 236 | - name: Install yarn 237 | run: sudo apt install yarn -y 238 | - name: Yarn install 239 | run: yarn install --production 240 | working-directory: admin-js/ 241 | - name: Yarn build 242 | run: yarn build --production 243 | working-directory: admin-js/ 244 | - name: Install dependencies 245 | run: 246 | python -m pip install -U pip wheel setuptools build twine 247 | - name: Build dists 248 | run: | 249 | python -m build 250 | - name: Make Release 251 | uses: aio-libs/create-release@v1.6.6 252 | with: 253 | changes_file: CHANGES.rst 254 | name: aiohttp-admin 255 | version_file: aiohttp_admin/__init__.py 256 | github_token: ${{ secrets.GITHUB_TOKEN }} 257 | pypi_token: ${{ secrets.PYPI_API_TOKEN }} 258 | dist_dir: dist 259 | fix_issue_regex: "`#(\\d+) `" 260 | fix_issue_repl: "(#\\1)" 261 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | 3 | # From yarn install 4 | admin-js/yarn.lock 5 | admin-js/node_modules/ 6 | examples/demo/admin-js/yarn.lock 7 | examples/demo/admin-js/node_modules/ 8 | # Generated by yarn build 9 | aiohttp_admin/static/admin.js 10 | aiohttp_admin/static/*.js.map 11 | examples/demo/static/admin.js 12 | examples/demo/static/*.js.map 13 | # coverage (when running pytest) 14 | .coverage 15 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files = aiohttp_admin, examples, tests 3 | check_untyped_defs = True 4 | follow_imports_for_stubs = True 5 | disallow_any_decorated = True 6 | disallow_any_generics = True 7 | disallow_any_unimported = True 8 | disallow_incomplete_defs = True 9 | disallow_subclassing_any = True 10 | disallow_untyped_calls = True 11 | disallow_untyped_decorators = True 12 | disallow_untyped_defs = True 13 | enable_error_code = ignore-without-code, possibly-undefined, redundant-expr, redundant-self, truthy-bool, truthy-iterable, unused-awaitable 14 | implicit_reexport = False 15 | no_implicit_optional = True 16 | pretty = True 17 | show_column_numbers = True 18 | show_error_codes = True 19 | strict_equality = True 20 | warn_incomplete_stub = True 21 | warn_redundant_casts = True 22 | warn_return_any = True 23 | warn_unreachable = True 24 | warn_unused_ignores = True 25 | 26 | [mypy-aiohttp_admin.backends.sqlalchemy] 27 | # We use Any for several parameters, causing a few of these errors. 28 | disallow_any_decorated = False 29 | 30 | [mypy-tests.*] 31 | disallow_any_decorated = False 32 | disallow_untyped_calls = False 33 | disallow_untyped_defs = False 34 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: 'v4.4.0' 4 | hooks: 5 | - id: check-merge-conflict 6 | - repo: https://github.com/asottile/yesqa 7 | rev: v1.5.0 8 | hooks: 9 | - id: yesqa 10 | additional_dependencies: ["flake8-bandit", "flake8-bugbear"] 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: 'v4.4.0' 13 | hooks: 14 | - id: end-of-file-fixer 15 | exclude: >- 16 | ^docs/[^/]*\.svg$ 17 | - id: requirements-txt-fixer 18 | - id: trailing-whitespace 19 | - id: file-contents-sorter 20 | files: | 21 | CONTRIBUTORS.txt| 22 | docs/spelling_wordlist.txt| 23 | .gitignore| 24 | .gitattributes 25 | - id: check-case-conflict 26 | - id: check-json 27 | - id: check-xml 28 | - id: check-executables-have-shebangs 29 | - id: check-toml 30 | - id: check-xml 31 | - id: check-yaml 32 | - id: debug-statements 33 | - id: check-added-large-files 34 | - id: check-symlinks 35 | - id: debug-statements 36 | - id: detect-aws-credentials 37 | args: ['--allow-missing-credentials'] 38 | - id: detect-private-key 39 | exclude: ^examples/ 40 | - repo: https://github.com/PyCQA/flake8 41 | rev: '6.1.0' 42 | hooks: 43 | - id: flake8 44 | exclude: "^docs/" 45 | - repo: https://github.com/asottile/pyupgrade 46 | rev: 'v3.3.1' 47 | hooks: 48 | - id: pyupgrade 49 | args: ['--py36-plus'] 50 | - repo: https://github.com/Lucas-C/pre-commit-hooks-markup 51 | rev: v1.0.1 52 | hooks: 53 | - id: rst-linter 54 | files: >- 55 | ^[^/]+[.]rst$ 56 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | CHANGES 3 | ======= 4 | 5 | .. towncrier release notes start 6 | 7 | 0.1.0a3 (2023-12-03) 8 | ==================== 9 | 10 | - Used ``AppKey`` with aiohttp 3.9. 11 | - Added Python 3.12 support. 12 | - Added support for dynamically loaded components. 13 | - Reverted a change which broke relationship fields. 14 | 15 | 0.1.0a2 (2023-10-02) 16 | ==================== 17 | 18 | - Added ``permission_for()`` to create sqlalchemy permissions programatically. 19 | - Added ``field_props`` and ``input_props`` to the schema to pass extra props to components. 20 | - Added support for more relationships (one-to-many, many-to-one etc.). 21 | - Added a ``js_module`` option to include custom functions. 22 | - Added ``comp()``, ``func()`` and ``regex()``. 23 | - Added ``show_actions`` to allow customising the show actions. 24 | - Set many additional props/validators from inspecting the SqlAlchemy models. 25 | - Migrated to Pydantic v2. 26 | - Fixed behaviour with dates and times. 27 | - Various minor improvements. 28 | 29 | 0.1.0a1 (2023-04-23) 30 | ==================== 31 | 32 | - Removed ``auth_policy`` parameter from ``setup()``, this is no longer needed. 33 | - Added a default ``identity_callback`` for simple applications, so it is no longer a required schema item. 34 | - Added ``Permissions.all`` enum value (which should replace ``tuple(Permissions)``). 35 | - Added validators to inputs (e.g. required, minValue etc. See examples/validators.py). 36 | - Added extensive permission controls (see examples/permissions.py). 37 | - Added ``admin["permission_re"]`` regex object to test if permission strings are valid. 38 | - Added buttons for the user to change visible columns in the list view. 39 | - Added initial support for ORM (1-to-many) relationships. 40 | - Added option to add simple bulk update buttons. 41 | - Added option to customise resource icons in sidebar. 42 | - Added option to customise admin title and resource labels. 43 | - Added support for non-id primary keys. 44 | - Added default favicon. 45 | - Included JS map file. 46 | - Fixed autocomplete behaviour in reference inputs (e.g. for foreign keys). 47 | - Fixed handling of date/datetime inputs. 48 | 49 | 0.1.0a0 (2023-02-27) 50 | ==================== 51 | 52 | - Migrated to react-admin and completely reinvented the API. 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2016 Nikolay Novik 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.rst 2 | include LICENSE 3 | include README.rst 4 | graft aiohttp_admin 5 | global-exclude *.pyc 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | aiohttp-admin 2 | ============= 3 | .. image:: https://codecov.io/gh/aio-libs/aiohttp-admin/branch/master/graph/badge.svg 4 | :target: https://codecov.io/gh/aio-libs/aiohttp-admin 5 | 6 | **aiohttp-admin** allows you to create a admin interface in minutes. It is designed to 7 | be flexible and database agnostic. 8 | 9 | It has built-in support for SQLAlchemy, allowing admin views to be created automatically 10 | from DB models (ORM or core). 11 | 12 | To see how to use the 0.1 versions, please refer to the examples. Documentation will be updated at a later date. 13 | 14 | Development 15 | ----------- 16 | 17 | To develop or build the project from source, you'll need to build the admin JS file:: 18 | 19 | cd admin-js/ 20 | yarn install 21 | yarn build 22 | 23 | After that, it can be treated as any other Python project. 24 | -------------------------------------------------------------------------------- /admin-js/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-env", 4 | ["@babel/preset-react", {runtime: "automatic"}], 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /admin-js/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | collectCoverageFrom: ["src/**", "tests/**"], 4 | errorOnDeprecated: true, 5 | maxWorkers: 1, 6 | resetMocks: true, 7 | restoreMocks: true, 8 | setupFilesAfterEnv: ["/tests/setupTests.js"], 9 | testEnvironment: "jsdom", 10 | testEnvironmentOptions: {"url": "http://localhost:8080", "pretendToBeVisual": true}, 11 | verbose: true, 12 | }; 13 | -------------------------------------------------------------------------------- /admin-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admin-js", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "18.2.0", 7 | "react-admin": "4.16.7", 8 | "react-dom": "18.2.0", 9 | "terser": "5.41.0", 10 | "vite": "6.3.5", 11 | 12 | "create-react-admin": "4.16.7", 13 | "ra-core": "4.16.7", 14 | "ra-data-fakerest": "4.16.7", 15 | "ra-data-graphql-simple": "4.16.7", 16 | "ra-data-graphql": "4.16.7", 17 | "ra-data-json-server": "4.16.7", 18 | "ra-data-local-forage": "4.16.7", 19 | "ra-data-local-storage": "4.16.7", 20 | "ra-data-simple-rest": "4.16.7", 21 | "ra-i18n-i18next": "4.16.7", 22 | "ra-i18n-polyglot": "4.16.7", 23 | "ra-input-rich-text": "4.16.7", 24 | "ra-language-english": "4.16.7", 25 | "ra-language-french": "4.16.7", 26 | "ra-no-code": "4.16.7", 27 | "ra-ui-materialui": "4.16.7" 28 | }, 29 | "devDependencies": { 30 | "@babel/preset-env": "7.27.2", 31 | "@babel/preset-react": "7.27.1", 32 | "@testing-library/dom": "10.4.0", 33 | "@testing-library/jest-dom": "6.6.3", 34 | "@testing-library/react": "16.3.0", 35 | "@testing-library/user-event": "14.6.1", 36 | "@ungap/structured-clone": "1.3.0", 37 | "jest": "29.7.0", 38 | "jest-environment-jsdom": "29.7.0", 39 | "jest-fail-on-console": "3.3.1", 40 | "whatwg-fetch": "3.6.20" 41 | }, 42 | "scripts": { 43 | "dev": "vite", 44 | "build": "vite build", 45 | "test": "jest" 46 | }, 47 | "eslintConfig": { 48 | "extends": [ 49 | "react-app", 50 | "react-app/jest" 51 | ], 52 | "rules": { 53 | "react/jsx-pascal-case": [1, {"allowLeadingUnderscore": true}] 54 | } 55 | }, 56 | "browserslist": { 57 | "production": [ 58 | ">0.2%", 59 | "not dead" 60 | ], 61 | "development": [ 62 | "last 1 chrome version", 63 | "last 1 firefox version", 64 | "last 1 safari version" 65 | ] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /admin-js/src/admin.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactJSXRuntime from "react/jsx-runtime"; 3 | import ReactDOM from "react-dom"; 4 | import ReactDOMClient from "react-dom/client"; 5 | import {Link, Route, useLocation, useNavigate, useParams} from 'react-router-dom'; 6 | import QueryString from 'query-string'; 7 | import {App} from "./App"; 8 | 9 | // Copy libraries to global location for shim. 10 | window.React = React; 11 | window.ReactJSXRuntime = ReactJSXRuntime; 12 | window.ReactDOM = ReactDOM; 13 | window.ReactDOMClient = ReactDOMClient; 14 | window.ReactRouterDOM = {Link, Route, useLocation, useNavigate, useParams}; 15 | window.QueryString = QueryString; 16 | 17 | const _body = document.querySelector("body"); 18 | const STATE = Object.freeze(JSON.parse(_body.dataset.state)); 19 | 20 | const root = ReactDOMClient.createRoot(document.getElementById("root")); 21 | root.render( 22 | 23 | 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /admin-js/tests/permissions.test.js: -------------------------------------------------------------------------------- 1 | import {within} from "@testing-library/dom"; 2 | import {screen, waitFor} from "@testing-library/react"; 3 | import userEvent from "@testing-library/user-event"; 4 | 5 | global.pythonProcessPath = "examples/permissions.py"; 6 | 7 | 8 | describe("admin", () => { 9 | beforeAll(() => setLogin("admin", "")); 10 | 11 | test("view", async () => { 12 | const table = await screen.findByRole("table"); 13 | const headers = within(table).getAllByRole("columnheader"); 14 | expect(headers.slice(1, -1).map((e) => e.textContent)).toEqual(["Id", "Num", "Optional Num"]); 15 | 16 | const rows = within(table).getAllByRole("row"); 17 | const firstCells = within(rows[1]).getAllByRole("cell").slice(1, -1); 18 | expect(firstCells.map((e) => e.textContent)).toEqual(["1", "5", ""]); 19 | const secondCells = within(rows[2]).getAllByRole("cell").slice(1, -1); 20 | expect(secondCells.map((e) => e.textContent)).toEqual(["2", "82", "12"]); 21 | }); 22 | }); 23 | 24 | describe("filter", () => { 25 | beforeAll(() => setLogin("filter", "")); 26 | 27 | test("view", async () => { 28 | const table = await screen.findByRole("table"); 29 | const rows = within(table).getAllByRole("row").slice(1); 30 | expect(rows).toHaveLength(5); 31 | expect(rows.map(r => within(r).getAllByRole("cell")[1].textContent)).toEqual(["1", "3", "4", "5", "6"]); 32 | 33 | await userEvent.click(screen.getByRole("link", {"name": "Create"})); 34 | await waitFor(() => screen.getByText("Create Simple")); 35 | const num = screen.getByLabelText("Num *"); 36 | expect(num).toHaveAttribute("aria-disabled", "true"); 37 | expect(num).toHaveTextContent("5"); 38 | }); 39 | }); 40 | 41 | describe("admin", () => { 42 | beforeAll(() => setLogin("admin", "")); 43 | 44 | test("bulk update", async () => { 45 | const container = await screen.findByRole("columnheader", {"name": "Select all"}); 46 | const selectAll = within(container).getByRole("checkbox"); 47 | await userEvent.click(selectAll); 48 | expect(selectAll).toBeChecked(); 49 | await userEvent.click(await screen.findByRole("button", {"name": "Set to 7"})); 50 | expect(await screen.findByText("Update 6 simples")).toBeInTheDocument(); 51 | await userEvent.click(screen.getByRole("button", {"name": "Confirm"})); 52 | return; // Broken now 53 | await waitFor(() => screen.getAllByText("7")); 54 | 55 | const table = await screen.findByRole("table"); 56 | const rows = within(table).getAllByRole("row"); 57 | const firstCells = within(rows[1]).getAllByRole("cell"); 58 | const secondCells = within(rows[2]).getAllByRole("cell"); 59 | expect(firstCells[3]).toHaveTextContent("7"); 60 | expect(secondCells[3]).toHaveTextContent("7"); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /admin-js/tests/relationships.test.js: -------------------------------------------------------------------------------- 1 | import {within} from "@testing-library/dom"; 2 | import {screen, waitFor} from "@testing-library/react"; 3 | import userEvent from "@testing-library/user-event"; 4 | 5 | global.pythonProcessPath = "examples/relationships.py"; 6 | 7 | 8 | test("datagrid works", async () => { 9 | const table = await screen.findByRole("table"); 10 | await userEvent.click(screen.getByRole("button", {"name": "Columns"})); 11 | await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Children")); 12 | await userEvent.keyboard("[Escape]"); 13 | 14 | const grid = await within(table).findByRole("table"); 15 | await sleep(0.1); 16 | const childHeaders = within(grid).getAllByRole("columnheader"); 17 | expect(childHeaders.slice(1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]); 18 | const childRows = within(grid).getAllByRole("row"); 19 | expect(childRows.length).toBe(3); 20 | const firstCells = within(childRows[1]).getAllByRole("cell"); 21 | expect(firstCells.slice(1).map((e) => e.textContent)).toEqual(["2", "Child Bar", "5"]); 22 | const secondCells = within(childRows[2]).getAllByRole("cell"); 23 | expect(secondCells.slice(1).map((e) => e.textContent)).toEqual(["1", "Child Foo", "1"]); 24 | 25 | const h = within(grid).getByRole("columnheader", {"name": "Select all"}); 26 | const check = within(h).getByRole("checkbox"); 27 | await userEvent.click(check); 28 | expect(check).toBeChecked(); 29 | // Check page hasn't redirected to show view. 30 | expect(location.href).toMatch(/\/onetomany_parent$/); 31 | }); 32 | 33 | test("onetomany child displays", async () => { 34 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 35 | await userEvent.click(await screen.findByText("Onetomany children")); 36 | 37 | await waitFor(() => screen.getByRole("heading", {"name": "Onetomany children"})); 38 | await sleep(1); 39 | 40 | await userEvent.click(screen.getByRole("button", {"name": "Columns"})); 41 | // TODO: Remove when fixed: https://github.com/marmelab/react-admin/issues/9587 42 | await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Parent Id")); 43 | await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Parent")); 44 | await userEvent.keyboard("[Escape]"); 45 | 46 | const table = screen.getAllByRole("table")[0]; 47 | const headers = within(table.querySelector("thead")).getAllByRole("columnheader"); 48 | expect(headers.slice(1, -1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value", "Parent Id", "Parent"]); 49 | 50 | const rows = within(table).getAllByRole("row").filter((e) => e.parentElement.parentElement === table); 51 | const firstCells = within(rows[1]).getAllByRole("cell").filter((e) => e.parentElement === rows[1]); 52 | expect(firstCells.slice(1, -2).map((e) => e.textContent)).toEqual(["1", "Child Foo", "1", "Bar"]); 53 | const secondCells = within(rows[2]).getAllByRole("cell").filter((e) => e.parentElement === rows[2]); 54 | expect(secondCells.slice(1, -2).map((e) => e.textContent)).toEqual(["2", "Child Bar", "5", "Bar"]); 55 | 56 | const grid = within(firstCells.at(-2)).getByRole("table"); 57 | const childHeaders = within(grid).getAllByRole("columnheader"); 58 | expect(childHeaders.map((e) => e.textContent)).toEqual(["Name", "Value"]); 59 | const childRows = within(grid).getAllByRole("row"); 60 | expect(childRows.length).toBe(2); 61 | const childCells = within(childRows[1]).getAllByRole("cell"); 62 | expect(childCells.map((e) => e.textContent)).toEqual(["Bar", "2"]); 63 | }); 64 | 65 | test("onetoone parents display", async () => { 66 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 67 | await userEvent.click(await screen.findByText("Onetoone parents")); 68 | 69 | await waitFor(() => screen.getByRole("heading", {"name": "Onetoone parents"})); 70 | await sleep(1); 71 | 72 | const table = screen.getByRole("table"); 73 | await userEvent.click(within(table).getAllByRole("row")[1]); 74 | 75 | await waitFor(() => screen.getByRole("heading", {"name": "Onetoone parent Foo"})); 76 | const grid = await screen.findByRole("table"); 77 | const childHeaders = within(grid).getAllByRole("columnheader"); 78 | expect(childHeaders.map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]); 79 | const childRows = within(grid).getAllByRole("row"); 80 | expect(childRows.length).toBe(2); 81 | const childCells = within(childRows[1]).getAllByRole("cell"); 82 | expect(childCells.map((e) => e.textContent)).toEqual(["2", "Child Bar", "2"]); 83 | }); 84 | 85 | test("manytomany left displays", async () => { 86 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 87 | await userEvent.click(await screen.findByText("Manytomany lefts")); 88 | 89 | await waitFor(() => screen.getByRole("heading", {"name": "Manytomany lefts"})); 90 | await sleep(1); 91 | 92 | await userEvent.click(screen.getByRole("button", {"name": "Columns"})); 93 | // TODO: Remove when fixed: https://github.com/marmelab/react-admin/issues/9587 94 | await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Children")); 95 | await userEvent.keyboard("[Escape]"); 96 | 97 | const table = screen.getAllByRole("table")[0]; 98 | const headers = within(table.querySelector("thead")).getAllByRole("columnheader"); 99 | expect(headers.slice(1, -1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value", "Children"]); 100 | 101 | const rows = within(table).getAllByRole("row").filter((e) => e.parentElement.parentElement === table); 102 | const firstCells = within(rows[1]).getAllByRole("cell").filter((e) => e.parentElement === rows[1]); 103 | expect(firstCells.slice(1, -2).map((e) => e.textContent)).toEqual(["1", "Foo", "2"]); 104 | const secondCells = within(rows[2]).getAllByRole("cell").filter((e) => e.parentElement === rows[2]); 105 | expect(secondCells.slice(1, -2).map((e) => e.textContent)).toEqual(["2", "Bar", "3"]); 106 | 107 | const firstGrid = await within(firstCells.at(-2)).findByRole("table"); 108 | const firstHeaders = within(firstGrid).getAllByRole("columnheader"); 109 | await waitFor(() => firstHeaders[1].textContent.trim() != ""); 110 | expect(firstHeaders.slice(1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]); 111 | const firstRows = within(firstGrid).getAllByRole("row"); 112 | expect(firstRows.length).toBe(3); 113 | let cells = within(firstRows[1]).getAllByRole("cell"); 114 | expect(cells.slice(1).map((e) => e.textContent)).toEqual(["3", "Bar Child", "6"]); 115 | cells = within(firstRows[2]).getAllByRole("cell"); 116 | expect(cells.slice(1).map((e) => e.textContent)).toEqual(["1", "Foo Child", "5"]); 117 | 118 | const secondGrid = within(secondCells.at(-2)).getByRole("table"); 119 | const secondHeaders = within(secondGrid).getAllByRole("columnheader"); 120 | await waitFor(() => secondHeaders[1].textContent.trim() != ""); 121 | expect(secondHeaders.slice(1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]); 122 | const secondRows = within(secondGrid).getAllByRole("row"); 123 | expect(secondRows.length).toBe(4); 124 | cells = within(secondRows[1]).getAllByRole("cell"); 125 | expect(cells.slice(1).map((e) => e.textContent)).toEqual(["3", "Bar Child", "6"]); 126 | cells = within(secondRows[2]).getAllByRole("cell"); 127 | expect(cells.slice(1).map((e) => e.textContent)).toEqual(["2", "Baz Child", "7"]); 128 | cells = within(secondRows[3]).getAllByRole("cell"); 129 | expect(cells.slice(1).map((e) => e.textContent)).toEqual(["1", "Foo Child", "5"]); 130 | }); 131 | 132 | test("composite foreign key child displays table", async () => { 133 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 134 | await userEvent.click(await screen.findByText("Composite foreign key children")); 135 | 136 | await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key children"})); 137 | await userEvent.click(screen.getByRole("button", {"name": "Columns"})); 138 | await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Parents")); 139 | await userEvent.keyboard("[Escape]"); 140 | await sleep(0.5); 141 | const table = screen.getAllByRole("table")[0]; 142 | const rows = within(table).getAllByRole("row").filter((e) => e.parentElement.parentElement === table); 143 | const cells = within(rows[2]).getAllByRole("cell").filter((e) => e.parentElement === rows[2]); 144 | expect(cells.slice(1, -2).map((e) => e.textContent)).toEqual(["0", "1", "B"]); 145 | expect(within(rows[1]).getAllByRole("cell").at(-2)).toHaveTextContent("No results found"); 146 | 147 | const grid = within(cells.at(-2)).getByRole("table"); 148 | const childHeaders = within(grid).getAllByRole("columnheader"); 149 | expect(childHeaders.slice(1).map((e) => e.textContent)).toEqual(["Item Id", "Item Name"]); 150 | const childRows = within(grid).getAllByRole("row"); 151 | expect(childRows.length).toBe(2); 152 | const childCells = within(childRows[1]).getAllByRole("cell"); 153 | expect(childCells.slice(1).map((e) => e.textContent)).toEqual(["1", "Foo"]); 154 | }); 155 | 156 | test("composite foreign key parent displays", async () => { 157 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 158 | await userEvent.click(await screen.findByText("Composite foreign key parents")); 159 | 160 | await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key parents"})); 161 | await sleep(1); 162 | const table = screen.getAllByRole("table")[0]; 163 | const rows = within(table).getAllByRole("row").filter((e) => e.parentElement.parentElement === table); 164 | const cells = within(rows[1]).getAllByRole("cell").filter((e) => e.parentElement === rows[1]); 165 | expect(cells.slice(1, -1).map((e) => e.textContent)).toEqual(["1", "Foo"]); 166 | await userEvent.click(rows[1]); 167 | 168 | await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key parent"})); 169 | const main = screen.getByRole("main"); 170 | expect((await within(main).findAllByRole("link", {"name": "B"}))[0]).toHaveTextContent("B"); 171 | 172 | const grid = within(main).getByRole("table"); 173 | const headers = within(grid).getAllByRole("columnheader"); 174 | expect(headers.map((e) => e.textContent)).toEqual(["Description"]); 175 | const childRows = within(grid).getAllByRole("row"); 176 | expect(childRows.length).toBe(2); 177 | const childCells = within(childRows[1]).getAllByRole("cell"); 178 | expect(childCells.map((e) => e.textContent)).toEqual(["B"]); 179 | }); 180 | 181 | test("composite foreign key reference input updates", async () => { 182 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 183 | await userEvent.click(await screen.findByText("Composite foreign key parents")); 184 | 185 | await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key parents"})); 186 | const table = screen.getAllByRole("table")[0]; 187 | const rows = within(table).getAllByRole("row").filter((e) => e.parentElement.parentElement === table); 188 | await userEvent.click(within(rows[1]).getByRole("link", {"name": "Edit"})); 189 | 190 | await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key parent"})); 191 | // TODO: identifiers are screwed up for these inputs. 192 | const referenceInput = await screen.findByRole("combobox", {"name": "Child Id Ref Num"}); 193 | const referenceInput2 = await screen.findByRole("combobox", {"name": ""}); 194 | await waitFor(() => expect(referenceInput).not.toHaveValue("")); 195 | expect(referenceInput).toHaveValue("B"); 196 | expect(referenceInput2).toHaveValue("B"); 197 | await userEvent.click(within(referenceInput.parentElement).getByRole("button", {"name": "Open"})); 198 | 199 | const popup = await screen.findByRole("presentation"); 200 | const options = within(popup).getAllByRole("option"); 201 | expect(options.map(e => e.textContent)).toEqual(["A", "C"]); 202 | await userEvent.click(within(popup).getByRole("option", {"name": "A"})); 203 | 204 | expect(referenceInput).toHaveValue("A"); 205 | expect(referenceInput2).toHaveValue("A"); 206 | await userEvent.click(screen.getByRole("button", {"name": "Save"})); 207 | 208 | await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key parents"})); 209 | await userEvent.click(screen.getByRole("button", {"name": "Columns"})); 210 | await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Ref Num")); 211 | await userEvent.keyboard("[Escape]"); 212 | await sleep(1); 213 | const table2 = screen.getAllByRole("table")[0]; 214 | const rows2 = within(table2).getAllByRole("row").filter((e) => e.parentElement.parentElement === table2); 215 | const cells = within(rows2[1]).getAllByRole("cell").filter((e) => e.parentElement === rows2[1]); 216 | expect(cells.at(-2)).toHaveTextContent("A"); 217 | }); 218 | -------------------------------------------------------------------------------- /admin-js/tests/setupTests.js: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const {spawn} = require("child_process"); 3 | import "whatwg-fetch"; // https://github.com/jsdom/jsdom/issues/1724 4 | import "@testing-library/jest-dom"; 5 | import failOnConsole from "jest-fail-on-console"; 6 | import {memoryStore} from "react-admin"; 7 | import {configure, render, screen} from "@testing-library/react"; 8 | import * as structuredClone from "@ungap/structured-clone"; 9 | 10 | const {App} = require("../src/App"); 11 | 12 | let pythonProcess; 13 | let STATE; 14 | 15 | jest.setTimeout(300000); // 5 mins 16 | configure({"asyncUtilTimeout": 10000}); 17 | jest.mock("react-admin", () => { 18 | const originalModule = jest.requireActual("react-admin"); 19 | return { 20 | ...originalModule, 21 | downloadCSV: jest.fn(), // Mock downloadCSV to test export button. 22 | }; 23 | }); 24 | 25 | // https://github.com/jsdom/jsdom/issues/3363#issuecomment-1387439541 26 | global.structuredClone = structuredClone.default; 27 | 28 | // To render full-width 29 | window.matchMedia = (query) => ({ 30 | matches: true, 31 | addListener: () => {}, 32 | removeListener: () => {} 33 | }); 34 | 35 | // Ignore not implemented errors 36 | window.scrollTo = jest.fn(); 37 | 38 | 39 | global.sleep = (delay_s) => new Promise((resolve) => setTimeout(resolve, delay_s * 1000)); 40 | 41 | failOnConsole({ 42 | silenceMessage: (msg) => { 43 | return ( 44 | // Suppress act() warnings, because there's too many async changes happening. 45 | msg.includes("inside a test was not wrapped in act(...).") 46 | // Error in react-admin which doesn't actually break anything. 47 | // https://github.com/marmelab/react-admin/issues/8849 48 | || msg.includes("Fetched record's id attribute") 49 | || msg.includes("The above error occurred in the component") 50 | ); 51 | } 52 | }); 53 | 54 | beforeAll(async() => { 55 | if (!global.pythonProcessPath) 56 | return; 57 | 58 | if (global.__coverage__) 59 | pythonProcess = spawn("coverage", ["run", "--append", "--source=examples/,aiohttp_admin/", global.pythonProcessPath], {"cwd": ".."}); 60 | else 61 | pythonProcess = spawn("python3", ["-u", global.pythonProcessPath], {"cwd": ".."}); 62 | 63 | pythonProcess.stderr.on("data", (data) => {console.error(`stderr: ${data}`);}); 64 | //pythonProcess.stdout.on("data", (data) => {console.log(`stdout: ${data}`);}); 65 | 66 | // Wait till server accepts requests 67 | await new Promise(resolve => { 68 | const cutoff = Date.now() + 10000; 69 | function alive() { 70 | http.get("http://localhost:8080/", resolve).on("error", e => Date.now() < cutoff && setTimeout(alive, 100)); 71 | } 72 | alive(); 73 | }); 74 | 75 | await new Promise(resolve => { 76 | http.get("http://localhost:8080/admin", resp => { 77 | if (resp.statusCode !== 200) 78 | throw new Error("Request failed"); 79 | 80 | let html = ""; 81 | resp.on("data", (chunk) => { html += chunk; }); 82 | resp.on("end", () => { 83 | const parser = new DOMParser(); 84 | const doc = parser.parseFromString(html, "text/html"); 85 | STATE = JSON.parse(doc.querySelector("body").dataset.state); 86 | resolve(); 87 | }); 88 | }); 89 | }); 90 | }, 10000); 91 | 92 | afterAll(() => { 93 | if (pythonProcess) 94 | pythonProcess.kill("SIGINT"); 95 | }); 96 | 97 | 98 | let login = {"username": "admin", "password": "admin"}; 99 | global.setLogin = (username, password) => { login = {username, password}; }; 100 | 101 | beforeEach(async () => { 102 | location.href = "/"; 103 | localStorage.clear(); 104 | 105 | if (STATE) { 106 | const resp = await fetch("http://localhost:8080/admin/token", {"method": "POST", "body": JSON.stringify(login)}); 107 | localStorage.setItem("identity", resp.headers.get("X-Token")); 108 | render(); 109 | const profile = await screen.findByText(login["username"], {"exact": false}); 110 | expect(profile).toHaveAccessibleName("Profile"); 111 | } 112 | }); 113 | -------------------------------------------------------------------------------- /admin-js/tests/simple.test.js: -------------------------------------------------------------------------------- 1 | import {within} from "@testing-library/dom"; 2 | import {screen, waitFor} from "@testing-library/react"; 3 | import userEvent from "@testing-library/user-event"; 4 | import {downloadCSV as mockDownloadCSV} from "react-admin"; 5 | 6 | global.pythonProcessPath = "examples/simple.py"; 7 | 8 | 9 | test("login works", async () => { 10 | await userEvent.click(screen.getByRole("button", {"name": "Profile"})); 11 | await userEvent.click(await screen.findByRole("menuitem", {"name": "Logout"})); 12 | 13 | await userEvent.type(await screen.findByLabelText(/Username/), "admin"); 14 | await userEvent.type(screen.getByLabelText(/Password/), "admin"); 15 | await userEvent.click(screen.getByRole("button", {"name": "Sign in"})); 16 | 17 | expect(await screen.findByText("Admin user")).toBeInTheDocument(); 18 | }); 19 | 20 | test("data is displayed", async () => { 21 | const table = await screen.findByRole("table"); 22 | const headers = within(table).getAllByRole("columnheader"); 23 | expect(headers.slice(1, -1).map((e) => e.textContent)).toEqual(["Id", "Num", "Optional Num", "Value"]); 24 | 25 | const rows = within(table).getAllByRole("row"); 26 | const firstCells = within(rows[1]).getAllByRole("cell").slice(1, -1); 27 | expect(firstCells.map((e) => e.textContent)).toEqual(["1", "5", "", "first"]); 28 | const secondCells = within(rows[2]).getAllByRole("cell").slice(1, -1); 29 | expect(secondCells.map((e) => e.textContent)).toEqual(["2", "82", "12", "with child"]); 30 | }); 31 | 32 | test("parents are displayed", async () => { 33 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 34 | await userEvent.click(await screen.findByText("Parents")); 35 | 36 | await waitFor(() => screen.getByText("USD")); 37 | const table = screen.getByRole("table"); 38 | const headers = within(table).getAllByRole("columnheader"); 39 | expect(headers.slice(1, -1).map((e) => e.textContent)).toEqual(["Id", "Date", "Currency"]); 40 | 41 | const rows = within(table).getAllByRole("row"); 42 | const firstCells = within(rows[1]).getAllByRole("cell").slice(1, -1); 43 | expect(firstCells.map((e) => e.textContent)).toEqual(["with child", "2/13/2023, 7:04:00 PM", "USD"]); 44 | expect(within(firstCells[0]).getByRole("link")).toBeInTheDocument(); 45 | }); 46 | 47 | test("filter labels are correct", async () => { 48 | const main = await screen.findByRole("main"); 49 | const quickSearch = main.querySelector("form"); 50 | const labels = quickSearch.querySelectorAll("label"); 51 | expect(Array.from(labels).map((e) => e.textContent)).toEqual(["Id", "Num", "Optional Num", "Value"]); 52 | }); 53 | 54 | test("parent filter labels are correct", async () => { 55 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 56 | await userEvent.click(await screen.findByText("Parents")); 57 | 58 | await waitFor(() => screen.getByText("USD")); 59 | const main = await screen.findByRole("main"); 60 | const quickSearch = main.querySelector("form"); 61 | const labels = quickSearch.querySelectorAll("label"); 62 | expect(Array.from(labels).map((e) => e.textContent)).toEqual(["Id", "Date", "Currency"]); 63 | }); 64 | 65 | test("filters work", async () => { 66 | const main = await screen.findByRole("main"); 67 | const quickSearch = main.querySelector("form"); 68 | const table = await within(main).findByRole("table"); 69 | let rows = within(table).getAllByRole("row"); 70 | expect(rows.length).toBeGreaterThan(2); 71 | return; // Broken now 72 | await userEvent.type(within(quickSearch).getByRole("spinbutton", {"name": "Id"}), "1"); 73 | 74 | await waitFor(() => within(main).getByRole("button", {"name": "Add filter"})); 75 | await sleep(0.5); 76 | rows = within(table).getAllByRole("row"); 77 | expect(rows.length).toBe(2); 78 | expect(within(rows[1]).getAllByRole("cell")[1]).toHaveTextContent("1"); 79 | }); 80 | 81 | test("enum filter works", async () => { 82 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 83 | await userEvent.click(await screen.findByText("Parents")); 84 | await waitFor(() => screen.getByText("USD")); 85 | 86 | const main = screen.getByRole("main"); 87 | const quickSearch = main.querySelector("form"); 88 | const table = within(main).getByRole("table"); 89 | const currencySelect = within(quickSearch).getByRole("combobox", {"name": "Currency"}); 90 | expect(within(table).getAllByRole("row").length).toBe(2); 91 | const record = within(table).getAllByRole("row")[1]; 92 | await userEvent.click(currencySelect); 93 | return; // Broken now 94 | await userEvent.click(await screen.findByRole("option", {"name": "GBP"})); 95 | 96 | expect(await within(main).findByText("No results found")).toBeInTheDocument(); 97 | expect(within(main).queryByRole("table")).not.toBeInTheDocument(); 98 | 99 | await userEvent.click(currencySelect); 100 | await userEvent.click(await screen.findByRole("option", {"name": "USD"})); 101 | 102 | await waitFor(() => expect(within(main).getByRole("table")).toBeInTheDocument()); 103 | const rows = within(within(main).getByRole("table")).getAllByRole("row"); 104 | expect(currencySelect).toHaveTextContent("USD"); 105 | expect(rows.length).toBe(2); 106 | expect(within(rows[1]).getByText("USD")).toBeInTheDocument(); 107 | }); 108 | 109 | test("edit form", async () => { 110 | await userEvent.click((await screen.findAllByLabelText("Edit"))[0]); 111 | await waitFor(() => screen.getByRole("link", {"name": "List"})); 112 | 113 | const main = screen.getByRole("main"); 114 | const edit = main.querySelector("form"); 115 | const id = within(edit).getByLabelText("Id *"); 116 | //expect(id).toBeRequired(); 117 | expect(id).toHaveValue(1); 118 | const num = within(edit).getByLabelText("Num *"); 119 | //expect(num).toBeRequired(); 120 | expect(num).toHaveValue(5); 121 | const opt = within(edit).getByLabelText("Optional Num"); 122 | expect(opt).not.toBeRequired(); 123 | expect(opt).toHaveValue(null); 124 | const value = within(edit).getByLabelText("Value *"); 125 | //expect(value).toBeRequired(); 126 | expect(value).toHaveValue("first"); 127 | }); 128 | 129 | test("reference input label", async () => { 130 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 131 | await userEvent.click(await screen.findByText("Parents")); 132 | 133 | await waitFor(() => screen.getByText("USD")); 134 | await userEvent.click(screen.getAllByLabelText("Edit")[0]); 135 | await waitFor(() => screen.getByRole("link", {"name": "List"})); 136 | 137 | const main = screen.getByRole("main"); 138 | const edit = main.querySelector("form"); 139 | expect(within(edit).getByLabelText("Id *")).toHaveValue("with child"); 140 | }); 141 | 142 | test("reference input filter", async () => { 143 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 144 | await userEvent.click(await screen.findByText("Parents")); 145 | await waitFor(() => screen.getByText("USD")); 146 | 147 | const main = screen.getByRole("main"); 148 | const quickSearch = main.querySelector("form"); 149 | const input = within(quickSearch).getByRole("combobox", {"name": "Id"}); 150 | const table = within(main).getByRole("table"); 151 | expect(within(table).getAllByRole("row").length).toBe(2); 152 | await userEvent.click(within(input.parentElement).getByRole("button", {"name": "Open"})); 153 | const resultsInitial = await screen.findByRole("listbox", {"name": "Id"}); 154 | const optionsInitial = within(resultsInitial).getAllByRole("option"); 155 | expect(optionsInitial.map(e => e.textContent)).toEqual(["first", "with child"]); 156 | 157 | return; // Broken now 158 | await userEvent.click(within(resultsInitial).getByRole("option", {"name": "first"})); 159 | await waitFor(() => expect(screen.queryByText("USD")).not.toBeInTheDocument()); 160 | expect(await within(main).findByText("No results found")).toBeInTheDocument(); 161 | 162 | await userEvent.type(input, "w"); 163 | const resultsFiltered = await screen.findByRole("listbox", {"name": "Id"}); 164 | const optionsFiltered = within(resultsFiltered).getAllByRole("option"); 165 | expect(optionsFiltered.map(e => e.textContent)).toEqual(["with child"]); 166 | 167 | await userEvent.click(within(resultsFiltered).getByRole("option", {"name": "with child"})); 168 | await waitFor(() => expect(screen.queryByText("USD")).toBeInTheDocument()); 169 | expect(within(table).getAllByRole("row").length).toBe(2); 170 | }); 171 | 172 | test("export works", async () => { 173 | await userEvent.click(await screen.findByRole("button", {"name": "Export"})); 174 | await waitFor(() => expect(mockDownloadCSV).toHaveBeenCalled()); 175 | 176 | const csv = "id,num,optional_num,value\n1,5,,first\n2,82,12,with child"; 177 | expect(mockDownloadCSV).toHaveBeenCalledWith(csv, "simple"); 178 | }); 179 | 180 | test("create form works", async () => { 181 | await userEvent.click(await screen.findByLabelText("Create")); 182 | await waitFor(() => screen.getByRole("heading", {"name": "Create Simple"})); 183 | 184 | await userEvent.type(screen.getByLabelText("Num *"), "12"); 185 | await userEvent.type(screen.getByLabelText("Value *"), "Foo"); 186 | await userEvent.click(screen.getByRole("button", {"name": "Save"})); 187 | 188 | const main = await screen.findByRole("main"); 189 | expect(await within(main).findByText("3")).toBeInTheDocument(); 190 | expect(within(main).getByText("12")).toBeInTheDocument(); 191 | expect(within(main).getByText("Foo")).toBeInTheDocument(); 192 | }); 193 | 194 | test("edit submit", async () => { 195 | await userEvent.click((await screen.findAllByLabelText("Edit"))[0]); 196 | await waitFor(() => screen.getByRole("link", {"name": "List"})); 197 | const form = screen.getByRole("main").querySelector("form"); 198 | expect(within(form).getByLabelText("Id *")).toHaveValue(1); 199 | 200 | await userEvent.type(within(form).getByLabelText("Id *"), "3"); 201 | await userEvent.type(within(form).getByLabelText("Num *"), "7"); 202 | await userEvent.click(within(form).getByRole("button", {"name": "Save"})); 203 | 204 | const table = await screen.findByRole("table"); 205 | await sleep(0.2); 206 | const rows = within(table).getAllByRole("row"); 207 | const cells = within(rows.at(-1)).getAllByRole("cell").slice(1, -1); 208 | expect(cells.map((e) => e.textContent)).toEqual(["13", "57", "", "first"]); 209 | 210 | expect(within(table).queryByText("1")).not.toBeInTheDocument(); 211 | }); 212 | 213 | test("reference input edit", async () => { 214 | await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); 215 | await userEvent.click(await screen.findByText("Parents")); 216 | await waitFor(() => screen.getByText("USD")); 217 | 218 | await userEvent.click(screen.getAllByLabelText("Edit")[0]); 219 | await waitFor(() => screen.getByRole("link", {"name": "List"})); 220 | const form = screen.getByRole("main").querySelector("form"); 221 | const idInput = within(form).getByLabelText(/Id/); 222 | expect(idInput).toHaveValue("with child"); 223 | 224 | await userEvent.click(within(idInput.parentElement).getByRole("button", {"name": "Open"})); 225 | const results = await screen.findByRole("listbox", {"name": "Id"}); 226 | await userEvent.click(await within(results).findByRole("option", {"name": "first"})); 227 | expect(idInput).toHaveValue("first"); 228 | await userEvent.click(within(form).getByRole("button", {"name": "Save"})); 229 | 230 | const table = await screen.findByRole("table"); 231 | await sleep(0.2); 232 | const rows = within(table).getAllByRole("row"); 233 | expect(rows.length).toEqual(2); 234 | const idCell = within(rows[1]).getAllByRole("cell")[1]; 235 | expect(idCell).toHaveTextContent("first"); 236 | }); 237 | -------------------------------------------------------------------------------- /admin-js/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | build: { 5 | minify: "terser", 6 | outDir: "../aiohttp_admin/static/", 7 | rollupOptions: { 8 | input: "src/admin.jsx", 9 | output: { 10 | entryFileNames: "[name].js" 11 | } 12 | }, 13 | sourcemap: true, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /aiohttp_admin/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | import secrets 3 | from typing import Optional 4 | 5 | import aiohttp_security 6 | import aiohttp_session 7 | from aiohttp import web 8 | from aiohttp.typedefs import Handler 9 | from aiohttp_session.cookie_storage import EncryptedCookieStorage 10 | from pydantic import ValidationError 11 | 12 | from .routes import setup_resources, setup_routes 13 | from .security import AdminAuthorizationPolicy, Permissions, TokenIdentityPolicy, check 14 | from .types import (Schema, State, UserDetails, 15 | check_credentials_key, data, fk, permission_re_key, state_key) 16 | 17 | __all__ = ("Permissions", "Schema", "UserDetails", "data", "fk", "permission_re_key", "setup") 18 | __version__ = "0.1.0a3" 19 | 20 | 21 | @web.middleware 22 | async def pydantic_middleware(request: web.Request, handler: Handler) -> web.StreamResponse: 23 | try: 24 | return await handler(request) 25 | except ValidationError as e: 26 | raise web.HTTPBadRequest(text=e.json(), content_type="application/json") 27 | 28 | 29 | def setup(app: web.Application, schema: Schema, *, path: str = "/admin", 30 | secret: Optional[bytes] = None) -> web.Application: 31 | """Initialize the admin. 32 | 33 | Args: 34 | app - Parent application to add the admin sub app to. 35 | schema - Schema to define admin layout/behaviour. 36 | auth_policy - aiohttp-security auth policy. 37 | path - The path used when adding the admin sub app to app. 38 | secret - Cookie encryption key. If not provided, a random key is generated, which 39 | will result in users being logged out each time the app is restarted. To 40 | avoid this (or if using multiple servers) it is recommended to generate a 41 | random secret (e.g. secrets.token_bytes()) and save the value. 42 | 43 | Returns the admin application. 44 | """ 45 | async def on_startup(admin: web.Application) -> None: 46 | """Configuration steps which require the application to be already configured. 47 | 48 | This is very awkward, as we need the nested function to be able to reference 49 | prefixed_subapp at the end of the setup. Once we have that object the app 50 | is frozen and we can't modify the app, in order to add this startup function. 51 | Therefore, we add this function first, then we can get the reference from the 52 | enclosing scope later. 53 | """ 54 | storage._cookie_params["path"] = prefixed_subapp.canonical 55 | admin[state_key]["urls"] = { 56 | "token": str(admin.router["token"].url_for()), 57 | "logout": str(admin.router["logout"].url_for()) 58 | } 59 | 60 | def key(r: web.RouteDef) -> str: 61 | name: str = r.kwargs["name"] 62 | return name.removeprefix(m.name + "_") 63 | 64 | def value(r: web.RouteDef) -> tuple[str, str]: 65 | return (r.method, str(admin.router[r.kwargs["name"]].url_for())) 66 | 67 | for res in schema["resources"]: 68 | m = res["model"] 69 | urls = admin[state_key]["resources"][m.name]["urls"] 70 | urls.update((key(r), value(r)) for r in m.routes) 71 | 72 | schema = check(Schema, schema) 73 | if secret is None: 74 | secret = secrets.token_bytes() 75 | 76 | admin = web.Application() 77 | admin.middlewares.append(pydantic_middleware) 78 | admin.on_startup.append(on_startup) 79 | admin[check_credentials_key] = schema["security"]["check_credentials"] 80 | admin[state_key] = State({"view": schema.get("view", {}), "js_module": schema.get("js_module"), 81 | "urls": {}, "resources": {}}) 82 | 83 | max_age = schema["security"].get("max_age") 84 | secure = schema["security"].get("secure", True) 85 | storage = EncryptedCookieStorage( 86 | secret, max_age=max_age, httponly=True, samesite="Strict", secure=secure) 87 | identity_policy = TokenIdentityPolicy(storage._fernet, schema) 88 | aiohttp_session.setup(admin, storage) 89 | aiohttp_security.setup(admin, identity_policy, AdminAuthorizationPolicy(schema)) 90 | 91 | setup_routes(admin) 92 | setup_resources(admin, schema) 93 | 94 | resource_patterns = [] 95 | for r, state in admin[state_key]["resources"].items(): 96 | fields = (f.removeprefix("data.") for f in state["fields"].keys()) 97 | resource_patterns.append( 98 | r"(?#Resource name){r}" 99 | r"(?#Optional field name)(\.({f}))?" 100 | r"(?#Permission type)\.(view|edit|add|delete|\*)" 101 | r"(?#No filters if negated)(?(2)$|" 102 | r'(?#Optional filters)\|({f})=(?#JSON number or str)(\".*?\"|\d+))*'.format( 103 | r=r, f="|".join(fields))) 104 | p_re = (r"(?#Global admin permission)~?admin\.(view|edit|add|delete|\*)" 105 | r"|" 106 | r"(?#Resource permission)(~)?admin\.({})").format("|".join(resource_patterns)) 107 | admin[permission_re_key] = re.compile(p_re) 108 | 109 | prefixed_subapp = app.add_subapp(path, admin) 110 | return admin 111 | -------------------------------------------------------------------------------- /aiohttp_admin/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-admin/7cf41ca022bef2936a4ed77b33891be99a39d3b6/aiohttp_admin/backends/__init__.py -------------------------------------------------------------------------------- /aiohttp_admin/backends/abc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import sys 4 | from abc import ABC, abstractmethod 5 | from collections.abc import Sequence 6 | from datetime import date, datetime, time 7 | from enum import Enum 8 | from functools import cached_property, partial 9 | from types import MappingProxyType 10 | from typing import Any, Generic, Literal, Optional, TypeVar, final 11 | 12 | from aiohttp import web 13 | from aiohttp_security import check_permission, permits 14 | from pydantic import Json 15 | 16 | from ..security import check, permissions_as_dict 17 | from ..types import ComponentState, InputState, fk, resources_key 18 | 19 | if sys.version_info >= (3, 10): 20 | from typing import TypeAlias 21 | else: 22 | from typing_extensions import TypeAlias 23 | 24 | if sys.version_info >= (3, 12): 25 | from typing import TypedDict 26 | else: 27 | from typing_extensions import TypedDict 28 | 29 | _ID = TypeVar("_ID", bound=tuple[object, ...]) 30 | Record = dict[str, object] 31 | Meta = Optional[dict[str, object]] 32 | 33 | INPUT_TYPES = MappingProxyType({ 34 | "BooleanInput": bool, 35 | "DateInput": date, 36 | "DateTimeInput": datetime, 37 | "NumberInput": float, 38 | "TimeInput": time 39 | }) 40 | 41 | 42 | class Encoder(json.JSONEncoder): 43 | def default(self, o: object) -> Any: 44 | if isinstance(o, (date, time)): 45 | return str(o) 46 | if isinstance(o, Enum): 47 | return o.value 48 | if isinstance(o, bytes): 49 | return o.decode(errors="replace") 50 | 51 | return super().default(o) 52 | 53 | 54 | json_response = partial(web.json_response, dumps=partial(json.dumps, cls=Encoder)) 55 | 56 | 57 | class APIRecord(TypedDict): 58 | id: str 59 | data: Record 60 | 61 | 62 | class _Pagination(TypedDict): 63 | page: int 64 | perPage: int 65 | 66 | 67 | class _Sort(TypedDict): 68 | field: str 69 | order: Literal["ASC", "DESC"] 70 | 71 | 72 | class _Params(TypedDict, total=False): 73 | meta: Meta 74 | 75 | 76 | class GetListParams(_Params): 77 | pagination: Json[_Pagination] 78 | sort: Json[_Sort] 79 | filter: Json[dict[str, object]] 80 | 81 | 82 | class GetOneParams(_Params): 83 | id: str 84 | 85 | 86 | class GetManyParams(_Params): 87 | ids: Json[tuple[str, ...]] 88 | 89 | 90 | class GetManyRefAPIParams(_Params): 91 | target: str 92 | id: str 93 | pagination: Json[_Pagination] 94 | sort: Json[_Sort] 95 | filter: Json[dict[str, object]] 96 | 97 | 98 | class GetManyRefParams(_Params): 99 | target: tuple[str, ...] 100 | id: tuple[object, ...] 101 | pagination: Json[_Pagination] 102 | sort: Json[_Sort] 103 | filter: Json[dict[str, object]] 104 | 105 | 106 | class _CreateData(TypedDict): 107 | """Id will not be included for create calls.""" 108 | data: Record 109 | 110 | 111 | class CreateParams(_Params): 112 | data: Json[_CreateData] 113 | 114 | 115 | class UpdateParams(_Params): 116 | id: str 117 | data: Json[APIRecord] 118 | previousData: Json[APIRecord] 119 | 120 | 121 | class UpdateManyParams(_Params): 122 | ids: Json[tuple[str, ...]] 123 | data: Json[Record] 124 | 125 | 126 | class DeleteParams(_Params): 127 | id: str 128 | previousData: Json[APIRecord] 129 | 130 | 131 | class DeleteManyParams(_Params): 132 | ids: Json[tuple[str, ...]] 133 | 134 | 135 | class _ListQuery(TypedDict): 136 | sort: _Sort 137 | filter: dict[str, object] 138 | 139 | 140 | class AbstractAdminResource(ABC, Generic[_ID]): 141 | name: str 142 | fields: dict[str, ComponentState] 143 | inputs: dict[str, InputState] 144 | primary_key: tuple[str, ...] 145 | omit_fields: set[str] 146 | _id_type: type[_ID] 147 | _foreign_rows: set[tuple[str, ...]] 148 | 149 | def __init__(self, record_type: Optional[dict[str, TypeAlias]] = None) -> None: 150 | for k, c in (*self.fields.items(), *self.inputs.items()): 151 | c["props"].setdefault("key", k) 152 | 153 | # For runtime type checking only. 154 | if record_type is None: 155 | record_type = {k.removeprefix("data."): Any for k in self.inputs} 156 | self._raw_record_type = record_type 157 | self._record_type = TypedDict("RecordType", record_type, total=False) # type: ignore[misc] 158 | 159 | @final 160 | async def filter_by_permissions(self, request: web.Request, perm_type: str, 161 | record: Record, original: Optional[Record] = None) -> Record: 162 | """Return a filtered record containing permissible fields only.""" 163 | return {k: v for k, v in record.items() 164 | if await permits(request, f"admin.{self.name}.{k}.{perm_type}", 165 | context=(request, original or record))} 166 | 167 | @abstractmethod 168 | async def get_list(self, params: GetListParams) -> tuple[list[Record], int]: 169 | """Return list of records and total count available (when not paginating).""" 170 | 171 | @abstractmethod 172 | async def get_one(self, record_id: _ID, meta: Meta) -> Record: 173 | """Return the matching record.""" 174 | 175 | @abstractmethod 176 | async def get_many(self, record_ids: Sequence[_ID], meta: Meta) -> list[Record]: 177 | """Return the matching records.""" 178 | 179 | @abstractmethod 180 | async def get_many_ref(self, params: GetManyRefParams) -> tuple[list[Record], int]: 181 | """Return list of records and total count available (when not paginating).""" 182 | 183 | @abstractmethod 184 | async def update(self, record_id: _ID, data: Record, previous_data: Record, 185 | meta: Meta) -> Record: 186 | """Update the record and return the updated record.""" 187 | 188 | @abstractmethod 189 | async def update_many(self, record_ids: Sequence[_ID], data: Record, meta: Meta) -> list[_ID]: 190 | """Update multiple records and return the IDs of updated records.""" 191 | 192 | @abstractmethod 193 | async def create(self, data: Record, meta: Meta) -> Record: 194 | """Create a new record and return the created record.""" 195 | 196 | @abstractmethod 197 | async def delete(self, record_id: _ID, previous_data: Record, meta: Meta) -> Record: 198 | """Delete a record and return the deleted record.""" 199 | 200 | @abstractmethod 201 | async def delete_many(self, record_ids: Sequence[_ID], meta: Meta) -> list[_ID]: 202 | """Delete the matching records and return their IDs.""" 203 | 204 | async def get_many_ref_name(self, target: str, meta: Meta) -> str: 205 | """Return the resource name for the reference. 206 | 207 | This can be used to change which resource should be returned by get_many_ref(). 208 | 209 | For example, if we have an SQLAlchemy model called 'parent' with a relationship 210 | called children, then a normal get_many_ref_name() call would go to the 'child' 211 | model with the details from the parent, and the default behaviour would work. 212 | 213 | However, the SQLAlchemy backend uses the meta to switch this and send the request 214 | to the 'parent' model instead and then use the children ORM attribute to fetch 215 | the referenced resources, thus requiring this method to return 'child'. 216 | This allows the SQLAlchemy backend to support complex relationships (e.g. 217 | many-to-many) without needing react-admin to know the details. 218 | """ 219 | return self.name 220 | 221 | # https://marmelab.com/react-admin/DataProviderWriting.html 222 | 223 | @final 224 | async def _get_list(self, request: web.Request) -> web.Response: 225 | await check_permission(request, f"admin.{self.name}.view", context=(request, None)) 226 | query = check(GetListParams, request.query) 227 | self._process_list_query(query, request) 228 | 229 | raw_results, total = await self.get_list(query) 230 | results = [await self._convert_record(r, request) for r in raw_results 231 | if await permits(request, f"admin.{self.name}.view", context=(request, r))] 232 | return json_response({"data": results, "total": total}) 233 | 234 | @final 235 | async def _get_one(self, request: web.Request) -> web.Response: 236 | await check_permission(request, f"admin.{self.name}.view", context=(request, None)) 237 | query = check(GetOneParams, request.query) 238 | record_id = check(self._id_type, query["id"].split("|")) 239 | 240 | result = await self.get_one(record_id, query.get("meta")) 241 | if not await permits(request, f"admin.{self.name}.view", context=(request, result)): 242 | raise web.HTTPForbidden() 243 | return json_response({"data": await self._convert_record(result, request)}) 244 | 245 | @final 246 | async def _get_many(self, request: web.Request) -> web.Response: 247 | await check_permission(request, f"admin.{self.name}.view", context=(request, None)) 248 | query = check(GetManyParams, request.query) 249 | record_ids = check(tuple[self._id_type, ...], (q.split("|") for q in query["ids"])) # type: ignore[name-defined] 250 | 251 | raw_results = await self.get_many(record_ids, query.get("meta")) 252 | if not raw_results: 253 | raise web.HTTPNotFound() 254 | 255 | results = [await self._convert_record(r, request) for r in raw_results 256 | if await permits(request, f"admin.{self.name}.view", context=(request, r))] 257 | return json_response({"data": results}) 258 | 259 | @final 260 | async def _get_many_ref(self, request: web.Request) -> web.Response: 261 | query = check(GetManyRefAPIParams, request.query) 262 | meta = query["filter"].pop("__meta__", None) 263 | if meta is not None: 264 | query["meta"] = check(dict[str, object], meta) 265 | reference = await self.get_many_ref_name(query["target"], query.get("meta")) 266 | ref_model = request.app[resources_key][reference] 267 | 268 | await check_permission(request, f"admin.{ref_model.name}.view", context=(request, None)) 269 | 270 | ref_model._process_list_query(query, request) 271 | 272 | if query["target"].startswith("fk_"): 273 | target = tuple(query["target"].removeprefix("fk_").split("__")) 274 | record_id = tuple(check(self._raw_record_type[k], v) 275 | for k, v in zip(target, query["id"].split("|"))) 276 | else: 277 | target = (query["target"],) 278 | record_id = check(self._id_type, query["id"].split("|")) 279 | 280 | raw_results, total = await self.get_many_ref({**query, "target": target, "id": record_id}) 281 | 282 | results = [await ref_model._convert_record(r, request) for r in raw_results 283 | if await permits(request, f"admin.{ref_model.name}.view", context=(request, r))] 284 | return json_response({"data": results, "total": total}) 285 | 286 | @final 287 | async def _create(self, request: web.Request) -> web.Response: 288 | query = check(CreateParams, request.query) 289 | # TODO(Pydantic): Dissallow extra arguments 290 | for k in query["data"]["data"]: 291 | if k not in self.inputs: 292 | raise web.HTTPBadRequest(reason=f"Invalid field '{k}'") 293 | record = self._check_record(query["data"]["data"]) 294 | await check_permission(request, f"admin.{self.name}.add", context=(request, record)) 295 | for k, v in record.items(): 296 | if v is not None: 297 | await check_permission(request, f"admin.{self.name}.{k}.add", 298 | context=(request, record)) 299 | 300 | result = await self.create(record, query.get("meta")) 301 | return json_response({"data": await self._convert_record(result, request)}) 302 | 303 | @final 304 | async def _update(self, request: web.Request) -> web.Response: 305 | await check_permission(request, f"admin.{self.name}.edit", context=(request, None)) 306 | query = check(UpdateParams, request.query) 307 | record_id = check(self._id_type, query["id"].split("|")) 308 | # TODO(Pydantic): Dissallow extra arguments 309 | for k in query["data"]["data"]: 310 | if k not in self.inputs: 311 | raise web.HTTPBadRequest(reason=f"Invalid field '{k}'") 312 | record = self._check_record(query["data"]["data"]) 313 | previous_data = self._check_record(query["previousData"]["data"]) 314 | 315 | # Check original record is allowed by permission filters. 316 | original = await self.get_one(record_id, query.get("meta")) 317 | if not await permits(request, f"admin.{self.name}.edit", context=(request, original)): 318 | raise web.HTTPForbidden() 319 | 320 | # Filter rather than forbid because react-admin still sends fields without an 321 | # input component. The query may not be the complete dict though, so we must 322 | # pass original for testing. 323 | record = await self.filter_by_permissions(request, "edit", record, original) 324 | # Check new values are allowed by permission filters. 325 | if not await permits(request, f"admin.{self.name}.edit", context=(request, record)): 326 | raise web.HTTPForbidden() 327 | 328 | if not record: 329 | raise web.HTTPBadRequest(reason="No allowed fields to change.") 330 | 331 | result = await self.update(record_id, record, previous_data, query.get("meta")) 332 | return json_response({"data": await self._convert_record(result, request)}) 333 | 334 | @final 335 | async def _update_many(self, request: web.Request) -> web.Response: 336 | await check_permission(request, f"admin.{self.name}.edit", context=(request, None)) 337 | query = check(UpdateManyParams, request.query) 338 | record_ids = check(tuple[self._id_type, ...], (i.split("|") for i in query["ids"])) # type: ignore[name-defined] 339 | # TODO(Pydantic): Dissallow extra arguments 340 | for k in query["data"]: 341 | if k not in self.inputs: 342 | raise web.HTTPBadRequest(reason=f"Invalid field '{k}'") 343 | record = self._check_record(query["data"]) 344 | 345 | # Check original records are allowed by permission filters. 346 | originals = await self.get_many(record_ids, query.get("meta")) 347 | if not originals: 348 | raise web.HTTPNotFound() 349 | allowed = (permits(request, f"admin.{self.name}.edit", context=(request, r)) 350 | for r in originals) 351 | allowed_f = (permits(request, f"admin.{self.name}.{k}.edit", context=(request, r)) 352 | for r in originals for k in record) 353 | if not all(await asyncio.gather(*allowed, *allowed_f)): 354 | raise web.HTTPForbidden() 355 | # Check new values are allowed by permission filters. 356 | if not await permits(request, f"admin.{self.name}.edit", context=(request, record)): 357 | raise web.HTTPForbidden() 358 | 359 | ids = await self.update_many(record_ids, record, query.get("meta")) 360 | # get_many() is called above, so we can be sure there will be results here. 361 | return json_response({"data": self._convert_ids(ids)}) 362 | 363 | @final 364 | async def _delete(self, request: web.Request) -> web.Response: 365 | await check_permission(request, f"admin.{self.name}.delete", context=(request, None)) 366 | query = check(DeleteParams, request.query) 367 | record_id = check(self._id_type, query["id"].split("|")) 368 | previous_data = self._check_record(query["previousData"]["data"]) 369 | 370 | original = await self.get_one(record_id, query.get("meta")) 371 | if not await permits(request, f"admin.{self.name}.delete", context=(request, original)): 372 | raise web.HTTPForbidden() 373 | 374 | result = await self.delete(record_id, previous_data, query.get("meta")) 375 | return json_response({"data": await self._convert_record(result, request)}) 376 | 377 | @final 378 | async def _delete_many(self, request: web.Request) -> web.Response: 379 | await check_permission(request, f"admin.{self.name}.delete", context=(request, None)) 380 | query = check(DeleteManyParams, request.query) 381 | record_ids = check(tuple[self._id_type, ...], (i.split("|") for i in query["ids"])) # type: ignore[name-defined] 382 | 383 | originals = await self.get_many(record_ids, query.get("meta")) 384 | allowed = await asyncio.gather(*(permits(request, f"admin.{self.name}.delete", 385 | context=(request, r)) for r in originals)) 386 | if not all(allowed): 387 | raise web.HTTPForbidden() 388 | 389 | ids = await self.delete_many(record_ids, query.get("meta")) 390 | if not ids: 391 | raise web.HTTPNotFound() 392 | return json_response({"data": self._convert_ids(ids)}) 393 | 394 | @final 395 | def _check_record(self, record: Record) -> Record: 396 | """Check and convert input record.""" 397 | return check(self._record_type, record) 398 | 399 | @final 400 | async def _convert_record(self, record: Record, request: web.Request) -> APIRecord: 401 | """Convert record to correct output format.""" 402 | record = await self.filter_by_permissions(request, "view", record) 403 | 404 | foreign_keys = {fk(*keys): None if any(record[k] is None for k in keys) 405 | else "|".join(str(record[k]) for k in keys) 406 | for keys in self._foreign_rows if all(k in record for k in keys)} 407 | return { 408 | "id": "|".join(str(record[pk]) for pk in self.primary_key), 409 | "data": record, 410 | **foreign_keys # type: ignore[typeddict-item] 411 | } 412 | 413 | @final 414 | def _convert_ids(self, ids: Sequence[_ID]) -> tuple[str, ...]: 415 | """Convert IDs to correct output format.""" 416 | return tuple(str(i) for i in ids) 417 | 418 | def _process_list_query(self, query: _ListQuery, request: web.Request) -> None: 419 | # When sort order refers to "id", this should be translated to primary key. 420 | if query["sort"]["field"] == "id": 421 | query["sort"]["field"] = self.primary_key[0] 422 | else: 423 | query["sort"]["field"] = query["sort"]["field"].removeprefix("data.") 424 | 425 | query["filter"].update(check(dict[str, object], query["filter"].pop("data", {}))) 426 | 427 | merged_filter = {} 428 | for k, v in query["filter"].items(): 429 | if k.startswith("fk_"): 430 | v = check(str, v) 431 | for c, cv in zip(k.removeprefix("fk_").split("__"), v.split("|")): 432 | merged_filter[c] = check(self._raw_record_type[c], cv) 433 | else: 434 | merged_filter[k] = check(self._raw_record_type[k], v) 435 | query["filter"] = merged_filter 436 | 437 | # Add filters from advanced permissions. 438 | # The permissions will be cached on the request from a previous permissions check. 439 | permissions = permissions_as_dict(request["aiohttpadmin_permissions"]) 440 | filters = permissions.get(f"admin.{self.name}.view", 441 | permissions.get(f"admin.{self.name}.*", {})) 442 | for k, v in filters.items(): 443 | query["filter"][k] = v 444 | 445 | @cached_property 446 | def routes(self) -> tuple[web.RouteDef, ...]: 447 | """Routes to act on this resource. 448 | 449 | Every route returned must have a name. 450 | """ 451 | url = "/" + self.name 452 | return ( 453 | web.get(url + "/list", self._get_list, name=self.name + "_get_list"), 454 | web.get(url + "/one", self._get_one, name=self.name + "_get_one"), 455 | web.get(url, self._get_many, name=self.name + "_get_many"), 456 | web.get(url + "/ref", self._get_many_ref, name=self.name + "_get_many_ref"), 457 | web.post(url, self._create, name=self.name + "_create"), 458 | web.put(url + "/update", self._update, name=self.name + "_update"), 459 | web.put(url + "/update_many", self._update_many, name=self.name + "_update_many"), 460 | web.delete(url + "/one", self._delete, name=self.name + "_delete"), 461 | web.delete(url, self._delete_many, name=self.name + "_delete_many") 462 | ) 463 | -------------------------------------------------------------------------------- /aiohttp_admin/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-admin/7cf41ca022bef2936a4ed77b33891be99a39d3b6/aiohttp_admin/py.typed -------------------------------------------------------------------------------- /aiohttp_admin/routes.py: -------------------------------------------------------------------------------- 1 | """Setup routes for admin app.""" 2 | 3 | import copy 4 | from pathlib import Path 5 | 6 | from aiohttp import web 7 | 8 | from . import views 9 | from .backends.abc import AbstractAdminResource 10 | from .types import Schema, _ResourceState, data, resources_key, state_key 11 | 12 | 13 | def setup_resources(admin: web.Application, schema: Schema) -> None: 14 | resources: dict[str, AbstractAdminResource[tuple[object, ...]]] = {} 15 | for r in schema["resources"]: 16 | m = r["model"] 17 | resources[m.name] = m 18 | admin.router.add_routes(m.routes) 19 | 20 | try: 21 | omit_fields = m.fields.keys() - r["display"] 22 | except KeyError: 23 | omit_fields = m.omit_fields 24 | else: 25 | if not all(f in m.fields for f in r["display"]): 26 | raise ValueError(f"Display includes non-existent field {r['display']}") 27 | # TODO: Use label: https://github.com/marmelab/react-admin/issues/9587 28 | omit_fields = tuple(m.fields[f]["props"].get("source") for f in omit_fields) 29 | 30 | repr_field = r.get("repr", data(m.primary_key[0])) 31 | if repr_field.removeprefix("data.") not in m.fields: 32 | raise ValueError(f"repr not a valid field name: {repr_field}") 33 | 34 | # Don't modify the resource. 35 | fields = copy.deepcopy(m.fields) 36 | inputs = copy.deepcopy(m.inputs) 37 | 38 | validators = r.get("validators", {}) 39 | input_props = r.get("input_props", {}) 40 | for k, v in inputs.items(): 41 | k = k.removeprefix("data.") 42 | if k not in omit_fields: 43 | v["props"]["alwaysOn"] = "alwaysOn" # Always display filter 44 | if k in validators: 45 | v["props"]["validate"] = (tuple(v["props"].get("validate", ())) 46 | + tuple(validators[k])) 47 | v["props"].update(input_props.get(k, {})) 48 | 49 | for name, props in r.get("field_props", {}).items(): 50 | fields[name]["props"].update(props) 51 | 52 | state: _ResourceState = { 53 | "fields": fields, "inputs": inputs, "list_omit": tuple(omit_fields), 54 | "repr": repr_field, "label": r.get("label"), "icon": r.get("icon"), 55 | "bulk_update": r.get("bulk_update", {}), "urls": {}, 56 | "show_actions": r.get("show_actions", ())} 57 | admin[state_key]["resources"][m.name] = state 58 | admin[resources_key] = resources 59 | 60 | 61 | def setup_routes(admin: web.Application) -> None: 62 | """Add routes to the admin application.""" 63 | admin.router.add_get("", views.index, name="index") 64 | admin.router.add_post("/token", views.token, name="token") 65 | admin.router.add_delete("/logout", views.logout, name="logout") 66 | admin.router.add_static("/static", path=Path(__file__).with_name("static"), name="static") 67 | -------------------------------------------------------------------------------- /aiohttp_admin/security.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections.abc import Collection, Mapping, Sequence 3 | from enum import Enum 4 | from functools import lru_cache 5 | from typing import Optional, Type, TypeVar, Union 6 | 7 | from aiohttp import web 8 | from aiohttp_security import AbstractAuthorizationPolicy, SessionIdentityPolicy 9 | from cryptography.fernet import Fernet, InvalidToken 10 | from pydantic import Json, TypeAdapter, ValidationError 11 | 12 | from .types import IdentityDict, Schema, UserDetails 13 | 14 | _T = TypeVar("_T") 15 | 16 | 17 | @lru_cache # https://github.com/python/typeshed/issues/6347 18 | def _get_schema(t: Type[_T]) -> TypeAdapter[_T]: # type: ignore[misc] 19 | return TypeAdapter(t) 20 | 21 | 22 | def check(t: Type[_T], value: object) -> _T: 23 | """Validate value is of static type t.""" 24 | # https://github.com/python/mypy/issues/11470 25 | return _get_schema(t).validate_python(value) # type: ignore[arg-type,no-any-return] 26 | 27 | 28 | class Permissions(str, Enum): 29 | view = "admin.view" 30 | edit = "admin.edit" 31 | add = "admin.add" 32 | delete = "admin.delete" 33 | all = "admin.*" 34 | 35 | 36 | def has_permission(p: Union[str, Enum], permissions: Mapping[str, Mapping[str, Sequence[object]]], 37 | context: Optional[Mapping[str, object]]) -> bool: 38 | # TODO(PY311): StrEnum 39 | *parts, ptype = p.split(".") # type: ignore[union-attr] 40 | 41 | # Negative permissions. 42 | for i in range(len(parts), 0, -1): 43 | for t in (ptype, "*"): 44 | perm = ".".join((*parts[:i], t)) 45 | if "~" + perm in permissions: 46 | return False 47 | 48 | # Positive permissions. 49 | for i in range(len(parts), 0, -1): 50 | for t in (ptype, "*"): 51 | perm = ".".join((*parts[:i], t)) 52 | if perm in permissions: 53 | if not context: 54 | return True 55 | 56 | filters = permissions[perm] 57 | for attr, vals in filters.items(): 58 | if context.get(attr) not in vals: 59 | return False 60 | return True 61 | return False 62 | 63 | 64 | def permissions_as_dict(permissions: Collection[str]) -> dict[str, dict[str, list[object]]]: 65 | p_dict: dict[str, dict[str, list[object]]] = {} 66 | for p in permissions: 67 | perm, *filters = p.split("|") 68 | p_dict[perm] = {} 69 | for f in filters: 70 | k, v = f.split("=", maxsplit=1) 71 | p_dict[perm].setdefault(k, []).append(json.loads(v)) 72 | return p_dict 73 | 74 | 75 | class AdminAuthorizationPolicy(AbstractAuthorizationPolicy): 76 | def __init__(self, schema: Schema): 77 | super().__init__() 78 | self._identity_callback = schema["security"].get("identity_callback") 79 | 80 | async def authorized_userid(self, identity: str) -> str: 81 | return identity 82 | 83 | async def permits( 84 | self, identity: Optional[str], permission: Union[str, Enum], 85 | context: Optional[tuple[web.Request, Optional[Mapping[str, object]]]] = None 86 | ) -> bool: 87 | # TODO: https://github.com/aio-libs/aiohttp-security/issues/677 88 | assert context is not None # noqa: S101 89 | if identity is None: 90 | return False 91 | 92 | try: 93 | request, record = context 94 | except (TypeError, ValueError): 95 | raise TypeError("Context must be `(request, record)` or `(request, None)`") 96 | 97 | permissions: Optional[Collection[str]] = request.get("aiohttpadmin_permissions") 98 | if permissions is None: 99 | if self._identity_callback is None: 100 | permissions = (Permissions.all,) 101 | else: 102 | user = await self._identity_callback(identity) 103 | permissions = user["permissions"] 104 | # Cache permissions per request to avoid potentially dozens of DB calls. 105 | request["aiohttpadmin_permissions"] = permissions 106 | return has_permission(permission, permissions_as_dict(permissions), record) 107 | 108 | 109 | class TokenIdentityPolicy(SessionIdentityPolicy): 110 | def __init__(self, fernet: Fernet, schema: Schema): 111 | super().__init__() 112 | self._fernet = fernet 113 | config = schema["security"] 114 | self._identity_callback = config.get("identity_callback") 115 | self._max_age = config.get("max_age") 116 | 117 | async def identify(self, request: web.Request) -> Optional[str]: 118 | """Return the identity of an authorised user.""" 119 | # Validate JS token 120 | hdr = request.headers.get("Authorization") 121 | try: 122 | identity_data = check(Json[IdentityDict], hdr) 123 | except ValidationError: 124 | return None 125 | 126 | auth = identity_data["auth"].encode("utf-8") 127 | try: 128 | token_identity = self._fernet.decrypt(auth, ttl=self._max_age).decode("utf-8") 129 | except InvalidToken: 130 | return None 131 | 132 | # Validate cookie token 133 | cookie_identity = await super().identify(request) 134 | 135 | # Both identites must match. 136 | return token_identity if token_identity == cookie_identity else None 137 | 138 | async def remember(self, request: web.Request, response: web.StreamResponse, 139 | identity: str, **kwargs: object) -> None: 140 | """Send auth tokens to client for authentication.""" 141 | # For proper security we send a token for JS to store and an HTTP only cookie: 142 | # https://www.redotheweb.com/2015/11/09/api-security.html 143 | # Send token that will be saved in local storage by the JS client. 144 | response.headers["X-Token"] = json.dumps(await self.user_identity_dict(request, identity)) 145 | # Send httponly cookie, which will be invisible to JS. 146 | await super().remember(request, response, identity, **kwargs) # type: ignore[arg-type] 147 | 148 | async def forget(self, request: web.Request, response: web.StreamResponse) -> None: 149 | """Delete session cookie (JS client should choose to delete its token).""" 150 | await super().forget(request, response) 151 | 152 | async def user_identity_dict(self, request: web.Request, identity: str) -> IdentityDict: 153 | """Create the identity information sent back to the admin client. 154 | 155 | The 'auth' key will be used for the server authentication, everything else is 156 | just information that the client can use. For example, 'permissions' will be 157 | returned by the react-admin's getPermissions() and some values like 158 | 'fullName' or 'avatar' will be automatically used: 159 | https://marmelab.com/react-admin/AuthProviderWriting.html#getidentity 160 | 161 | All details (except auth) can be specified using the identity callback. 162 | """ 163 | if self._identity_callback is None: 164 | user_details: UserDetails = {"permissions": (Permissions.all,)} 165 | else: 166 | user_details = await self._identity_callback(identity) 167 | if "auth" in user_details: 168 | raise ValueError("Callback should not return a dict with 'auth' key.") 169 | 170 | auth = self._fernet.encrypt(identity.encode("utf-8")).decode("utf-8") 171 | identity_dict: IdentityDict = {"auth": auth, "fullName": "Admin user", "permissions": {}} 172 | # We change type of permissions below, so need to ignore this type error. 173 | identity_dict.update(user_details) # type: ignore[typeddict-item] 174 | identity_dict["permissions"] = permissions_as_dict(user_details["permissions"]) 175 | 176 | return identity_dict 177 | -------------------------------------------------------------------------------- /aiohttp_admin/static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /aiohttp_admin/types.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from collections.abc import Callable, Collection, Sequence 4 | from typing import Any, Awaitable, Literal, Mapping, NewType, Optional 5 | 6 | from aiohttp.web import AppKey 7 | 8 | if sys.version_info >= (3, 12): 9 | from typing import TypedDict 10 | else: 11 | from typing_extensions import TypedDict 12 | 13 | Data = NewType("Data", str) 14 | FK = NewType("FK", str) 15 | 16 | 17 | def data(key: str) -> Data: 18 | return Data(f"data.{key}") 19 | 20 | 21 | def fk(*keys: str) -> FK: 22 | return FK("fk_{}".format("__".join(sorted(keys)))) 23 | 24 | 25 | class ComponentState(TypedDict): 26 | __type__: Literal["component"] 27 | type: str 28 | props: dict[str, object] 29 | 30 | 31 | class FunctionState(TypedDict): 32 | __type__: Literal["function"] 33 | name: str 34 | args: Optional[Sequence[object]] 35 | 36 | 37 | class RegexState(TypedDict): 38 | __type__: Literal["regexp"] 39 | value: str 40 | 41 | 42 | class InputState(ComponentState): 43 | # Whether to show this input in the create form. 44 | show_create: bool 45 | 46 | 47 | class _IdentityDict(TypedDict, total=False): 48 | avatar: str 49 | 50 | 51 | class IdentityDict(_IdentityDict): 52 | auth: str 53 | fullName: str 54 | permissions: dict[str, dict[str, list[object]]] 55 | 56 | 57 | class UserDetails(TypedDict, total=False): 58 | # https://marmelab.com/react-admin/AuthProviderWriting.html#getidentity 59 | fullName: str 60 | avatar: str 61 | # https://marmelab.com/react-admin/AuthProviderWriting.html#getpermissions 62 | permissions: Collection[str] 63 | 64 | 65 | class __SecuritySchema(TypedDict, total=False): 66 | # Callback that receives identity and should return user details for the admin to use. 67 | identity_callback: Callable[[str], Awaitable[UserDetails]] 68 | # max_age value for cookies/tokens, defaults to None. 69 | max_age: Optional[int] 70 | # Secure flag for cookies, defaults to True. 71 | secure: bool 72 | 73 | 74 | class _SecuritySchema(__SecuritySchema): 75 | # Callback that receives request.config_dict, username and password and returns 76 | # True if authorised, False otherwise. 77 | check_credentials: Callable[[str, str], Awaitable[bool]] 78 | 79 | 80 | class _ViewSchema(TypedDict, total=False): 81 | # Path to favicon. 82 | icon: str 83 | # Name for the project (shown in the title), defaults to the package name. 84 | name: str 85 | 86 | 87 | class _Resource(TypedDict, total=False): 88 | # List of field names that should be shown in the list view by default. 89 | display: Sequence[str] 90 | # Display label in admin. 91 | label: str 92 | # URL path to custom icon. 93 | icon: str 94 | # name of the field that should be used for repr 95 | # (e.g. when displaying a foreign key reference). 96 | repr: str 97 | # Bulk update actions (which appear when selecting rows in the list view). 98 | # Format: {"Button Label": {"field_to_update": "value_to_set"}} 99 | # e.g. {"Reset Views": {"views": 0}} 100 | bulk_update: dict[str, dict[str, Any]] 101 | # Custom validators to add to inputs. 102 | validators: dict[str, Sequence[FunctionState]] 103 | # Custom props to add to fields. 104 | field_props: dict[str, dict[str, Any]] 105 | # Custom props to add to inputs. 106 | input_props: dict[str, dict[str, Any]] 107 | # Custom components to add to the actions in the show view. 108 | show_actions: Sequence[ComponentState] 109 | 110 | 111 | class Resource(_Resource): 112 | # The admin resource model. 113 | model: Any # TODO(pydantic): AbstractAdminResource 114 | 115 | 116 | class _Schema(TypedDict, total=False): 117 | view: _ViewSchema 118 | js_module: str 119 | 120 | 121 | class Schema(_Schema): 122 | security: _SecuritySchema 123 | resources: Sequence[Resource] 124 | 125 | 126 | class _ResourceState(TypedDict): 127 | fields: dict[str, ComponentState] 128 | inputs: dict[str, InputState] 129 | show_actions: Sequence[ComponentState] 130 | repr: str 131 | icon: Optional[str] 132 | urls: dict[str, tuple[str, str]] # (method, url) 133 | bulk_update: dict[str, dict[str, Any]] 134 | list_omit: tuple[str, ...] 135 | label: Optional[str] 136 | 137 | 138 | class State(TypedDict): 139 | resources: dict[str, _ResourceState] 140 | urls: dict[str, str] 141 | view: _ViewSchema 142 | js_module: Optional[str] 143 | 144 | 145 | def comp(t: str, props: Optional[Mapping[str, object]] = None) -> ComponentState: 146 | """Use a component of type t with the given props.""" 147 | props = dict(props or {}) 148 | # Set default label, otherwise react-admin will use a label with the prefix. 149 | if "label" not in props and "source" in props: 150 | s = props["source"] 151 | assert isinstance(s, str) # noqa: S101 152 | props["label"] = s.removeprefix("fk_").removeprefix("data.").replace("_", " ").title() 153 | 154 | return {"__type__": "component", "type": t, "props": props} 155 | 156 | 157 | def func(name: str, args: Optional[Sequence[object]] = None) -> FunctionState: 158 | """Use the function with matching name. 159 | 160 | If args are provided, the function will be called with those arguments. 161 | Otherwise, the function itself will be passed in the frontend. 162 | e.g. To use the 'required' validator, use func("required", ()) 163 | Or, to pass a custom function directly as a prop, use func("myFunction") 164 | """ 165 | return {"__type__": "function", "name": name, "args": args} 166 | 167 | 168 | def regex(value: str) -> RegexState: 169 | """Convert value to a RegExp object on the frontend.""" 170 | return {"__type__": "regexp", "value": value} 171 | 172 | 173 | check_credentials_key = AppKey[Callable[[str, str], Awaitable[bool]]]("check_credentials") 174 | permission_re_key = AppKey("permission_re", re.Pattern[str]) 175 | resources_key = AppKey("resources", dict[str, Any]) # TODO(pydantic): AbstractAdminResource 176 | state_key = AppKey("state", State) 177 | -------------------------------------------------------------------------------- /aiohttp_admin/views.py: -------------------------------------------------------------------------------- 1 | import __main__ 2 | import json 3 | import sys 4 | 5 | from aiohttp import web 6 | from aiohttp_security import forget, remember 7 | from pydantic import Json 8 | 9 | from .security import check 10 | from .types import check_credentials_key, state_key 11 | 12 | if sys.version_info >= (3, 12): 13 | from typing import TypedDict 14 | else: 15 | from typing_extensions import TypedDict 16 | 17 | 18 | class _Login(TypedDict): 19 | username: str 20 | password: str 21 | 22 | 23 | INDEX_TEMPLATE = """ 24 | 25 | 26 | 27 | 28 | {name} Admin 29 | 30 | 31 | 32 | 33 |
34 | 35 | """ 36 | 37 | 38 | async def index(request: web.Request) -> web.Response: 39 | """Root page which loads react-admin.""" 40 | static = request.app.router["static"] 41 | js = static.url_for(filename="admin.js") 42 | state = json.dumps(request.app[state_key]) 43 | 44 | # __package__ can be None, despite what the documentation claims. 45 | package_name = __main__.__package__ or "My" 46 | # Common convention is to have _app suffix for package name, so try and strip that. 47 | package_name = package_name.removesuffix("_app").replace("_", " ").title() 48 | name = request.app[state_key]["view"].get("name", package_name) 49 | 50 | icon = request.app[state_key]["view"].get("icon", static.url_for(filename="favicon.svg")) 51 | 52 | output = INDEX_TEMPLATE.format(name=name, icon=icon, js=js, state=state) 53 | return web.Response(text=output, content_type="text/html") 54 | 55 | 56 | async def token(request: web.Request) -> web.Response: 57 | """Validate user credentials and log the user in.""" 58 | data = check(Json[_Login], await request.read()) 59 | 60 | check_credentials = request.app[check_credentials_key] 61 | if not await check_credentials(data["username"], data["password"]): 62 | raise web.HTTPUnauthorized(text="Wrong username or password") 63 | 64 | response = web.Response() 65 | await remember(request, response, data["username"]) 66 | return response 67 | 68 | 69 | async def logout(request: web.Request) -> web.Response: 70 | """Log the user out.""" 71 | response = web.json_response() 72 | await forget(request, response) 73 | return response 74 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/aiohttp-admin.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/aiohttp-admin.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/aiohttp-admin" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/aiohttp-admin" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # The docs for the new admin realization 2 | 3 | ## Library Installation 4 | ``` 5 | pip install aiohttp_django 6 | ``` 7 | 8 | ## ModelAdmin 9 | ```python 10 | @schema.register 11 | class Tags(models.ModelAdmin): 12 | fields = ('id', 'name', 'published', ) 13 | 14 | class Meta: 15 | resource_type = PGResource 16 | table = tag 17 | ``` 18 | 19 | `ModelAdmin.fields` by default it's primary key 20 | 21 | `ModelAdmin.can_create` if it's `True` then show create button (by default is `True`) 22 | 23 | `ModelAdmin.can_edit` if it's `True` then show edit button (by default is `True`) 24 | 25 | `ModelAdmin.can_delete` if it's `True` then show delete button (by default is `True`) 26 | 27 | `ModelAdmin.per_page` count of items in a list page (by default is `10`) 28 | -------------------------------------------------------------------------------- /docs/admin_polls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-admin/7cf41ca022bef2936a4ed77b33891be99a39d3b6/docs/admin_polls.png -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-admin/7cf41ca022bef2936a4ed77b33891be99a39d3b6/docs/api.rst -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. module:: aiohttp-admin 2 | 3 | .. include:: ../CHANGES.txt 4 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # aiohttp-admin documentation build configuration file, created by 3 | # sphinx-quickstart on Sun Nov 13 21:04:19 2016. 4 | # 5 | # This file is execfile()d with the current directory set to its 6 | # containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | # If extensions (or modules to document with autodoc) are in another directory, 15 | # add these directories to sys.path here. If the directory is relative to the 16 | # documentation root, use os.path.abspath to make it absolute, like shown here. 17 | # 18 | # import os 19 | # import sys 20 | # sys.path.insert(0, os.path.abspath('.')) 21 | 22 | # -- General configuration ------------------------------------------------ 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | # 26 | # needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | 'sphinx.ext.autodoc', 33 | 'sphinx.ext.intersphinx', 34 | 'sphinx.ext.todo', 35 | 'sphinx.ext.coverage', 36 | 'sphinx.ext.viewcode', 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # The suffix(es) of source filenames. 43 | # You can specify multiple suffix as a list of string: 44 | # 45 | # source_suffix = ['.rst', '.md'] 46 | source_suffix = '.rst' 47 | 48 | # The encoding of source files. 49 | # 50 | # source_encoding = 'utf-8-sig' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = 'aiohttp-admin' 57 | copyright = '2016, Nikolay Novik' 58 | author = 'Nikolay Novik' 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = '0.0.1' 66 | # The full version, including alpha/beta/rc tags. 67 | release = '0.0.1' 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = None 75 | 76 | # There are two options for replacing |today|: either, you set today to some 77 | # non-false value, then it is used: 78 | # 79 | # today = '' 80 | # 81 | # Else, today_fmt is used as the format for a strftime call. 82 | # 83 | # today_fmt = '%B %d, %Y' 84 | 85 | # List of patterns, relative to source directory, that match files and 86 | # directories to ignore when looking for source files. 87 | # This patterns also effect to html_static_path and html_extra_path 88 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 89 | 90 | # The reST default role (used for this markup: `text`) to use for all 91 | # documents. 92 | # 93 | # default_role = None 94 | 95 | # If true, '()' will be appended to :func: etc. cross-reference text. 96 | # 97 | # add_function_parentheses = True 98 | 99 | # If true, the current module name will be prepended to all description 100 | # unit titles (such as .. function::). 101 | # 102 | # add_module_names = True 103 | 104 | # If true, sectionauthor and moduleauthor directives will be shown in the 105 | # output. They are ignored by default. 106 | # 107 | # show_authors = False 108 | 109 | # The name of the Pygments (syntax highlighting) style to use. 110 | pygments_style = 'sphinx' 111 | 112 | # A list of ignored prefixes for module index sorting. 113 | # modindex_common_prefix = [] 114 | 115 | # If true, keep warnings as "system message" paragraphs in the built documents. 116 | # keep_warnings = False 117 | 118 | # If true, `todo` and `todoList` produce output, else they produce nothing. 119 | todo_include_todos = True 120 | 121 | 122 | # -- Options for HTML output ---------------------------------------------- 123 | 124 | # The theme to use for HTML and HTML Help pages. See the documentation for 125 | # a list of builtin themes. 126 | # 127 | 128 | html_theme = "alabaster" 129 | 130 | # Theme options are theme-specific and customize the look and feel of a theme 131 | # further. For a list of options available for each theme, see the 132 | # documentation. 133 | # 134 | # html_theme_options = {} 135 | 136 | # Add any paths that contain custom themes here, relative to this directory. 137 | # html_theme_path = [] 138 | 139 | # The name for this set of Sphinx documents. 140 | # " v documentation" by default. 141 | # 142 | # html_title = 'aiohttp-admin v0.0.1' 143 | 144 | # A shorter title for the navigation bar. Default is the same as html_title. 145 | # 146 | # html_short_title = None 147 | 148 | # The name of an image file (relative to this directory) to place at the top 149 | # of the sidebar. 150 | # 151 | # html_logo = None 152 | 153 | # The name of an image file (relative to this directory) to use as a favicon of 154 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 155 | # pixels large. 156 | # 157 | # html_favicon = None 158 | 159 | # Add any paths that contain custom static files (such as style sheets) here, 160 | # relative to this directory. They are copied after the builtin static files, 161 | # so a file named "default.css" will overwrite the builtin "default.css". 162 | html_static_path = ['_static'] 163 | 164 | # Add any extra paths that contain custom files (such as robots.txt or 165 | # .htaccess) here, relative to this directory. These files are copied 166 | # directly to the root of the documentation. 167 | # 168 | # html_extra_path = [] 169 | 170 | # If not None, a 'Last updated on:' timestamp is inserted at every page 171 | # bottom, using the given strftime format. 172 | # The empty string is equivalent to '%b %d, %Y'. 173 | # 174 | # html_last_updated_fmt = None 175 | 176 | # If true, SmartyPants will be used to convert quotes and dashes to 177 | # typographically correct entities. 178 | # 179 | # html_use_smartypants = True 180 | 181 | # Custom sidebar templates, maps document names to template names. 182 | # 183 | # html_sidebars = {} 184 | 185 | # Additional templates that should be rendered to pages, maps page names to 186 | # template names. 187 | # 188 | # html_additional_pages = {} 189 | 190 | # If false, no module index is generated. 191 | # 192 | # html_domain_indices = True 193 | 194 | # If false, no index is generated. 195 | # 196 | # html_use_index = True 197 | 198 | # If true, the index is split into individual pages for each letter. 199 | # 200 | # html_split_index = False 201 | 202 | # If true, links to the reST sources are added to the pages. 203 | # 204 | # html_show_sourcelink = True 205 | 206 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 207 | # 208 | # html_show_sphinx = True 209 | 210 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 211 | # 212 | # html_show_copyright = True 213 | 214 | # If true, an OpenSearch description file will be output, and all pages will 215 | # contain a tag referring to it. The value of this option must be the 216 | # base URL from which the finished HTML is served. 217 | # 218 | # html_use_opensearch = '' 219 | 220 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 221 | # html_file_suffix = None 222 | 223 | # Language to be used for generating the HTML full-text search index. 224 | # Sphinx supports the following languages: 225 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 226 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' 227 | # 228 | # html_search_language = 'en' 229 | 230 | # A dictionary with options for the search language support, empty by default. 231 | # 'ja' uses this config value. 232 | # 'zh' user can custom change `jieba` dictionary path. 233 | # 234 | # html_search_options = {'type': 'default'} 235 | 236 | # The name of a javascript file (relative to the configuration directory) that 237 | # implements a search results scorer. If empty, the default will be used. 238 | # 239 | # html_search_scorer = 'scorer.js' 240 | 241 | # Output file base name for HTML help builder. 242 | htmlhelp_basename = 'aiohttp-admindoc' 243 | 244 | # -- Options for LaTeX output --------------------------------------------- 245 | 246 | latex_elements = { 247 | # The paper size ('letterpaper' or 'a4paper'). 248 | # 249 | # 'papersize': 'letterpaper', 250 | 251 | # The font size ('10pt', '11pt' or '12pt'). 252 | # 253 | # 'pointsize': '10pt', 254 | 255 | # Additional stuff for the LaTeX preamble. 256 | # 257 | # 'preamble': '', 258 | 259 | # Latex figure (float) alignment 260 | # 261 | # 'figure_align': 'htbp', 262 | } 263 | 264 | # Grouping the document tree into LaTeX files. List of tuples 265 | # (source start file, target name, title, 266 | # author, documentclass [howto, manual, or own class]). 267 | latex_documents = [ 268 | (master_doc, 'aiohttp-admin.tex', 'aiohttp-admin Documentation', 269 | 'Nikolay Novik', 'manual'), 270 | ] 271 | 272 | # The name of an image file (relative to this directory) to place at the top of 273 | # the title page. 274 | # 275 | # latex_logo = None 276 | 277 | # For "manual" documents, if this is true, then toplevel headings are parts, 278 | # not chapters. 279 | # 280 | # latex_use_parts = False 281 | 282 | # If true, show page references after internal links. 283 | # 284 | # latex_show_pagerefs = False 285 | 286 | # If true, show URL addresses after external links. 287 | # 288 | # latex_show_urls = False 289 | 290 | # Documents to append as an appendix to all manuals. 291 | # 292 | # latex_appendices = [] 293 | 294 | # It false, will not define \strong, \code, itleref, \crossref ... but only 295 | # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added 296 | # packages. 297 | # 298 | # latex_keep_old_macro_names = True 299 | 300 | # If false, no module index is generated. 301 | # 302 | # latex_domain_indices = True 303 | 304 | 305 | # -- Options for manual page output --------------------------------------- 306 | 307 | # One entry per manual page. List of tuples 308 | # (source start file, name, description, authors, manual section). 309 | man_pages = [ 310 | (master_doc, 'aiohttp-admin', 'aiohttp-admin Documentation', 311 | [author], 1) 312 | ] 313 | 314 | # If true, show URL addresses after external links. 315 | # 316 | # man_show_urls = False 317 | 318 | 319 | # -- Options for Texinfo output ------------------------------------------- 320 | 321 | # Grouping the document tree into Texinfo files. List of tuples 322 | # (source start file, target name, title, author, 323 | # dir menu entry, description, category) 324 | texinfo_documents = [ 325 | (master_doc, 'aiohttp-admin', 'aiohttp-admin Documentation', 326 | author, 'aiohttp-admin', 'One line description of project.', 327 | 'Miscellaneous'), 328 | ] 329 | 330 | # Documents to append as an appendix to all manuals. 331 | # 332 | # texinfo_appendices = [] 333 | 334 | # If false, no module index is generated. 335 | # 336 | # texinfo_domain_indices = True 337 | 338 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 339 | # 340 | # texinfo_show_urls = 'footnote' 341 | 342 | # If true, do not generate a @detailmenu in the "Top" node's menu. 343 | # 344 | # texinfo_no_detailmenu = False 345 | 346 | 347 | # Example configuration for intersphinx: refer to the Python standard library. 348 | intersphinx_mapping = {'https://docs.python.org/': None} 349 | -------------------------------------------------------------------------------- /docs/contents.rst.inc: -------------------------------------------------------------------------------- 1 | Documentation 2 | ------------- 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | contributing 8 | design 9 | api 10 | 11 | 12 | Additional Information 13 | ---------------------- 14 | 15 | .. toctree:: 16 | :maxdepth: 1 17 | 18 | changelog 19 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-admin/7cf41ca022bef2936a4ed77b33891be99a39d3b6/docs/demo.gif -------------------------------------------------------------------------------- /docs/design.rst: -------------------------------------------------------------------------------- 1 | Design 2 | ------ 3 | 4 | **aiohttp_admin** using following design philosophy: 5 | 6 | - backend and frontend of admin views are decoupled by REST API as 7 | result it is possible to change admin views without changing any **python** 8 | code. On browser side user interacts with single page application (ng-admin). 9 | 10 | - admin views are database agnostic, if it is possible to implement REST API 11 | it should be strait forward to add admin views. Some filtering features may 12 | be disabled if database do not support some kind of filtering. 13 | 14 | 15 | .. image:: diagram2.svg 16 | :align: center 17 | -------------------------------------------------------------------------------- /docs/diagram.svg: -------------------------------------------------------------------------------- 1 | 2 |
Model 
(pg, mysql with sqlalchemy.core or mongo with trafaret)
Model&nbsp;<div>(pg, mysql with sqlalchemy.core or mongo with trafaret)</div>
REST API
(aiohttp)
[Not supported by viewer]
Admin View 
ng-admin)
[Not supported by viewer]
Server
Server
Browser
Browser
-------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. aiohttp-admin documentation master file, created by 2 | sphinx-quickstart on Sun Nov 13 21:04:19 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to aiohttp-admin! 7 | ========================= 8 | 9 | .. image:: https://travis-ci.org/aio-libs/aiohttp_admin.svg?branch=master 10 | :target: https://travis-ci.org/aio-libs/aiohttp_admin 11 | .. image:: https://codecov.io/gh/aio-libs/aiohttp_admin/branch/master/graph/badge.svg 12 | :target: https://codecov.io/gh/aio-libs/aiohttp_admin 13 | 14 | 15 | **aiohttp_admin** will help you on building an admin interface 16 | on top of an existing data model. Library designed to be database agnostic and 17 | decoupled of any ORM or datbase layer. Admin module relies on async/await syntax (PEP492) 18 | thus *not* compatible with Python older than 3.5. 19 | 20 | 21 | **What is aiohttp-admin use cases?** 22 | 23 | - For small web applications or micro services, where custom admin interface is overkill. 24 | - To give a manager something to play with while proper admin interface is not ready. 25 | - Could be solution if you absolutely hate to write a lot of js/html but have to 26 | 27 | 28 | .. image:: demo.gif 29 | :align: center 30 | 31 | Features 32 | -------- 33 | 34 | - designed to be used with aiohttp; 35 | - library supports multiple database, out of the box MySQL, PostgreSQL, Mongodb; 36 | - clear separation of backend and frontend layers; 37 | - no WTForms, frontend is SPA; 38 | - uvloop_ compatible, tests executed with both: default and uvloop 39 | - database agnostic, if you can represent your entities with REST api, you can build admin views. 40 | 41 | 42 | .. include:: contents.rst.inc 43 | 44 | 45 | Ask Question 46 | ------------ 47 | Please feel free to ask question in `mail list `_ 48 | or raise issue on `github `_ 49 | 50 | Requirements 51 | ------------ 52 | 53 | * Python_ 3.5+ 54 | * PostgreSQL with, aiopg_ and sqlalchemy.core_ 55 | * MySQL with aiomysql_ and sqlalchemy.core_ 56 | * Mongodb with motor_ 57 | 58 | .. _Python: https://www.python.org 59 | .. _asyncio: http://docs.python.org/3.4/library/asyncio.html 60 | .. _uvloop: https://github.com/MagicStack/uvloop 61 | .. _aiopg: https://github.com/aio-libs/aiopg 62 | .. _aiomysql: https://github.com/aio-libs/aiomysql 63 | .. _motor: https://github.com/mongodb/motor 64 | .. _sqlalchemy.core: http://www.sqlalchemy.org/ 65 | .. _PEP492: https://www.python.org/dev/peps/pep-0492/ 66 | .. _docker: https://www.docker.com/ 67 | .. _instruction: https://docs.docker.com/engine/installation/linux/ubuntulinux/ 68 | .. _docker-machine: https://docs.docker.com/machine/ 69 | 70 | Indices and tables 71 | ================== 72 | 73 | * :ref:`genindex` 74 | * :ref:`modindex` 75 | * :ref:`search` 76 | -------------------------------------------------------------------------------- /examples/demo/README: -------------------------------------------------------------------------------- 1 | To build a custom component: 2 | 3 | First we need to replace some of our dependencies with a shim (ensure shim/ is copied 4 | to your project directory). 5 | In package.json, update the dependencies which are available in the shim/ directory: 6 | 7 | "react": "file:./shim/react", 8 | "react-admin": "file:./shim/react-admin", 9 | "react-dom": "file:./shim/react-dom", 10 | 11 | Also repeat these in a 'resolutions' config: 12 | 13 | "resolutions": { 14 | "react": "file:./shim/react", 15 | "react-admin": "file:./shim/react-admin", 16 | "react-dom": "file:./shim/react-dom", 17 | "react-router-dom": "file:./shim/react-router-dom", 18 | "query-string": "file:./shim/query-string" 19 | }, 20 | 21 | Using the shim for atleast react-admin is required, otherwise the components will 22 | end up using different contexts to the application and will fail to function. 23 | Using the shim for other libraries is recommended as it will significantly reduce 24 | the size of your compiled module. 25 | 26 | 27 | 28 | Second, we need to ensure that it is built as an ES6 module. To achieve this, add 29 | craco to the dependencies: 30 | 31 | "@craco/craco": "^7.1.0", 32 | 33 | Then create a craco.config.js file: 34 | 35 | module.exports = { 36 | webpack: { 37 | configure: { 38 | output: { 39 | library: { 40 | type: "module" 41 | } 42 | }, 43 | experiments: {outputModule: true} 44 | } 45 | } 46 | } 47 | 48 | And replace `react-scripts` with `craco` in the 'scripts' config: 49 | 50 | "scripts": { 51 | "start": "craco start", 52 | "build": "craco build", 53 | "test": "craco test", 54 | "eject": "craco eject" 55 | }, 56 | 57 | 58 | Then the components can be built as normal: 59 | 60 | yarn install 61 | yarn build 62 | -------------------------------------------------------------------------------- /examples/demo/admin-js/craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpack: { 3 | configure: { 4 | output: { 5 | library: { 6 | type: 'module' 7 | } 8 | }, 9 | experiments: {outputModule: true} 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/demo/admin-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admin-js", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@craco/craco": "^7.1.0", 7 | "@emotion/react": "^11.11.1", 8 | "@emotion/styled": "^11.11.0", 9 | "@mui/icons-material": "^5.14.14", 10 | "@mui/material": "^5.14.14", 11 | "@testing-library/jest-dom": "^5.14.1", 12 | "@testing-library/react": "^13.0.0", 13 | "@testing-library/user-event": "^13.2.1", 14 | "query-string": "file:./shim/query-string", 15 | "react": "file:./shim/react", 16 | "react-admin": "file:./shim/react-admin", 17 | "react-dom": "file:./shim/react-dom", 18 | "react-router-dom": "file:./shim/react-router-dom", 19 | "react-scripts": "5.0.1", 20 | "web-vitals": "^2.1.0" 21 | }, 22 | "resolutions": { 23 | "react": "file:./shim/react", 24 | "react-admin": "file:./shim/react-admin", 25 | "react-dom": "file:./shim/react-dom", 26 | "react-router-dom": "file:./shim/react-router-dom", 27 | "query-string": "file:./shim/query-string" 28 | }, 29 | "scripts": { 30 | "start": "craco start", 31 | "build": "craco build && (rm ../static/*.js.map || true) && mv build/static/js/main.*.js ../static/admin.js && mv build/static/js/main.*.js.map ../static/ && rm -rf build/", 32 | "test": "craco test", 33 | "eject": "craco eject" 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/demo/admin-js/public/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-admin/7cf41ca022bef2936a4ed77b33891be99a39d3b6/examples/demo/admin-js/public/index.html -------------------------------------------------------------------------------- /examples/demo/admin-js/shim/query-string/index.js: -------------------------------------------------------------------------------- 1 | module.exports = window.QueryString; 2 | -------------------------------------------------------------------------------- /examples/demo/admin-js/shim/react-admin/index.js: -------------------------------------------------------------------------------- 1 | module.exports = window.ReactAdmin; 2 | -------------------------------------------------------------------------------- /examples/demo/admin-js/shim/react-dom/index.js: -------------------------------------------------------------------------------- 1 | module.exports = window.ReactDOM; 2 | -------------------------------------------------------------------------------- /examples/demo/admin-js/shim/react-router-dom/index.js: -------------------------------------------------------------------------------- 1 | module.exports = window.ReactRouterDOM; 2 | -------------------------------------------------------------------------------- /examples/demo/admin-js/shim/react/index.js: -------------------------------------------------------------------------------- 1 | module.exports = window.React; 2 | -------------------------------------------------------------------------------- /examples/demo/admin-js/shim/react/jsx-runtime.js: -------------------------------------------------------------------------------- 1 | module.exports = window.ReactJSXRuntime; 2 | -------------------------------------------------------------------------------- /examples/demo/admin-js/src/index.js: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import Queue from '@mui/icons-material/Queue'; 3 | import { Link } from 'react-router-dom'; 4 | import { stringify } from 'query-string'; 5 | import { useResourceContext, useRecordContext, useCreatePath, Button } from 'react-admin'; 6 | 7 | export const CustomCloneButton = (props: CloneButtonProps) => { 8 | const { 9 | label = 'CUSTOM CLONE', 10 | scrollToTop = true, 11 | icon = defaultIcon, 12 | ...rest 13 | } = props; 14 | const resource = useResourceContext(props); 15 | const record = useRecordContext(props); 16 | const createPath = useCreatePath(); 17 | const pathname = createPath({ resource, type: 'create' }); 18 | return ( 19 | 38 | ); 39 | }; 40 | 41 | const defaultIcon = ; 42 | 43 | const stopPropagation = e => e.stopPropagation(); 44 | 45 | const omitId = ({ id, ...rest }) => rest; 46 | 47 | const sanitizeRestProps = ({ 48 | resource, 49 | record, 50 | ...rest 51 | }) => rest; 52 | 53 | export const components = {CustomCloneButton: memo(CustomCloneButton)}; 54 | -------------------------------------------------------------------------------- /examples/demo/app.py: -------------------------------------------------------------------------------- 1 | """Demo application. 2 | 3 | When running this file, admin will be accessible at /admin. 4 | """ 5 | 6 | import sqlalchemy as sa 7 | from aiohttp import web 8 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 9 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 10 | 11 | import aiohttp_admin 12 | from aiohttp_admin.backends.sqlalchemy import SAResource 13 | from aiohttp_admin.types import comp 14 | 15 | 16 | class Base(DeclarativeBase): 17 | """Base model.""" 18 | 19 | 20 | class User(Base): 21 | __tablename__ = "user" 22 | 23 | id: Mapped[int] = mapped_column(primary_key=True) 24 | username: Mapped[str] = mapped_column(sa.String(32)) 25 | email: Mapped[str | None] 26 | note: Mapped[str | None] 27 | votes: Mapped[int] = mapped_column() 28 | 29 | __table_args__ = (sa.CheckConstraint(sa.func.char_length(username) >= 3), 30 | sa.CheckConstraint(votes >= 1), sa.CheckConstraint(votes < 6), 31 | sa.CheckConstraint(votes % 2 == 1)) 32 | 33 | 34 | async def check_credentials(username: str, password: str) -> bool: 35 | return username == "admin" and password == "admin" 36 | 37 | 38 | async def create_app() -> web.Application: 39 | engine = create_async_engine("sqlite+aiosqlite:///:memory:") 40 | session = async_sessionmaker(engine, expire_on_commit=False) 41 | 42 | # Create some sample data 43 | async with engine.begin() as conn: 44 | await conn.run_sync(Base.metadata.create_all) 45 | async with session.begin() as sess: 46 | sess.add(User(username="Foo", votes=5)) 47 | sess.add(User(username="Spam", votes=1, note="Second user")) 48 | 49 | app = web.Application() 50 | app["static_root_url"] = "/static" 51 | app.router.add_static("/static", "static", name="static") 52 | 53 | # This is the setup required for aiohttp-admin. 54 | schema: aiohttp_admin.Schema = { 55 | "security": { 56 | "check_credentials": check_credentials, 57 | "secure": False 58 | }, 59 | "resources": ({"model": SAResource(engine, User), 60 | "show_actions": (comp("CustomCloneButton"),)},), 61 | # Use our JS module to include our custom validator. 62 | "js_module": str(app.router["static"].url_for(filename="admin.js")) 63 | } 64 | aiohttp_admin.setup(app, schema) 65 | 66 | return app 67 | 68 | if __name__ == "__main__": 69 | web.run_app(create_app()) 70 | -------------------------------------------------------------------------------- /examples/permissions.py: -------------------------------------------------------------------------------- 1 | """Example to demonstrate usage of permissions. 2 | 3 | When running this file, admin will be accessible at /admin. 4 | Check near the bottom of the file for valid usernames (and their respective permissions), 5 | login will work with any password. 6 | """ 7 | 8 | import json 9 | from datetime import datetime 10 | from functools import partial 11 | 12 | import sqlalchemy as sa 13 | from aiohttp import web 14 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine 15 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 16 | 17 | import aiohttp_admin 18 | from aiohttp_admin import Permissions, UserDetails, permission_re_key 19 | from aiohttp_admin.backends.sqlalchemy import SAResource, permission_for as p 20 | 21 | db = web.AppKey("db", async_sessionmaker[AsyncSession]) 22 | 23 | 24 | class Base(DeclarativeBase): 25 | """Base model.""" 26 | 27 | 28 | class Simple(Base): 29 | __tablename__ = "simple" 30 | 31 | id: Mapped[int] = mapped_column(primary_key=True) 32 | num: Mapped[int] 33 | optional_num: Mapped[float | None] 34 | value: Mapped[str] 35 | 36 | 37 | class SimpleParent(Base): 38 | __tablename__ = "parent" 39 | 40 | id: Mapped[int] = mapped_column(sa.ForeignKey(Simple.id, ondelete="CASCADE"), 41 | primary_key=True) 42 | date: Mapped[datetime] 43 | 44 | 45 | class User(Base): 46 | __tablename__ = "user" 47 | 48 | username: Mapped[str] = mapped_column(primary_key=True) 49 | permissions: Mapped[str] 50 | 51 | 52 | async def check_credentials(app: web.Application, username: str, password: str) -> bool: 53 | """Allow login to any user account regardless of password.""" 54 | async with app[db]() as sess: 55 | user = await sess.get(User, username.lower()) 56 | return user is not None 57 | 58 | 59 | async def identity_callback(app: web.Application, identity: str) -> UserDetails: 60 | async with app[db]() as sess: 61 | user = await sess.get(User, identity) 62 | if not user: 63 | raise ValueError("No user found for given identity") 64 | return {"permissions": json.loads(user.permissions), "fullName": user.username.title()} 65 | 66 | 67 | async def create_app() -> web.Application: 68 | engine = create_async_engine("sqlite+aiosqlite:///:memory:") 69 | session = async_sessionmaker(engine, expire_on_commit=False) 70 | 71 | # Create some sample data 72 | async with engine.begin() as conn: 73 | await conn.run_sync(Base.metadata.create_all) 74 | async with session.begin() as sess: 75 | sess.add(Simple(num=5, value="first")) 76 | p_simple = Simple(num=82, optional_num=12, value="with child") 77 | sess.add(p_simple) 78 | sess.add(Simple(num=5, value="second")) 79 | sess.add(Simple(num=5, value="3")) 80 | sess.add(Simple(num=5, optional_num=42, value="4")) 81 | sess.add(Simple(num=5, value="5")) 82 | async with session.begin() as sess: 83 | sess.add(SimpleParent(id=p_simple.id, date=datetime(2023, 2, 13, 19, 4))) 84 | 85 | app = web.Application() 86 | app[db] = session 87 | 88 | # This is the setup required for aiohttp-admin. 89 | schema: aiohttp_admin.Schema = { 90 | "security": { 91 | "check_credentials": partial(check_credentials, app), 92 | "identity_callback": partial(identity_callback, app), 93 | "secure": False 94 | }, 95 | "resources": ( 96 | {"model": SAResource(engine, Simple), 97 | "display": (Simple.id.name, Simple.num.name, Simple.optional_num.name), 98 | "bulk_update": {"Set to 7": {Simple.optional_num.name: 7}}}, 99 | {"model": SAResource(engine, SimpleParent)} 100 | ) 101 | } 102 | admin = aiohttp_admin.setup(app, schema) 103 | 104 | # Create users with various permissions. 105 | async with session.begin() as sess: 106 | sess.add(User(username="admin", permissions=json.dumps((Permissions.all,)))) 107 | sess.add(User(username="view", permissions=json.dumps((Permissions.view,)))) 108 | sess.add(User(username="add", permissions=json.dumps( 109 | (Permissions.view, Permissions.add,)))) 110 | sess.add(User(username="edit", permissions=json.dumps( 111 | (Permissions.view, Permissions.edit)))) 112 | sess.add(User(username="delete", permissions=json.dumps( 113 | (Permissions.view, Permissions.delete)))) 114 | users = { 115 | "simple": (p(Simple),), 116 | "mixed": (p(Simple, "view"), p(Simple, "edit"), p(SimpleParent, "view")), 117 | "negated": (Permissions.all, p(SimpleParent, negated=True), 118 | p(Simple, "edit", negated=True)), 119 | "field": (Permissions.all, p(Simple.optional_num, negated=True)), 120 | "field_edit": (Permissions.all, p(Simple.optional_num, "edit", negated=True)), 121 | "filter": (Permissions.all, p(Simple, filters={Simple.num: 5})), 122 | "filter_edit": (Permissions.all, p(Simple, "edit", filters={Simple.num: 5})), 123 | "filter_add": (Permissions.all, p(Simple, "add", filters={Simple.num: 5})), 124 | "filter_delete": (Permissions.all, p(Simple, "delete", filters={Simple.num: 5})), 125 | "filter_field": (Permissions.all, p(Simple.optional_num, filters={Simple.num: 5})), 126 | "filter_field_edit": (Permissions.all, p(Simple.optional_num, "edit", 127 | filters={Simple.num: 5})) 128 | } 129 | for name, permissions in users.items(): 130 | if any(admin[permission_re_key].fullmatch(p) is None for p in permissions): 131 | raise ValueError("Not a valid permission.") 132 | sess.add(User(username=name, permissions=json.dumps(permissions))) 133 | 134 | return app 135 | 136 | if __name__ == "__main__": 137 | web.run_app(create_app()) 138 | -------------------------------------------------------------------------------- /examples/relationships.py: -------------------------------------------------------------------------------- 1 | """Example that demonstrates use of various foreign key relationships. 2 | 3 | An example of each SQLAlchemy relationship is included. 4 | However, the many to many relationship requires the react-admin enterprise-edition 5 | (not currently supported by aiohttp-admin). 6 | https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html 7 | 8 | When running this file, admin will be accessible at /admin. 9 | """ 10 | 11 | import sqlalchemy as sa 12 | from aiohttp import web 13 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 14 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 15 | 16 | import aiohttp_admin 17 | from aiohttp_admin.backends.sqlalchemy import SAResource 18 | 19 | 20 | class Base(DeclarativeBase): 21 | """Base model.""" 22 | 23 | 24 | class OneToManyParent(Base): 25 | __tablename__ = "onetomany_parent" 26 | 27 | id: Mapped[int] = mapped_column(primary_key=True) 28 | name: Mapped[str] 29 | value: Mapped[int] 30 | children: Mapped[list["OneToManyChild"]] = relationship(back_populates="parent") 31 | 32 | 33 | class OneToManyChild(Base): 34 | __tablename__ = "onetomany_child" 35 | 36 | id: Mapped[int] = mapped_column(primary_key=True) 37 | name: Mapped[str] 38 | value: Mapped[int] 39 | parent_id: Mapped[int] = mapped_column(sa.ForeignKey(OneToManyParent.id)) 40 | parent: Mapped[OneToManyParent] = relationship(back_populates="children") 41 | 42 | 43 | class ManyToOneParent(Base): 44 | __tablename__ = "manytoone_parent" 45 | 46 | id: Mapped[int] = mapped_column(primary_key=True) 47 | name: Mapped[str] 48 | value: Mapped[int] 49 | child_id: Mapped[int | None] = mapped_column(sa.ForeignKey("manytoone_child.id")) 50 | child: Mapped["ManyToOneChild | None"] = relationship(back_populates="parents") 51 | 52 | 53 | class ManyToOneChild(Base): 54 | __tablename__ = "manytoone_child" 55 | 56 | id: Mapped[int] = mapped_column(primary_key=True) 57 | name: Mapped[str] 58 | value: Mapped[int] 59 | parents: Mapped[list[ManyToOneParent]] = relationship(back_populates="child") 60 | 61 | 62 | class OneToOneParent(Base): 63 | __tablename__ = "onetoone_parent" 64 | 65 | id: Mapped[int] = mapped_column(primary_key=True) 66 | name: Mapped[str] 67 | value: Mapped[int] 68 | child: Mapped["OneToOneChild"] = relationship(back_populates="parent") 69 | 70 | 71 | class OneToOneChild(Base): 72 | __tablename__ = "onetoone_child" 73 | 74 | id: Mapped[int] = mapped_column(primary_key=True) 75 | name: Mapped[str] 76 | value: Mapped[int] 77 | parent_id: Mapped[int] = mapped_column(sa.ForeignKey(OneToOneParent.id)) 78 | parent: Mapped[OneToOneParent] = relationship(back_populates="child") 79 | 80 | 81 | association_table = sa.Table( 82 | "association_table", 83 | Base.metadata, 84 | sa.Column("left_id", sa.ForeignKey("manytomany_left.id"), primary_key=True), 85 | sa.Column("right_id", sa.ForeignKey("manytomany_right.id"), primary_key=True), 86 | ) 87 | 88 | 89 | class ManyToManyParent(Base): 90 | __tablename__ = "manytomany_left" 91 | 92 | id: Mapped[int] = mapped_column(primary_key=True) 93 | name: Mapped[str] 94 | value: Mapped[int] 95 | children: Mapped[list["ManyToManyChild"]] = relationship(secondary=association_table, 96 | back_populates="parents") 97 | 98 | 99 | class ManyToManyChild(Base): 100 | __tablename__ = "manytomany_right" 101 | 102 | id: Mapped[int] = mapped_column(primary_key=True) 103 | name: Mapped[str] 104 | value: Mapped[int] 105 | parents: Mapped[list[ManyToManyParent]] = relationship(secondary=association_table, 106 | back_populates="children") 107 | 108 | 109 | class CompositeForeignKeyChild(Base): 110 | __tablename__ = "composite_foreign_key_child" 111 | 112 | num: Mapped[int] = mapped_column(primary_key=True) 113 | ref_num: Mapped[int] = mapped_column(primary_key=True) 114 | description: Mapped[str] = mapped_column(sa.String(64)) 115 | 116 | parents: Mapped[list["CompositeForeignKeyParent"]] = relationship(back_populates="child") 117 | 118 | 119 | class CompositeForeignKeyParent(Base): 120 | __tablename__ = "composite_foreign_key_parent" 121 | 122 | item_id: Mapped[int] = mapped_column(primary_key=True) 123 | item_name: Mapped[str] = mapped_column(sa.String(64)) 124 | child_id: Mapped[int] 125 | ref_num: Mapped[int] 126 | 127 | child: Mapped[CompositeForeignKeyChild] = relationship(back_populates="parents") 128 | 129 | @sa.orm.declared_attr.directive 130 | @classmethod 131 | def __table_args__(cls) -> tuple[sa.schema.SchemaItem, ...]: 132 | return (sa.ForeignKeyConstraint( 133 | ["child_id", "ref_num"], 134 | ["composite_foreign_key_child.num", "composite_foreign_key_child.ref_num"] 135 | ),) 136 | 137 | 138 | async def check_credentials(username: str, password: str) -> bool: 139 | return username == "admin" and password == "admin" 140 | 141 | 142 | async def create_app() -> web.Application: 143 | engine = create_async_engine("sqlite+aiosqlite:///:memory:") 144 | session = async_sessionmaker(engine, expire_on_commit=False) 145 | 146 | # Create some sample data 147 | async with engine.begin() as conn: 148 | await conn.run_sync(Base.metadata.create_all) 149 | async with session.begin() as sess: 150 | sess.add(OneToManyParent(name="Foo", value=1)) 151 | onetomany_1 = OneToManyParent(name="Bar", value=2) 152 | sess.add(onetomany_1) 153 | manytoone_1 = ManyToOneChild(name="Child Foo", value=4) 154 | sess.add(manytoone_1) 155 | onetoone_1 = OneToOneParent(name="Foo", value=3) 156 | sess.add(onetoone_1) 157 | onetoone_2 = OneToOneParent(name="Bar", value=5) 158 | sess.add(onetoone_2) 159 | manytomany_p1 = ManyToManyParent(name="Foo", value=2) 160 | manytomany_p2 = ManyToManyParent(name="Bar", value=3) 161 | manytomany_c1 = ManyToManyChild(name="Foo Child", value=5) 162 | manytomany_c2 = ManyToManyChild(name="Bar Child", value=6) 163 | manytomany_c3 = ManyToManyChild(name="Baz Child", value=7) 164 | manytomany_p1.children.append(manytomany_c1) 165 | manytomany_p1.children.append(manytomany_c2) 166 | manytomany_p2.children.append(manytomany_c1) 167 | manytomany_p2.children.append(manytomany_c2) 168 | manytomany_p2.children.append(manytomany_c3) 169 | sess.add(manytomany_p1) 170 | sess.add(manytomany_p2) 171 | sess.add(manytomany_c1) 172 | sess.add(manytomany_c2) 173 | composite_child_1 = CompositeForeignKeyChild(num=0, ref_num=0, description="A") 174 | composite_child_2 = CompositeForeignKeyChild(num=0, ref_num=1, description="B") 175 | composite_child_3 = CompositeForeignKeyChild(num=1, ref_num=0, description="C") 176 | sess.add(composite_child_1) 177 | sess.add(composite_child_2) 178 | sess.add(composite_child_3) 179 | sess.add(CompositeForeignKeyParent(item_name="Foo", child_id=0, ref_num=1)) 180 | sess.add(CompositeForeignKeyParent(item_name="Bar", child_id=1, ref_num=0)) 181 | async with session.begin() as sess: 182 | sess.add(OneToManyChild(name="Child Foo", value=1, parent_id=onetomany_1.id)) 183 | sess.add(OneToManyChild(name="Child Bar", value=5, parent_id=onetomany_1.id)) 184 | sess.add(ManyToOneParent(name="Foo", value=5, child_id=manytoone_1.id)) 185 | sess.add(ManyToOneParent(name="Bar", value=3)) 186 | sess.add(OneToOneChild(name="Child Foo", value=0, parent_id=onetoone_2.id)) 187 | sess.add(OneToOneChild(name="Child Bar", value=2, parent_id=onetoone_1.id)) 188 | 189 | app = web.Application() 190 | 191 | # This is the setup required for aiohttp-admin. 192 | schema: aiohttp_admin.Schema = { 193 | "security": { 194 | "check_credentials": check_credentials, 195 | "secure": False 196 | }, 197 | "resources": ( 198 | {"model": SAResource(engine, OneToManyParent), "repr": aiohttp_admin.data("name")}, 199 | {"model": SAResource(engine, OneToManyChild)}, 200 | {"model": SAResource(engine, ManyToOneParent), "repr": aiohttp_admin.data("name")}, 201 | {"model": SAResource(engine, ManyToOneChild)}, 202 | {"model": SAResource(engine, OneToOneParent), "repr": aiohttp_admin.data("name")}, 203 | {"model": SAResource(engine, OneToOneChild)}, 204 | {"model": SAResource(engine, ManyToManyParent)}, 205 | {"model": SAResource(engine, ManyToManyChild)}, 206 | {"model": SAResource(engine, CompositeForeignKeyChild), 207 | "repr": aiohttp_admin.data("description")}, 208 | {"model": SAResource(engine, CompositeForeignKeyParent)} 209 | ) 210 | } 211 | aiohttp_admin.setup(app, schema) 212 | 213 | return app 214 | 215 | if __name__ == "__main__": 216 | web.run_app(create_app()) 217 | -------------------------------------------------------------------------------- /examples/simple.py: -------------------------------------------------------------------------------- 1 | """Minimal example with simple database models. 2 | 3 | When running this file, admin will be accessible at /admin. 4 | """ 5 | 6 | from datetime import datetime 7 | from enum import Enum 8 | 9 | import sqlalchemy as sa 10 | from aiohttp import web 11 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 12 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 13 | 14 | import aiohttp_admin 15 | from aiohttp_admin.backends.sqlalchemy import SAResource 16 | 17 | # Example DB models 18 | 19 | 20 | class Currency(Enum): 21 | EUR = 1 22 | GBP = 2 23 | USD = 3 24 | 25 | 26 | class Base(DeclarativeBase): 27 | """Base model.""" 28 | 29 | 30 | class Simple(Base): 31 | __tablename__ = "simple" 32 | 33 | id: Mapped[int] = mapped_column(primary_key=True) 34 | num: Mapped[int] 35 | optional_num: Mapped[float | None] 36 | value: Mapped[str] 37 | 38 | 39 | class SimpleParent(Base): 40 | __tablename__ = "parent" 41 | 42 | id: Mapped[int] = mapped_column(sa.ForeignKey(Simple.id, ondelete="CASCADE"), 43 | primary_key=True) 44 | date: Mapped[datetime] 45 | currency: Mapped[Currency] = mapped_column(default="USD") 46 | 47 | 48 | async def check_credentials(username: str, password: str) -> bool: 49 | return username == "admin" and password == "admin" 50 | 51 | 52 | async def create_app() -> web.Application: 53 | engine = create_async_engine("sqlite+aiosqlite:///:memory:") 54 | session = async_sessionmaker(engine, expire_on_commit=False) 55 | 56 | # Create some sample data 57 | async with engine.begin() as conn: 58 | await conn.run_sync(Base.metadata.create_all) 59 | async with session.begin() as sess: 60 | sess.add(Simple(num=5, value="first")) 61 | p = Simple(num=82, optional_num=12, value="with child") 62 | sess.add(p) 63 | async with session.begin() as sess: 64 | sess.add(SimpleParent(id=p.id, date=datetime(2023, 2, 13, 19, 4))) 65 | 66 | app = web.Application() 67 | 68 | # This is the setup required for aiohttp-admin. 69 | schema: aiohttp_admin.Schema = { 70 | "security": { 71 | "check_credentials": check_credentials, 72 | "secure": False 73 | }, 74 | "resources": ( 75 | {"model": SAResource(engine, Simple), "repr": aiohttp_admin.data("value")}, 76 | {"model": SAResource(engine, SimpleParent)} 77 | ) 78 | } 79 | aiohttp_admin.setup(app, schema) 80 | 81 | return app 82 | 83 | if __name__ == "__main__": 84 | web.run_app(create_app()) 85 | -------------------------------------------------------------------------------- /examples/validators.py: -------------------------------------------------------------------------------- 1 | """Minimal example with simple database models. 2 | 3 | When running this file, admin will be accessible at /admin. 4 | """ 5 | 6 | import sqlalchemy as sa 7 | from aiohttp import web 8 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 9 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 10 | 11 | import aiohttp_admin 12 | from aiohttp_admin.backends.sqlalchemy import SAResource 13 | from aiohttp_admin.types import func, regex 14 | 15 | JS = """ 16 | const odd = (value, allValues) => { 17 | if (value % 2 === 0) 18 | return "Votes must be an odd number"; 19 | return undefined; 20 | }; 21 | 22 | export const functions = {odd}; 23 | """ 24 | 25 | 26 | class Base(DeclarativeBase): 27 | """Base model.""" 28 | 29 | 30 | class User(Base): 31 | __tablename__ = "user" 32 | 33 | id: Mapped[int] = mapped_column(primary_key=True) 34 | username: Mapped[str] = mapped_column(sa.String(32)) 35 | email: Mapped[str | None] 36 | note: Mapped[str | None] 37 | votes: Mapped[int] = mapped_column() 38 | 39 | __table_args__ = (sa.CheckConstraint(sa.func.char_length(username) >= 3), 40 | sa.CheckConstraint(votes >= 1), sa.CheckConstraint(votes < 6), 41 | sa.CheckConstraint(votes % 2 == 1)) 42 | 43 | 44 | async def check_credentials(username: str, password: str) -> bool: 45 | return username == "admin" and password == "admin" 46 | 47 | 48 | async def serve_js(request: web.Request) -> web.Response: 49 | return web.Response(text=JS, content_type="text/javascript") 50 | 51 | 52 | async def create_app() -> web.Application: 53 | engine = create_async_engine("sqlite+aiosqlite:///:memory:") 54 | session = async_sessionmaker(engine, expire_on_commit=False) 55 | 56 | # Create some sample data 57 | async with engine.begin() as conn: 58 | await conn.run_sync(Base.metadata.create_all) 59 | async with session.begin() as sess: 60 | sess.add(User(username="Foo", votes=5)) 61 | sess.add(User(username="Spam", votes=1, note="Second user")) 62 | 63 | app = web.Application() 64 | app.router.add_get("/js", serve_js, name="js") 65 | 66 | # This is the setup required for aiohttp-admin. 67 | schema: aiohttp_admin.Schema = { 68 | "security": { 69 | "check_credentials": check_credentials, 70 | "secure": False 71 | }, 72 | "resources": ({"model": SAResource(engine, User), 73 | "validators": {User.username.name: (func("regex", 74 | (regex(r"^[A-Z][a-z]+$"),)),), 75 | User.email.name: (func("email", ()),), 76 | # Custom validator from our JS module. 77 | # Min/Max validators are automatically included. 78 | User.votes.name: (func("odd"),)}},), 79 | # Use our JS module to include our custom validator. 80 | "js_module": str(app.router["js"].url_for()) 81 | } 82 | aiohttp_admin.setup(app, schema) 83 | 84 | return app 85 | 86 | if __name__ == "__main__": 87 | web.run_app(create_app()) 88 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | # show 10 slowest invocations: 4 | --durations=10 5 | # a bit of verbosity doesn't hurt: 6 | -v 7 | # report all the things == -rxXs: 8 | -ra 9 | # show values of the local vars in errors: 10 | --showlocals 11 | # coverage reports 12 | --cov=aiohttp_admin/ --cov=tests/ --cov-report term 13 | asyncio_mode = auto 14 | filterwarnings = 15 | error 16 | testpaths = tests/ 17 | xfail_strict = true 18 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | flake8==7.0.0 4 | flake8-bandit==4.1.1 5 | flake8-bugbear==24.12.12 6 | flake8-import-order==0.18.2 7 | flake8-requirements==2.2.1 8 | mypy==1.10.0 9 | sphinx==7.4.7 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | aiohttp==3.12.11 3 | aiohttp-security==0.5.0 4 | aiohttp-session[secure]==2.12.1 5 | aiosqlite==0.21.0 6 | cryptography==45.0.3 7 | pydantic==2.11.5 8 | pytest==8.4.0 9 | pytest-aiohttp==1.1.0 10 | pytest-cov==6.1.1 11 | sqlalchemy==2.0.41 12 | typing_extensions>=3.10; python_version<"3.12" 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from pathlib import Path 4 | 5 | from setuptools import find_packages, setup 6 | 7 | if not sys.version_info >= (3, 9): 8 | raise RuntimeError("aiohttp_admin doesn't support Python earlier than 3.9") 9 | 10 | 11 | def read_version(): 12 | regexp = re.compile(r'^__version__\W*=\W*"([\d.abrc]+)"') 13 | init_py = Path(__file__).parent / "aiohttp_admin" / "__init__.py" 14 | with init_py.open() as f: 15 | for line in f: 16 | match = regexp.match(line) 17 | if match is not None: 18 | return match.group(1) 19 | raise RuntimeError("Cannot find version in aiohttp_admin/__init__.py") 20 | 21 | 22 | classifiers = ( 23 | "License :: OSI Approved :: Apache Software License", 24 | "Intended Audience :: Developers", 25 | "Programming Language :: Python :: 3", 26 | "Development Status :: 3 - Alpha", 27 | "Topic :: Internet :: WWW/HTTP", 28 | "Framework :: AsyncIO", 29 | "Framework :: aiohttp", 30 | ) 31 | 32 | 33 | setup(name="aiohttp-admin", 34 | version=read_version(), 35 | description="admin interface for aiohttp application", 36 | long_description="\n\n".join((Path("README.rst").read_text(), 37 | Path("CHANGES.rst").read_text())), 38 | classifiers=classifiers, 39 | url="https://github.com/aio-libs/aiohttp-admin", 40 | download_url="https://github.com/aio-libs/aiohttp-admin", 41 | license="Apache 2", 42 | packages=find_packages(), 43 | install_requires=("aiohttp>=3.9", "aiohttp_security", "aiohttp_session", 44 | "cryptography", "pydantic>2,<3", 45 | 'typing_extensions>=3.10; python_version<"3.12"'), 46 | extras_require={"sa": ["sqlalchemy>=2.0.4,<3"]}, 47 | include_package_data=True) 48 | -------------------------------------------------------------------------------- /tests/_auth.py: -------------------------------------------------------------------------------- 1 | async def check_credentials(username: str, password: str) -> bool: 2 | return username == "admin" and password == "admin123" 3 | -------------------------------------------------------------------------------- /tests/_resources.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from aiohttp_admin.backends.abc import (AbstractAdminResource, GetListParams, 4 | GetManyRefParams, Meta, Record) 5 | from aiohttp_admin.types import ComponentState, InputState 6 | 7 | 8 | class DummyResource(AbstractAdminResource[tuple[str]]): 9 | def __init__(self, name: str, fields: dict[str, ComponentState], 10 | inputs: dict[str, InputState], primary_key: str): 11 | self.name = name 12 | self.fields = fields 13 | self.inputs = inputs 14 | self.primary_key = (primary_key,) 15 | self.omit_fields = set() 16 | self._id_type = tuple[str] # type: ignore[assignment] 17 | self._foreign_rows = set() 18 | super().__init__() 19 | 20 | async def get_list(self, params: GetListParams) -> tuple[list[Record], int]: # pragma: no cover 21 | raise NotImplementedError() 22 | 23 | async def get_one(self, record_id: tuple[str], meta: Meta) -> Record: # pragma: no cover 24 | raise NotImplementedError() 25 | 26 | async def get_many(self, record_ids: Sequence[tuple[str]], meta: Meta) -> list[Record]: # pragma: no cover 27 | raise NotImplementedError() 28 | 29 | async def get_many_ref(self, params: GetManyRefParams) -> tuple[list[Record], int]: # pragma: no cover 30 | raise NotImplementedError() 31 | 32 | async def update( # pragma: no cover 33 | self, record_id: tuple[str], data: Record, previous_data: Record, meta: Meta 34 | ) -> Record: 35 | raise NotImplementedError() 36 | 37 | async def update_many( # pragma: no cover 38 | self, record_ids: Sequence[tuple[str]], data: Record, meta: Meta 39 | ) -> list[tuple[str]]: 40 | raise NotImplementedError() 41 | 42 | async def create(self, data: Record, meta: Meta) -> Record: # pragma: no cover 43 | raise NotImplementedError() 44 | 45 | async def delete(self, record_id: tuple[str], previous_data: Record, meta: Meta) -> Record: # pragma: no cover 46 | raise NotImplementedError() 47 | 48 | async def delete_many( # pragma: no cover 49 | self, record_ids: Sequence[tuple[str]], meta: Meta 50 | ) -> list[tuple[str]]: 51 | raise NotImplementedError() 52 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Callable 2 | from typing import Optional 3 | from unittest.mock import AsyncMock, create_autospec 4 | 5 | import pytest 6 | import sqlalchemy as sa 7 | from aiohttp import web 8 | from aiohttp.test_utils import TestClient 9 | from pytest_aiohttp import AiohttpClient 10 | from sqlalchemy.ext.asyncio import (AsyncEngine, AsyncSession, 11 | async_sessionmaker, create_async_engine) 12 | from sqlalchemy.orm import DeclarativeBaseNoMeta, Mapped, mapped_column, relationship 13 | 14 | import aiohttp_admin 15 | from _auth import check_credentials 16 | from aiohttp_admin.backends.sqlalchemy import SAResource 17 | 18 | IdentityCallback = Callable[[Optional[str]], Awaitable[aiohttp_admin.UserDetails]] 19 | _Client = TestClient[web.Request, web.Application] 20 | 21 | 22 | class Base(DeclarativeBaseNoMeta): 23 | """Base model.""" 24 | 25 | 26 | class DummyModel(Base): 27 | __tablename__ = "dummy" 28 | 29 | id: Mapped[int] = mapped_column(primary_key=True) 30 | 31 | foreigns: Mapped[list["ForeignModel"]] = relationship() 32 | 33 | 34 | class Dummy2Model(Base): 35 | __tablename__ = "dummy2" 36 | 37 | id: Mapped[int] = mapped_column(primary_key=True) 38 | msg: Mapped[Optional[str]] 39 | 40 | 41 | class ForeignModel(Base): 42 | __tablename__ = "foreign" 43 | 44 | id: Mapped[int] = mapped_column(primary_key=True) 45 | dummy: Mapped[int] = mapped_column(sa.ForeignKey(DummyModel.id)) 46 | 47 | 48 | model = web.AppKey[type[DummyModel]]("model") 49 | model2 = web.AppKey[type[Dummy2Model]]("model2") 50 | db = web.AppKey("db", async_sessionmaker[AsyncSession]) 51 | admin = web.AppKey("admin", web.Application) 52 | 53 | 54 | @pytest.fixture 55 | def mock_engine() -> AsyncMock: 56 | return create_autospec(AsyncEngine, instance=True, spec_set=True) # type: ignore[no-any-return] # noqa: B950 57 | 58 | 59 | @pytest.fixture 60 | def create_admin_client( 61 | aiohttp_client: AiohttpClient 62 | ) -> Callable[[Optional[IdentityCallback]], Awaitable[_Client]]: 63 | async def admin_client(identity_callback: Optional[IdentityCallback] = None) -> _Client: 64 | app = web.Application() 65 | app[model] = DummyModel 66 | app[model2] = Dummy2Model 67 | engine = create_async_engine("sqlite+aiosqlite:///:memory:") 68 | app[db] = async_sessionmaker(engine, expire_on_commit=False) 69 | async with engine.begin() as conn: 70 | await conn.run_sync(Base.metadata.create_all) 71 | async with app[db].begin() as sess: 72 | dummy = DummyModel() 73 | sess.add(dummy) 74 | sess.add(Dummy2Model(msg="Test")) 75 | sess.add(Dummy2Model(msg="Test")) 76 | sess.add(Dummy2Model(msg="Other")) 77 | async with app[db].begin() as sess: 78 | sess.add(ForeignModel(dummy=dummy.id)) 79 | 80 | schema: aiohttp_admin.Schema = { 81 | "security": { 82 | "check_credentials": check_credentials, 83 | "secure": False 84 | }, 85 | "resources": ( 86 | {"model": SAResource(engine, DummyModel)}, 87 | {"model": SAResource(engine, Dummy2Model)}, 88 | {"model": SAResource(engine, ForeignModel)} 89 | ) 90 | } 91 | if identity_callback: 92 | schema["security"]["identity_callback"] = identity_callback 93 | app[admin] = aiohttp_admin.setup(app, schema) 94 | 95 | return await aiohttp_client(app) 96 | 97 | return admin_client 98 | 99 | 100 | @pytest.fixture 101 | async def admin_client(create_admin_client: Callable[[], Awaitable[_Client]]) -> _Client: 102 | return await create_admin_client() 103 | 104 | 105 | @pytest.fixture 106 | def login() -> Callable[[_Client], Awaitable[dict[str, str]]]: 107 | async def do_login(admin_client: _Client) -> dict[str, str]: 108 | assert admin_client.app 109 | url = admin_client.app[admin].router["token"].url_for() 110 | login = {"username": "admin", "password": "admin123"} 111 | async with admin_client.post(url, json=login) as resp: 112 | assert resp.status == 200 113 | token = resp.headers["X-Token"] 114 | 115 | return {"Authorization": token} 116 | 117 | return do_login 118 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiohttp import web 3 | 4 | import aiohttp_admin 5 | from _auth import check_credentials 6 | from _resources import DummyResource 7 | from aiohttp_admin.types import comp, func, permission_re_key, state_key 8 | 9 | 10 | def test_path() -> None: 11 | app = web.Application() 12 | schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, 13 | "resources": ()} 14 | admin = aiohttp_admin.setup(app, schema) 15 | 16 | assert str(admin.router["index"].url_for()) == "/admin" 17 | 18 | admin = aiohttp_admin.setup(app, schema, path="/another/admin") 19 | 20 | assert str(admin.router["index"].url_for()) == "/another/admin" 21 | 22 | 23 | def test_js_module() -> None: 24 | app = web.Application() 25 | schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, 26 | "resources": (), "js_module": "/custom_js.js"} 27 | admin = aiohttp_admin.setup(app, schema) 28 | 29 | assert admin[state_key]["js_module"] == "/custom_js.js" 30 | 31 | 32 | def test_no_js_module() -> None: 33 | app = web.Application() 34 | schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, 35 | "resources": ()} 36 | admin = aiohttp_admin.setup(app, schema) 37 | 38 | assert admin[state_key]["js_module"] is None 39 | 40 | 41 | def test_validators() -> None: 42 | dummy = DummyResource( 43 | "dummy", 44 | {"id": {"__type__": "component", "type": "NumberField", "props": {}}}, 45 | {"id": {"__type__": "component", "type": "NumberInput", 46 | "props": {"validate": ({"__type__": "function", "name": "required", "args": ()},)}, 47 | "show_create": True}}, 48 | "id") 49 | app = web.Application() 50 | schema: aiohttp_admin.Schema = { 51 | "security": {"check_credentials": check_credentials}, 52 | "resources": ({"model": dummy, "validators": {"id": (func("minValue", (3,)),)}},)} 53 | admin = aiohttp_admin.setup(app, schema) 54 | validators = admin[state_key]["resources"]["dummy"]["inputs"]["id"]["props"]["validate"] 55 | assert validators == (func("required", ()), func("minValue", (3,))) 56 | assert ("minValue", 3) not in dummy.inputs["id"]["props"]["validate"] # type: ignore[operator] 57 | 58 | 59 | def test_re() -> None: 60 | test_re = DummyResource( 61 | "testre", {"id": comp("NumberField"), "value": comp("TextField")}, {}, "id") 62 | 63 | app = web.Application() 64 | schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, 65 | "resources": ({"model": test_re},)} 66 | admin = aiohttp_admin.setup(app, schema) 67 | r = admin[permission_re_key] 68 | 69 | assert r.fullmatch("admin.*") 70 | assert r.fullmatch("admin.view") 71 | assert r.fullmatch("~admin.edit") 72 | assert r.fullmatch("admin.testre.*") 73 | assert r.fullmatch("admin.testre.add") 74 | assert r.fullmatch("admin.testre.id.*") 75 | assert r.fullmatch("admin.testre.value.edit") 76 | assert r.fullmatch("~admin.testre.id.edit") 77 | assert r.fullmatch("admin.testre.edit|id=5") 78 | assert r.fullmatch('admin.testre.add|id=1|value="4"|value="7"') 79 | assert r.fullmatch('admin.testre.value.*|value="foo"') 80 | assert r.fullmatch("admin.testre.value.delete|id=5|id=3") 81 | 82 | assert r.fullmatch("testre.edit") is None 83 | assert r.fullmatch("admin.create") is None 84 | assert r.fullmatch("admin.nottest.*") is None 85 | assert r.fullmatch("admin.*|id=1") is None 86 | assert r.fullmatch("admin.testre.edit|other=5") is None 87 | assert r.fullmatch("admin.testre.value.*|value=unquoted") is None 88 | assert r.fullmatch("~admin.testre.edit|id=5") is None 89 | assert r.fullmatch('~admin.testre.value.delete|value="1"') is None 90 | 91 | 92 | def test_display() -> None: 93 | app = web.Application() 94 | model = DummyResource( 95 | "test", 96 | {"id": comp("TextField", {"source": "id"}), "foo": comp("TextField", {"source": "foo"})}, 97 | {"id": comp("TextInput", {"validate": (func("required", ()),)}) | {"show_create": False}, # type: ignore[dict-item] 98 | "foo": comp("TextInput") | {"show_create": True}}, # type: ignore[dict-item] 99 | "id") 100 | schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, 101 | "resources": ({"model": model, "display": ("foo",)},)} 102 | 103 | admin = aiohttp_admin.setup(app, schema) 104 | 105 | test_state = admin[state_key]["resources"]["test"] 106 | assert test_state["list_omit"] == ("id",) 107 | assert test_state["inputs"]["id"]["props"] == {"key": "id", 108 | "validate": (func("required", ()),)} 109 | assert test_state["inputs"]["foo"]["props"] == {"key": "foo", "alwaysOn": "alwaysOn"} 110 | 111 | 112 | def test_display_invalid() -> None: 113 | app = web.Application() 114 | model = DummyResource("test", {"id": comp("TextField"), "foo": comp("TextField")}, {}, "id") 115 | schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, 116 | "resources": ({"model": model, "display": ("bar",)},)} 117 | 118 | with pytest.raises(ValueError, match=r"Display includes non-existent field \('bar',\)"): 119 | aiohttp_admin.setup(app, schema) 120 | 121 | 122 | def test_extra_props() -> None: 123 | app = web.Application() 124 | model = DummyResource( 125 | "test", 126 | {"id": comp("TextField", {"textAlign": "right", "placeholder": "foo"})}, 127 | {"id": comp("TextInput", {"resettable": False, "type": "text"}) 128 | | {"show_create": False}}, # type: ignore[dict-item] 129 | "id") 130 | schema: aiohttp_admin.Schema = { 131 | "security": {"check_credentials": check_credentials}, 132 | "resources": ({ 133 | "model": model, 134 | "field_props": {"id": {"textAlign": "left", "label": "Spam"}}, 135 | "input_props": {"id": {"type": "email", "multiline": True}} 136 | },)} 137 | 138 | admin = aiohttp_admin.setup(app, schema) 139 | 140 | test_state = admin[state_key]["resources"]["test"] 141 | assert test_state["fields"]["id"]["props"] == {"textAlign": "left", "placeholder": "foo", 142 | "label": "Spam", "key": "id"} 143 | assert test_state["inputs"]["id"]["props"] == {"alwaysOn": "alwaysOn", "type": "email", 144 | "multiline": True, "resettable": False, 145 | "key": "id"} 146 | 147 | 148 | def test_invalid_repr() -> None: 149 | app = web.Application() 150 | model = DummyResource("test", {"id": comp("TextField"), "foo": comp("TextField")}, {}, "id") 151 | schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, 152 | "resources": ({"model": model, "repr": "bar"},)} 153 | 154 | with pytest.raises(ValueError, match=r"not a valid field name: bar"): 155 | aiohttp_admin.setup(app, schema) 156 | -------------------------------------------------------------------------------- /tests/test_backends_abc.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections.abc import Awaitable, Callable 3 | 4 | from aiohttp import web 5 | from aiohttp.test_utils import TestClient 6 | 7 | from conftest import admin 8 | 9 | _Client = TestClient[web.Request, web.Application] 10 | _Login = Callable[[_Client], Awaitable[dict[str, str]]] 11 | 12 | 13 | async def test_create_with_null(admin_client: _Client, login: _Login) -> None: 14 | h = await login(admin_client) 15 | assert admin_client.app 16 | url = admin_client.app[admin].router["dummy2_create"].url_for() 17 | p = {"data": json.dumps({"data": {"msg": None}})} 18 | async with admin_client.post(url, params=p, headers=h) as resp: 19 | assert resp.status == 200, await resp.text() 20 | assert await resp.json() == {"data": {"id": "4", "data": {"id": 4, "msg": None}}} 21 | 22 | 23 | async def test_invalid_field(admin_client: _Client, login: _Login) -> None: 24 | h = await login(admin_client) 25 | assert admin_client.app 26 | url = admin_client.app[admin].router["dummy2_create"].url_for() 27 | p = {"data": json.dumps({"data": {"incorrect": "foo"}})} 28 | async with admin_client.post(url, params=p, headers=h) as resp: 29 | assert resp.status == 400, await resp.text() 30 | assert "Invalid field 'incorrect'" in await resp.text() 31 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from collections.abc import Awaitable, Callable 4 | 5 | import pytest 6 | import sqlalchemy as sa 7 | from aiohttp import web 8 | from aiohttp.test_utils import TestClient 9 | 10 | from aiohttp_admin.types import comp, data, func 11 | from conftest import admin, db, model, model2 12 | 13 | _Client = TestClient[web.Request, web.Application] 14 | _Login = Callable[[_Client], Awaitable[dict[str, str]]] 15 | 16 | 17 | async def test_admin_view(admin_client: _Client) -> None: 18 | assert admin_client.app 19 | url = admin_client.app[admin].router["index"].url_for() 20 | async with admin_client.get(url) as resp: 21 | assert resp.status == 200 22 | html = await resp.text() 23 | 24 | m = re.search("(.*)", html) 25 | assert m is not None 26 | assert m.group(1) == "My Admin" 27 | 28 | m = re.search('', html) 29 | assert m is not None 30 | assert m.group(1) == "/admin/static/admin.js" 31 | 32 | m = re.search("", html) 33 | assert m is not None 34 | state = json.loads(m.group(1)) 35 | 36 | r = state["resources"]["dummy"] 37 | # TODO: https://github.com/marmelab/react-admin/issues/9587 38 | assert r["list_omit"] == ["id"] 39 | assert r["fields"].keys() == {"id", "foreigns"} 40 | assert r["fields"]["id"] == comp("NumberField", {"source": data("id"), "key": "id"}) 41 | assert r["inputs"] == { 42 | "id": comp("NumberInput", 43 | {"source": data("id"), "key": "id", # "alwaysOn": "alwaysOn", 44 | "validate": [func("required", [])]}) 45 | | {"show_create": False}} 46 | assert r["repr"] == data("id") 47 | assert state["urls"] == {"token": "/admin/token", "logout": "/admin/logout"} 48 | 49 | 50 | async def test_list_pagination(admin_client: _Client, login: _Login) -> None: 51 | h = await login(admin_client) 52 | assert admin_client.app 53 | async with admin_client.app[db].begin() as sess: 54 | for _ in range(25): 55 | sess.add(admin_client.app[model]()) 56 | 57 | url = admin_client.app[admin].router["dummy_get_list"].url_for() 58 | p = {"pagination": '{"page": 1, "perPage": 30}', 59 | "sort": '{"field": "id", "order": "ASC"}', "filter": '{}'} 60 | async with admin_client.get(url, params=p, headers=h) as resp: 61 | assert resp.status == 200 62 | all_rows = await resp.json() 63 | assert len(all_rows["data"]) == all_rows["total"] == 26 64 | assert tuple(r["id"] for r in all_rows["data"]) == tuple(str(i) for i in range(1, 27)) 65 | 66 | p = {"pagination": '{"page": 2, "perPage": 12}', 67 | "sort": '{"field": "id", "order": "DESC"}', "filter": '{}'} 68 | async with admin_client.get(url, params=p, headers=h) as resp: 69 | assert resp.status == 200 70 | page = await resp.json() 71 | assert page["total"] == 26 72 | assert tuple(r["id"] for r in page["data"]) == tuple(str(i) for i in range(14, 2, -1)) 73 | 74 | p = {"pagination": '{"page": 20, "perPage": 10}', 75 | "sort": '{"field": "id", "order": "DESC"}', "filter": '{}'} 76 | async with admin_client.get(url, params=p, headers=h) as resp: 77 | assert resp.status == 200 78 | page = await resp.json() 79 | assert page["data"] == [] 80 | assert page["total"] == 26 81 | 82 | 83 | async def test_list_filtering_by_pk(admin_client: _Client, login: _Login) -> None: 84 | h = await login(admin_client) 85 | assert admin_client.app 86 | async with admin_client.app[db].begin() as sess: 87 | for _ in range(15): 88 | sess.add(admin_client.app[model]()) 89 | 90 | url = admin_client.app[admin].router["dummy_get_list"].url_for() 91 | p = {"pagination": '{"page": 1, "perPage": 10}', 92 | "sort": '{"field": "id", "order": "ASC"}', "filter": '{"id": 3}'} 93 | exp_rec = {"id": "3", "fk_id": "3", "data": {"id": 3}} 94 | async with admin_client.get(url, params=p, headers=h) as resp: 95 | assert resp.status == 200 96 | assert await resp.json() == {"data": [exp_rec], "total": 1} 97 | 98 | 99 | @pytest.mark.xfail(reason="Need to implement #668 to make this work properly") 100 | async def test_list_text_like_filtering(admin_client: _Client, login: _Login) -> None: 101 | h = await login(admin_client) 102 | assert admin_client.app 103 | async with admin_client.app[db].begin() as sess: 104 | for _ in range(15): 105 | sess.add(admin_client.app[model]()) 106 | 107 | url = admin_client.app[admin].router["dummy_get_list"].url_for() 108 | p = {"pagination": '{"page": 1, "perPage": 10}', 109 | "sort": '{"field": "id", "order": "ASC"}', "filter": '{"id": "3"}'} 110 | async with admin_client.get(url, params=p, headers=h) as resp: 111 | assert resp.status == 200 112 | assert await resp.json() == {"data": [{"id": "3"}, {"id": "13"}], "total": 2} 113 | 114 | 115 | async def test_get_one(admin_client: _Client, login: _Login) -> None: 116 | h = await login(admin_client) 117 | assert admin_client.app 118 | url = admin_client.app[admin].router["dummy_get_one"].url_for() 119 | 120 | async with admin_client.get(url, params={"id": 1}, headers=h) as resp: 121 | assert resp.status == 200 122 | assert await resp.json() == {"data": {"id": "1", "fk_id": "1", "data": {"id": 1}}} 123 | 124 | 125 | async def test_get_one_not_exists(admin_client: _Client, login: _Login) -> None: 126 | h = await login(admin_client) 127 | assert admin_client.app 128 | url = admin_client.app[admin].router["dummy_get_one"].url_for() 129 | 130 | async with admin_client.get(url, params={"id": 5}, headers=h) as resp: 131 | assert resp.status == 404 132 | 133 | 134 | async def test_get_many(admin_client: _Client, login: _Login) -> None: 135 | h = await login(admin_client) 136 | assert admin_client.app 137 | async with admin_client.app[db].begin() as sess: 138 | for _ in range(15): 139 | sess.add(admin_client.app[model]()) 140 | 141 | url = admin_client.app[admin].router["dummy_get_many"].url_for() 142 | p = {"ids": '["3", "7", "12"]'} 143 | async with admin_client.get(url, params=p, headers=h) as resp: 144 | assert resp.status == 200 145 | assert await resp.json() == {"data": [{"id": "3", "fk_id": "3", "data": {"id": 3}}, 146 | {"id": "7", "fk_id": "7", "data": {"id": 7}}, 147 | {"id": "12", "fk_id": "12", "data": {"id": 12}}]} 148 | 149 | 150 | async def test_get_many_not_exists(admin_client: _Client, login: _Login) -> None: 151 | h = await login(admin_client) 152 | assert admin_client.app 153 | async with admin_client.app[db].begin() as sess: 154 | for _ in range(5): 155 | sess.add(admin_client.app[model]()) 156 | 157 | url = admin_client.app[admin].router["dummy_get_many"].url_for() 158 | p = {"ids": '["3", "4", "8"]'} 159 | async with admin_client.get(url, params=p, headers=h) as resp: 160 | assert resp.status == 200 161 | assert await resp.json() == {"data": [{"id": "3", "fk_id": "3", "data": {"id": 3}}, 162 | {"id": "4", "fk_id": "4", "data": {"id": 4}}]} 163 | 164 | p = {"ids": '["9", "10", "11"]'} 165 | async with admin_client.get(url, params=p, headers=h) as resp: 166 | assert resp.status == 404 167 | 168 | 169 | async def test_get_many_ref(admin_client: _Client, login: _Login) -> None: 170 | h = await login(admin_client) 171 | assert admin_client.app 172 | 173 | url = admin_client.app[admin].router["foreign_get_many_ref"].url_for() 174 | page = json.dumps({"page": 1, "perPage": 10}) 175 | sort = json.dumps({"field": "id", "order": "DESC"}) 176 | p = {"target": "dummy", "id": "1", "pagination": page, "sort": sort, "filter": "{}"} 177 | expected_record = {"id": "1", "fk_dummy": "1", "data": {"id": 1, "dummy": 1}} 178 | async with admin_client.get(url, params=p, headers=h) as resp: 179 | assert resp.status == 200, await resp.text() 180 | assert await resp.json() == {"data": [expected_record], "total": 1} 181 | 182 | 183 | async def test_get_many_ref_orm(admin_client: _Client, login: _Login) -> None: 184 | h = await login(admin_client) 185 | assert admin_client.app 186 | 187 | url = admin_client.app[admin].router["dummy_get_many_ref"].url_for() 188 | page = json.dumps({"page": 1, "perPage": 10}) 189 | sort = json.dumps({"field": "id", "order": "DESC"}) 190 | f = json.dumps({"__meta__": {"orm": True}}) 191 | p = {"target": "foreigns", "id": "1", "pagination": page, "sort": sort, "filter": f} 192 | expected_record = {"id": "1", "fk_dummy": "1", "data": {"id": 1, "dummy": 1}} 193 | async with admin_client.get(url, params=p, headers=h) as resp: 194 | assert resp.status == 200, await resp.text() 195 | assert await resp.json() == {"data": [expected_record], "total": 1} 196 | 197 | 198 | async def test_create(admin_client: _Client, login: _Login) -> None: 199 | h = await login(admin_client) 200 | assert admin_client.app 201 | url = admin_client.app[admin].router["dummy_create"].url_for() 202 | p = {"data": json.dumps({"data": {}})} 203 | async with admin_client.post(url, params=p, headers=h) as resp: 204 | assert resp.status == 200 205 | assert await resp.json() == {"data": {"id": "2", "fk_id": "2", "data": {"id": 2}}} 206 | 207 | async with admin_client.app[db]() as sess: 208 | r = await sess.get(admin_client.app[model], 2) 209 | assert r is not None 210 | assert r.id == 2 211 | 212 | 213 | async def test_create_duplicate_id(admin_client: _Client, login: _Login) -> None: 214 | h = await login(admin_client) 215 | assert admin_client.app 216 | url = admin_client.app[admin].router["dummy_create"].url_for() 217 | p = {"data": '{"id": 1}'} 218 | async with admin_client.post(url, params=p, headers=h) as resp: 219 | assert resp.status == 400 220 | 221 | 222 | async def test_update(admin_client: _Client, login: _Login) -> None: 223 | h = await login(admin_client) 224 | assert admin_client.app 225 | url = admin_client.app[admin].router["dummy_update"].url_for() 226 | p = {"id": 1, "data": json.dumps({"id": "4", "data": {"id": 4}}), 227 | "previousData": json.dumps({"id": "1", "data": {"id": 1}})} 228 | async with admin_client.put(url, params=p, headers=h) as resp: 229 | assert resp.status == 200 230 | assert await resp.json() == {"data": {"id": "4", "fk_id": "4", "data": {"id": 4}}} 231 | 232 | async with admin_client.app[db]() as sess: 233 | r = await sess.get(admin_client.app[model], 4) 234 | assert r is not None 235 | assert r.id == 4 236 | 237 | assert await sess.get(admin_client.app[model], 1) is None 238 | assert await sess.get(admin_client.app[model], 2) is None 239 | 240 | 241 | async def test_update_deleted_entity(admin_client: _Client, login: _Login) -> None: 242 | h = await login(admin_client) 243 | assert admin_client.app 244 | url = admin_client.app[admin].router["dummy_update"].url_for() 245 | p = {"id": "2", "data": '{"id": "4", "data": {"id": 4}}', 246 | "previousData": '{"id": "2", "data": {"id": 2}}'} 247 | async with admin_client.put(url, params=p, headers=h) as resp: 248 | assert resp.status == 404 249 | 250 | 251 | async def test_update_invalid_attributes(admin_client: _Client, login: _Login) -> None: 252 | h = await login(admin_client) 253 | assert admin_client.app 254 | url = admin_client.app[admin].router["dummy_update"].url_for() 255 | p = {"id": "1", "data": '{"id": "4", "data": {"foo": "invalid"}}', 256 | "previousData": '{"id": "1", "data": {"id": 1}}'} 257 | async with admin_client.put(url, params=p, headers=h) as resp: 258 | assert resp.status == 400 259 | assert "foo" in await resp.text() 260 | 261 | 262 | async def test_update_many(admin_client: _Client, login: _Login) -> None: 263 | h = await login(admin_client) 264 | assert admin_client.app 265 | url = admin_client.app[admin].router["dummy2_update_many"].url_for() 266 | p = {"ids": '["1", "2"]', "data": json.dumps({"msg": "ABC"})} 267 | async with admin_client.put(url, params=p, headers=h) as resp: 268 | assert resp.status == 200 269 | assert await resp.json() == {"data": ["1", "2"]} 270 | 271 | async with admin_client.app[db]() as sess: 272 | r = await sess.get(admin_client.app[model2], 1) 273 | assert r is not None 274 | assert r.msg == "ABC" 275 | r = await sess.get(admin_client.app[model2], 2) 276 | assert r is not None 277 | assert r.msg == "ABC" 278 | 279 | 280 | async def test_update_many_deleted_entity(admin_client: _Client, login: _Login) -> None: 281 | h = await login(admin_client) 282 | assert admin_client.app 283 | url = admin_client.app[admin].router["dummy_update_many"].url_for() 284 | p = {"ids": '["2"]', "data": '{"id": 4}'} 285 | async with admin_client.put(url, params=p, headers=h) as resp: 286 | assert resp.status == 404 287 | 288 | 289 | async def test_update_many_invalid_attributes(admin_client: _Client, login: _Login) -> None: 290 | h = await login(admin_client) 291 | assert admin_client.app 292 | url = admin_client.app[admin].router["dummy_update_many"].url_for() 293 | p = {"ids": '["1"]', "data": '{"foo": "invalid"}'} 294 | async with admin_client.put(url, params=p, headers=h) as resp: 295 | assert resp.status == 400 296 | assert "foo" in await resp.text() 297 | 298 | 299 | async def test_delete(admin_client: _Client, login: _Login) -> None: 300 | h = await login(admin_client) 301 | assert admin_client.app 302 | url = admin_client.app[admin].router["dummy_delete"].url_for() 303 | p = {"id": "1", "previousData": '{"id": "1", "data": {"id": 1}}'} 304 | async with admin_client.delete(url, params=p, headers=h) as resp: 305 | assert resp.status == 200 306 | assert await resp.json() == {"data": {"id": "1", "fk_id": "1", "data": {"id": 1}}} 307 | 308 | async with admin_client.app[db]() as sess: 309 | assert await sess.get(admin_client.app[model], 1) is None 310 | r = await sess.scalars(sa.select(admin_client.app[model])) 311 | assert len(r.all()) == 0 312 | 313 | 314 | async def test_delete_entity_not_exists(admin_client: _Client, login: _Login) -> None: 315 | h = await login(admin_client) 316 | assert admin_client.app 317 | url = admin_client.app[admin].router["dummy_delete"].url_for() 318 | p = {"id": "5", "previousData": '{"id": "5", "data": {"id": 5}}'} 319 | async with admin_client.delete(url, params=p, headers=h) as resp: 320 | assert resp.status == 404 321 | 322 | 323 | async def test_delete_many(admin_client: _Client, login: _Login) -> None: 324 | h = await login(admin_client) 325 | assert admin_client.app 326 | async with admin_client.app[db].begin() as sess: 327 | for _ in range(5): 328 | sess.add(admin_client.app[model]()) 329 | 330 | url = admin_client.app[admin].router["dummy_delete_many"].url_for() 331 | p = {"ids": '["2", "3", "5"]'} 332 | async with admin_client.delete(url, params=p, headers=h) as resp: 333 | assert resp.status == 200 334 | assert await resp.json() == {"data": ["2", "3", "5"]} 335 | 336 | async with admin_client.app[db]() as sess: 337 | r = await sess.scalars(sa.select(admin_client.app[model])) 338 | models = r.all() 339 | assert len(models) == 3 340 | assert {m.id for m in models} == {1, 4, 6} 341 | 342 | 343 | async def test_delete_many_not_exists(admin_client: _Client, login: _Login) -> None: 344 | h = await login(admin_client) 345 | assert admin_client.app 346 | async with admin_client.app[db].begin() as sess: 347 | for _ in range(5): 348 | sess.add(admin_client.app[model]()) 349 | 350 | url = admin_client.app[admin].router["dummy_delete_many"].url_for() 351 | p = {"ids": '["2", "3", "9"]'} 352 | async with admin_client.delete(url, params=p, headers=h) as resp: 353 | assert resp.status == 200 354 | assert await resp.json() == {"data": ["2", "3"]} 355 | 356 | async with admin_client.app[db]() as sess: 357 | r = await sess.scalars(sa.select(admin_client.app[model])) 358 | models = r.all() 359 | assert len(models) == 4 360 | assert {m.id for m in models} == {1, 4, 5, 6} 361 | 362 | url = admin_client.app[admin].router["dummy_delete_many"].url_for() 363 | p = {"ids": '["12", "13"]'} 364 | async with admin_client.delete(url, params=p, headers=h) as resp: 365 | assert resp.status == 404 366 | 367 | async with admin_client.app[db]() as sess: 368 | r = await sess.scalars(sa.select(admin_client.app[model])) 369 | assert len(r.all()) == 4 370 | --------------------------------------------------------------------------------