Repository: aio-libs/aiohttp_admin Branch: master Commit: abd2214bd785 Files: 70 Total size: 285.4 KB Directory structure: gitextract_hr52gqcg/ ├── .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 │ ├── types.py │ └── views.py ├── docs/ │ ├── Makefile │ ├── README.md │ ├── api.rst │ ├── changelog.rst │ ├── conf.py │ ├── contents.rst.inc │ ├── contributing.rst │ ├── design.rst │ └── index.rst ├── examples/ │ ├── demo/ │ │ ├── README │ │ ├── admin-js/ │ │ │ ├── craco.config.js │ │ │ ├── package.json │ │ │ ├── public/ │ │ │ │ └── index.html │ │ │ ├── shim/ │ │ │ │ ├── query-string/ │ │ │ │ │ └── index.js │ │ │ │ ├── react/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── jsx-runtime.js │ │ │ │ ├── react-admin/ │ │ │ │ │ └── index.js │ │ │ │ ├── react-dom/ │ │ │ │ │ └── index.js │ │ │ │ └── react-router-dom/ │ │ │ │ └── index.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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codecov.yml ================================================ codecov: notify: after_n_builds: 6 ================================================ FILE: .coveragerc ================================================ # .coveragerc to control coverage.py [run] branch = True ================================================ FILE: .flake8 ================================================ [flake8] enable-extensions = G max-doc-length = 90 max-line-length = 90 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 # E226: Missing whitespace around arithmetic operators can help group things together. # E501: Superseeded by B950 (from Bugbear) # E722: Superseeded by B001 (from Bugbear) # W503: Mutually exclusive with W504. ignore = E226,E501,E722,W503 per-file-ignores = # I900: Caused by awkward non-package imports. # S101: Pytest uses assert # S105: Examples, not real passwords tests/*:S101,I900,S105 examples/*:I900,S105 # flake8-import-order application-import-names = aiohttp_admin, conftest, _auth, _auth_helpers, _models, _resources import-order-style = pycharm # flake8-quotes inline-quotes = " # flake8-requirements requirements-file = requirements-dev.txt ================================================ FILE: .github/FUNDING.yml ================================================ github: Dreamsorcerer ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily - package-ecosystem: npm directory: "/admin-js/" schedule: interval: daily groups: react-admin: patterns: - "create-react-admin" - "ra-*" - "react-admin" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" ================================================ FILE: .github/workflows/auto-merge.yml ================================================ name: Dependabot auto-merge on: pull_request_target permissions: pull-requests: write contents: write jobs: dependabot: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' }} steps: - name: Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Enable auto-merge for Dependabot PRs run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: - master - '[0-9].[0-9]+' # matches to backport branches, e.g. 3.6 tags: [ 'v*' ] pull_request: branches: - master - '[0-9].[0-9]+' jobs: lint: name: Linter runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v6 with: python-version: '3.10' cache: 'pip' cache-dependency-path: '**/requirements*.txt' - name: Install dependencies uses: py-actions/py-dependency-install@v4 with: path: requirements-dev.txt - name: Install itself run: | pip install . - name: Mypy run: mypy - name: Pre-Commit hooks uses: pre-commit/action@v3.0.1 - name: Flake8 run: flake8 - name: Prepare twine checker run: | pip install -U build twine wheel python -m build - name: Run twine checker run: | twine check dist/* yarn_build: permissions: contents: read # to fetch code (actions/checkout) name: Build JS with Yarn runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@v6 - name: Disable man-db to speed up apt run: | echo 'set man-db/auto-update false' | sudo debconf-communicate >/dev/null sudo dpkg-reconfigure man-db - name: Install yarn run: sudo apt install yarn -y - name: Get yarn cache dir run: echo "yarn_cache=$(yarn cache dir)" >> "$GITHUB_ENV" - name: Cache node modules uses: actions/cache@v5 id: cache_node_modules with: key: node-${{ hashFiles('admin-js/package.json') }}-${{ github.run_id }} restore-keys: node-${{ hashFiles('admin-js/package.json') }} path: | ${{ env.yarn_cache }} admin-js/node_modules admin-js/yarn.lock - name: Yarn install if: steps.cache_node_modules.outputs.cache-hit != 'true' run: yarn install --production working-directory: admin-js/ - name: Cache output files uses: actions/cache@v5 id: cache_admin_js with: key: yarn-${{ hashFiles('admin-js/src/*') }}-${{ hashFiles('admin-js/yarn.lock') }} path: | aiohttp_admin/static/admin.js aiohttp_admin/static/admin.js.map - name: Yarn build if: steps.cache_admin_js.outputs.cache-hit != 'true' run: yarn build --minify false working-directory: admin-js/ test: name: Test needs: yarn_build strategy: matrix: pyver: ['3.9', '3.10', '3.11', '3.12'] include: - pyver: pypy-3.9 runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Python ${{ matrix.pyver }} uses: actions/setup-python@v6 with: allow-prereleases: true python-version: ${{ matrix.pyver }} cache: 'pip' cache-dependency-path: '**/requirements*.txt' - name: Restore cached JS files uses: actions/cache/restore@v5 with: key: yarn-${{ hashFiles('admin-js/src/*') }} fail-on-cache-miss: true path: | aiohttp_admin/static/admin.js aiohttp_admin/static/admin.js.map - name: Install dependencies uses: py-actions/py-dependency-install@v4 with: path: requirements.txt - name: Run unittests env: COLOR: 'yes' run: | pytest tests python -m coverage xml - name: Upload coverage uses: codecov/codecov-action@v6 with: fail_ci_if_error: true files: ./coverage.xml flags: unit token: ${{ secrets.CODECOV_TOKEN }} test-integration: name: Integration Test needs: yarn_build runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - name: Disable man-db to speed up apt run: | echo 'set man-db/auto-update false' | sudo debconf-communicate >/dev/null sudo dpkg-reconfigure man-db - name: Install yarn run: sudo apt install yarn -y - name: Get yarn cache dir run: echo "yarn_cache=$(yarn cache dir)" >> "$GITHUB_ENV" - name: Cache node modules uses: actions/cache@v5 with: key: node-${{ hashFiles('admin-js/package.json') }}-${{ github.run_id }} restore-keys: node-${{ hashFiles('admin-js/package.json') }} path: | ${{ env.yarn_cache }} admin-js/node_modules - name: Yarn install run: yarn install working-directory: admin-js/ - name: Setup Python uses: actions/setup-python@v6 with: python-version: 3.12 cache: 'pip' cache-dependency-path: '**/requirements*.txt' - name: Restore cached JS files uses: actions/cache/restore@v5 with: key: yarn-${{ hashFiles('admin-js/src/*') }} fail-on-cache-miss: true path: | aiohttp_admin/static/admin.js aiohttp_admin/static/admin.js.map - name: Install dependencies uses: py-actions/py-dependency-install@v4 with: path: requirements.txt - name: Run tests run: yarn test --coverage working-directory: admin-js/ - name: Upload JS coverage uses: codecov/codecov-action@v6 with: fail_ci_if_error: true directory: admin-js/coverage/ flags: js, integration token: ${{ secrets.CODECOV_TOKEN }} - name: Generate Python coverage run: python -m coverage xml - name: Upload Python coverage uses: codecov/codecov-action@v6 with: fail_ci_if_error: true files: ./coverage.xml flags: integration token: ${{ secrets.CODECOV_TOKEN }} check: # This job does nothing and is only used for the branch protection if: always() needs: [lint, test, test-integration] runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} deploy: name: Deploy environment: release runs-on: ubuntu-latest needs: [check] if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v6 with: python-version: 3.11 - name: Disable man-db to speed up apt run: | echo 'set man-db/auto-update false' | sudo debconf-communicate >/dev/null sudo dpkg-reconfigure man-db - name: Install yarn run: sudo apt install yarn -y - name: Yarn install run: yarn install --production working-directory: admin-js/ - name: Yarn build run: yarn build --production working-directory: admin-js/ - name: Install dependencies run: python -m pip install -U pip wheel setuptools build twine - name: Build dists run: | python -m build - name: Make Release uses: aio-libs/create-release@v1.6.6 with: changes_file: CHANGES.rst name: aiohttp-admin version_file: aiohttp_admin/__init__.py github_token: ${{ secrets.GITHUB_TOKEN }} pypi_token: ${{ secrets.PYPI_API_TOKEN }} dist_dir: dist fix_issue_regex: "`#(\\d+) `" fix_issue_repl: "(#\\1)" ================================================ FILE: .gitignore ================================================ __pycache__/ # From yarn install admin-js/yarn.lock admin-js/node_modules/ examples/demo/admin-js/yarn.lock examples/demo/admin-js/node_modules/ # Generated by yarn build aiohttp_admin/static/admin.js aiohttp_admin/static/*.js.map examples/demo/static/admin.js examples/demo/static/*.js.map # coverage (when running pytest) .coverage ================================================ FILE: .mypy.ini ================================================ [mypy] files = aiohttp_admin, examples, tests check_untyped_defs = True follow_imports_for_stubs = True disallow_any_decorated = True disallow_any_generics = True disallow_any_unimported = True disallow_incomplete_defs = True disallow_subclassing_any = True disallow_untyped_calls = True disallow_untyped_decorators = True disallow_untyped_defs = True enable_error_code = ignore-without-code, possibly-undefined, redundant-expr, redundant-self, truthy-bool, truthy-iterable, unused-awaitable implicit_reexport = False no_implicit_optional = True pretty = True show_column_numbers = True show_error_codes = True strict_equality = True warn_incomplete_stub = True warn_redundant_casts = True warn_return_any = True warn_unreachable = True warn_unused_ignores = True [mypy-aiohttp_admin.backends.sqlalchemy] # We use Any for several parameters, causing a few of these errors. disallow_any_decorated = False [mypy-tests.*] disallow_any_decorated = False disallow_untyped_calls = False disallow_untyped_defs = False ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 'v4.4.0' hooks: - id: check-merge-conflict - repo: https://github.com/asottile/yesqa rev: v1.5.0 hooks: - id: yesqa additional_dependencies: ["flake8-bandit", "flake8-bugbear"] - repo: https://github.com/pre-commit/pre-commit-hooks rev: 'v4.4.0' hooks: - id: end-of-file-fixer exclude: >- ^docs/[^/]*\.svg$ - id: requirements-txt-fixer - id: trailing-whitespace - id: file-contents-sorter files: | CONTRIBUTORS.txt| docs/spelling_wordlist.txt| .gitignore| .gitattributes - id: check-case-conflict - id: check-json - id: check-xml - id: check-executables-have-shebangs - id: check-toml - id: check-xml - id: check-yaml - id: debug-statements - id: check-added-large-files - id: check-symlinks - id: debug-statements - id: detect-aws-credentials args: ['--allow-missing-credentials'] - id: detect-private-key exclude: ^examples/ - repo: https://github.com/PyCQA/flake8 rev: '6.1.0' hooks: - id: flake8 exclude: "^docs/" - repo: https://github.com/asottile/pyupgrade rev: 'v3.3.1' hooks: - id: pyupgrade args: ['--py36-plus'] - repo: https://github.com/Lucas-C/pre-commit-hooks-markup rev: v1.0.1 hooks: - id: rst-linter files: >- ^[^/]+[.]rst$ ================================================ FILE: CHANGES.rst ================================================ ======= CHANGES ======= .. towncrier release notes start 0.1.0a3 (2023-12-03) ==================== - Used ``AppKey`` with aiohttp 3.9. - Added Python 3.12 support. - Added support for dynamically loaded components. - Reverted a change which broke relationship fields. 0.1.0a2 (2023-10-02) ==================== - Added ``permission_for()`` to create sqlalchemy permissions programatically. - Added ``field_props`` and ``input_props`` to the schema to pass extra props to components. - Added support for more relationships (one-to-many, many-to-one etc.). - Added a ``js_module`` option to include custom functions. - Added ``comp()``, ``func()`` and ``regex()``. - Added ``show_actions`` to allow customising the show actions. - Set many additional props/validators from inspecting the SqlAlchemy models. - Migrated to Pydantic v2. - Fixed behaviour with dates and times. - Various minor improvements. 0.1.0a1 (2023-04-23) ==================== - Removed ``auth_policy`` parameter from ``setup()``, this is no longer needed. - Added a default ``identity_callback`` for simple applications, so it is no longer a required schema item. - Added ``Permissions.all`` enum value (which should replace ``tuple(Permissions)``). - Added validators to inputs (e.g. required, minValue etc. See examples/validators.py). - Added extensive permission controls (see examples/permissions.py). - Added ``admin["permission_re"]`` regex object to test if permission strings are valid. - Added buttons for the user to change visible columns in the list view. - Added initial support for ORM (1-to-many) relationships. - Added option to add simple bulk update buttons. - Added option to customise resource icons in sidebar. - Added option to customise admin title and resource labels. - Added support for non-id primary keys. - Added default favicon. - Included JS map file. - Fixed autocomplete behaviour in reference inputs (e.g. for foreign keys). - Fixed handling of date/datetime inputs. 0.1.0a0 (2023-02-27) ==================== - Migrated to react-admin and completely reinvented the API. ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2016 Nikolay Novik Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: MANIFEST.in ================================================ include CHANGES.rst include LICENSE include README.rst graft aiohttp_admin global-exclude *.pyc ================================================ FILE: README.rst ================================================ aiohttp-admin ============= .. image:: https://codecov.io/gh/aio-libs/aiohttp-admin/branch/master/graph/badge.svg :target: https://codecov.io/gh/aio-libs/aiohttp-admin **aiohttp-admin** allows you to create a admin interface in minutes. It is designed to be flexible and database agnostic. It has built-in support for SQLAlchemy, allowing admin views to be created automatically from DB models (ORM or core). To see how to use the 0.1 versions, please refer to the examples. Documentation will be updated at a later date. Development ----------- To develop or build the project from source, you'll need to build the admin JS file:: cd admin-js/ yarn install yarn build After that, it can be treated as any other Python project. ================================================ FILE: admin-js/babel.config.js ================================================ module.exports = { presets: [ "@babel/preset-env", ["@babel/preset-react", {runtime: "automatic"}], ], }; ================================================ FILE: admin-js/jest.config.js ================================================ module.exports = { clearMocks: true, collectCoverageFrom: ["src/**", "tests/**"], errorOnDeprecated: true, maxWorkers: 1, resetMocks: true, restoreMocks: true, setupFilesAfterEnv: ["/tests/setupTests.js"], testEnvironment: "jsdom", testEnvironmentOptions: {"url": "http://localhost:8080", "pretendToBeVisual": true}, verbose: true, }; ================================================ FILE: admin-js/package.json ================================================ { "name": "admin-js", "version": "0.1.0", "private": true, "dependencies": { "react": "18.2.0", "react-admin": "4.16.7", "react-dom": "18.2.0", "terser": "5.46.2", "vite": "8.0.10", "create-react-admin": "4.16.7", "ra-core": "4.16.7", "ra-data-fakerest": "4.16.7", "ra-data-graphql-simple": "4.16.7", "ra-data-graphql": "4.16.7", "ra-data-json-server": "4.16.7", "ra-data-local-forage": "4.16.7", "ra-data-local-storage": "4.16.7", "ra-data-simple-rest": "4.16.7", "ra-i18n-i18next": "4.16.7", "ra-i18n-polyglot": "4.16.7", "ra-input-rich-text": "4.16.7", "ra-language-english": "4.16.7", "ra-language-french": "4.16.7", "ra-no-code": "4.16.7", "ra-ui-materialui": "4.16.7" }, "devDependencies": { "@babel/preset-env": "7.29.3", "@babel/preset-react": "7.28.5", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", "@ungap/structured-clone": "1.3.0", "jest": "30.3.0", "jest-environment-jsdom": "30.3.0", "jest-fail-on-console": "3.3.4", "whatwg-fetch": "3.6.20" }, "scripts": { "dev": "vite", "build": "vite build", "test": "jest" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest" ], "rules": { "react/jsx-pascal-case": [1, {"allowLeadingUnderscore": true}] } }, "browserslist": { "production": [ ">0.2%", "not dead" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } } ================================================ FILE: admin-js/src/App.jsx ================================================ import {useState} from "react"; import { Admin, AppBar, AutocompleteInput, BooleanField, BooleanInput, BulkDeleteButton, Button, BulkExportButton, BulkUpdateButton, CloneButton, Create, CreateButton, Datagrid, DatagridConfigurable, DateField, DateInput, DateTimeInput, DeleteButton, Edit, EditButton, ExportButton, FilterButton, HttpError, InspectorButton, Layout, List, ListButton, NullableBooleanInput, NumberInput, NumberField, ReferenceField, ReferenceInput, ReferenceManyField, ReferenceOneField, Resource, SaveButton, SelectColumnsButton, SelectField, SelectInput, Show, ShowButton, SimpleForm, SimpleShowLayout, TextField, TextInput, TimeInput, TitlePortal, Toolbar, TopToolbar, WithRecord, downloadCSV, email, maxLength, maxValue, minLength, minValue, regex, required, useCreate, useCreatePath, useDataProvider, useDelete, useDeleteMany, useGetList, useGetMany, useGetOne, useGetRecordId, useInfiniteGetList, useInput, useNotify, useRecordContext, useRedirect, useRefresh, useResourceContext, useUnselect, useUnselectAll, useUpdate, useUpdateMany, } from "react-admin"; import {useFormContext} from "react-hook-form"; import jsonExport from "jsonexport/dist"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; window.ReactAdmin = { Admin, AppBar, AutocompleteInput, BooleanField, BooleanInput, BulkDeleteButton, Button, BulkExportButton, BulkUpdateButton, CloneButton, Create, CreateButton, Datagrid, DatagridConfigurable, DateField, DateInput, DateTimeInput, DeleteButton, Edit, EditButton, ExportButton, FilterButton, HttpError, InspectorButton, Layout, List, ListButton, NullableBooleanInput, NumberInput, NumberField, ReferenceField, ReferenceInput, ReferenceManyField, ReferenceOneField, Resource, SaveButton, SelectColumnsButton, SelectField, SelectInput, Show, ShowButton, SimpleForm, SimpleShowLayout, TextField, TextInput, TimeInput, TitlePortal, Toolbar, TopToolbar, WithRecord, downloadCSV, email, maxLength, maxValue, minLength, minValue, regex, required, useCreate, useCreatePath, useDelete, useDataProvider, useDeleteMany, useGetList, useGetMany, useGetOne, useGetRecordId, useInfiniteGetList, useInput, useNotify, useRecordContext, useRedirect, useRefresh, useResourceContext, useUnselect, useUnselectAll, useUpdate, useUpdateMany, }; let STATE; // Hacked TimeField/TimeInput to actually work with times. // TODO: Replace once new components are introduced using Temporal API. const _TimeField = (props) => ( } /> ); const _TimeInput = (props) => ( v} parse={(v) => v} {...props} />); /** Reconfigure ReferenceInput to filter by the displayed repr field. Add referenceKeys prop to be able to update other fields for composite keys. */ const _ReferenceInput = (props) => { const {referenceKeys, validate, ...innerProps} = props; const {setValue} = useFormContext(); const change = (value, record) => { for (let [this_k, foreign_k] of referenceKeys) setValue(`data.${this_k}`, record ? record["data"][foreign_k] : null); }; const ref = props["reference"]; const repr = STATE["resources"][ref]["repr"].replace(/^data\./, ""); return ( ({[repr]: s})} label={props["label"]} onChange={change} validate={validate} /> ); }; /** Display a single record in a Datagrid-like view (e.g. for ReferenceField). */ const DatagridSingle = (props) => ( } /> ); const exportRecords = (records) => records.map((record) => record.data); // Create a mapping of components, so we can reference them by name later. const COMPONENTS = { Datagrid, DatagridSingle, BulkDeleteButton, BulkExportButton, BulkUpdateButton, CloneButton, CreateButton, ExportButton, FilterButton, ListButton, ShowButton, BooleanField, DateField, NumberField, ReferenceField, ReferenceManyField, ReferenceOneField, SelectField, TextField, TimeField: _TimeField, BooleanInput, DateInput, DateTimeInput, NullableBooleanInput, NumberInput, ReferenceInput: _ReferenceInput, SelectInput, TextInput, TimeInput: _TimeInput }; const FUNCTIONS = {exportRecords, email, maxLength, maxValue, minLength, minValue, regex, required}; /** Make an authenticated API request and return the response object. */ function apiRequest(url, options) { const headers = new Headers({ Accept: "application/json", Authorization: localStorage.getItem("identity") }); return fetch(url, Object.assign({"headers": headers}, options)).then((resp) => { if (resp.status < 200 || resp.status >= 300) { return resp.text().then(text => { throw new HttpError(text, resp.status, text); }); } return resp; }); } /** Make a dataProvider request to the given resource's endpoint and return the JSON result. */ function dataRequest(resource, endpoint, params) { for (const [k, v] of Object.entries(params)) { if (v === undefined) delete params[k]; else if (typeof v === "object" && v !== null) params[k] = JSON.stringify(v); } const query = new URLSearchParams(params).toString(); const [method, url] = STATE["resources"][resource]["urls"][endpoint]; return apiRequest(`${url}?${query}`, {"method": method}).then((resp) => resp.json()); } const dataProvider = { create: (resource, params) => dataRequest(resource, "create", params), delete: (resource, params) => dataRequest(resource, "delete", params), deleteMany: (resource, params) => dataRequest(resource, "delete_many", params), getList: (resource, params) => dataRequest(resource, "get_list", params), getMany: (resource, params) => dataRequest(resource, "get_many", params), getManyReference: (resource, params) => dataRequest(resource, "get_many_ref", params), getOne: (resource, params) => dataRequest(resource, "get_one", params), update: (resource, params) => dataRequest(resource, "update", params), updateMany: (resource, params) => dataRequest(resource, "update_many", params) } const authProvider = { login: ({username, password}) => { const body = JSON.stringify({username, password}); return apiRequest(STATE["urls"]["token"], {"method": "POST", "body": body}, true).then((resp) => { localStorage.setItem("identity", resp.headers.get("X-Token")); }); }, logout: () => { return apiRequest(STATE["urls"]["logout"], {"method": "DELETE"}, true).then((resp) => { localStorage.removeItem("identity"); }); }, checkAuth: () => { return localStorage.getItem("identity") ? Promise.resolve() : Promise.reject(); }, checkError: (error) => { return error.status === 401 ? Promise.reject() : Promise.resolve(); }, getIdentity: () => { return Promise.resolve(JSON.parse(localStorage.getItem("identity"))); }, getPermissions: () => { const identity = JSON.parse(localStorage.getItem("identity")); return Promise.resolve(identity ? identity["permissions"] : []); }, }; function evaluate(obj) { if (obj === null || obj === undefined) return obj; if (Array.isArray(obj)) return obj.map(evaluate); if (obj["__type__"] === "component") { const C = COMPONENTS[obj["type"]]; if (C === undefined) throw Error(`Unknown component '${obj["type"]}'`); let {children, ...props} = obj["props"]; props = Object.fromEntries(Object.entries(props).map(([k, v]) => [k, evaluate(v)])); if (!props["key"]) props["key"] = props["source"] || obj["type"]; if (children) return {evaluate(children)}; return ; } if (obj["__type__"] === "function") { const f = FUNCTIONS[obj["name"]]; if (f === undefined) throw Error(`Unknown function '${obj["name"]}'`); if (obj["args"] === null) return f; return f(...evaluate(obj["args"])); } if (obj["__type__"] === "regexp") return new RegExp(obj["value"]); return obj; } function createFields(fields, name, permissions) { let components = []; for (const state of Object.values(fields)) { const field = state["props"]["source"].replace(/^data\./, ""); if (!hasPermission(`${name}.${field}.view`, permissions)) continue; const c = evaluate(state); const withRecordPropNames = ["label", "sortable", "sortBy", "sortByOrder", "source"]; const withRecordProps = Object.fromEntries(withRecordPropNames.map((k) => [k, c.props[k]])); // Show icon if user doesn't have permission to view this field (based on filters). components.push( hasPermission(`${name}.${field}.view`, permissions, record) ? c : } />); } return components; } function createInputs(resource, name, perm_type, permissions) { let components = []; const resource_filters = getFilters(name, perm_type, permissions); for (let state of Object.values(resource["inputs"])) { const field = state["props"]["source"].replace(/^data\./, ""); if ((perm_type === "add" && !state["show_create"]) || !hasPermission(`${name}.${field}.${perm_type}`, permissions)) continue; const fvalues = resource_filters[field]; if (fvalues !== undefined) { // If there are filters for the resource-level permission which depend on // this field, then restrict the input options to the allowed values. const disabled = fvalues.length <= 1; const nullable = fvalues.indexOf(null); if (nullable > -1) fvalues.splice(nullable, 1); let choices = []; for (let v of fvalues) choices.push({"id": v, "name": v}); components.push( ); } else { if (perm_type === "view") { state = structuredClone(state); delete state["props"]["validate"]; } const c = evaluate(state); if (perm_type === "edit") // Don't render if filters disallow editing this field. components.push( hasPermission(`${name}.${field}.${perm_type}`, permissions, record) && c } />); else components.push(c); } } return components; } function createBulkUpdates(resource, name, permissions) { let buttons = []; for (const [label, data] of Object.entries(resource["bulk_update"])) { let allowed = true; for (const k of Object.keys(data)) { if (!hasPermission(`${name}.${k}.edit`, permissions)) { allowed = false; break; } } if (allowed) buttons.push(); } return buttons; } const AiohttpList = (resource, name, permissions) => { const exporter = (records) => { jsonExport(exportRecords(records), (err, csv) => downloadCSV(csv, name)); }; const ListActions = () => ( {hasPermission(`${name}.add`, permissions) && } ); const BulkActionButtons = () => ( <> {hasPermission(`${name}.edit`, permissions) && createBulkUpdates(resource, name, permissions)} {hasPermission(`${name}.delete`, permissions) && } ); const filters = createInputs(resource, name, "view", permissions); // Remove inputs with duplicate sources. const filterSources = filters.map(c => c["props"]["source"]); return ( } exporter={exporter} filters={filters.filter((v, i) => filterSources.indexOf(v["props"]["source"]) === i)}> }> {createFields(resource["fields"], name, permissions)} hasPermission(`${name}.edit`, permissions, record) && } /> ); } const AiohttpShow = (resource, name, permissions) => { const ShowActions = () => ( {resource["show_actions"].map(evaluate)} hasPermission(`${name}.edit`, permissions, record) && } /> ); return ( }> {createFields(resource["fields"], name, permissions)} ); } const AiohttpEdit = (resource, name, permissions) => { const EditActions = () => ( ); const AiohttpEditToolbar = props => ( hasPermission(`${name}.delete`, permissions, record) && } /> ); return( } mutationMode="pessimistic"> } sanitizeEmptyValues warnWhenUnsavedChanges> {createInputs(resource, name, "edit", permissions)} ); } const AiohttpCreate = (resource, name, permissions) => ( {createInputs(resource, name, "add", permissions)} ); /** Return any filters for a given permission. */ function getFilters(name, perm_type, permissions) { let filters = permissions[`admin.${name}.${perm_type}`]; if (filters !== undefined) return filters; filters = permissions[`admin.${name}.*`]; return filters || {}; } /** Return true if a user has the given permission. A record can be passed as the context parameter in order to check permission filters against the current record. */ function hasPermission(p, permissions, context=null) { const parts = ["admin", ...p.split(".")]; const type = parts.pop(); // Negative permissions. for (let i=parts.length; i > 0; --i) { for (let t of [type, "*"]) { let perm = [...parts.slice(0, i), t].join("."); if (permissions["~" + perm] !== undefined) return false; } } // Positive permissions. for (let i=parts.length; i > 0; --i) { for (let t of [type, "*"]) { let perm = [...parts.slice(0, i), t].join("."); if (permissions[perm] !== undefined) { if (!context) return true; let filters = permissions[perm]; for (let attr of Object.keys(filters)) { if (!filters[attr].includes(context["data"][attr])) return false; } return true; } } } return false; } const AiohttpIcon = (path) => { return ( ); }; function createResources(resources, permissions) { let components = []; for (const [name, r] of Object.entries(resources)) { components.push( AiohttpIcon(r["icon"]) : null} />); } return components; } const AiohttpAppBar = () => ( ); const App = (props) => { const {aiohttpState, ...adminProps} = props; STATE = aiohttpState; const [loaded, setLoaded] = useState(STATE["js_module"] === null); if (!loaded) { // The inline comment skips the Vite import() and allows us to use the native // browser's import() function. Needed to dynamically import a module. import(/* @vite-ignore */ STATE["js_module"]).then((mod) => { Object.assign(COMPONENTS, mod.components); Object.assign(FUNCTIONS, mod.functions); setLoaded(true); }); return ; } return ( } disableTelemetry requireAuth> {permissions => createResources(STATE["resources"], permissions)} ); }; export {App}; ================================================ FILE: admin-js/src/admin.jsx ================================================ import React from "react"; import ReactJSXRuntime from "react/jsx-runtime"; import ReactDOM from "react-dom"; import ReactDOMClient from "react-dom/client"; import {Link, Route, useLocation, useNavigate, useParams} from 'react-router-dom'; import QueryString from 'query-string'; import {App} from "./App"; // Copy libraries to global location for shim. window.React = React; window.ReactJSXRuntime = ReactJSXRuntime; window.ReactDOM = ReactDOM; window.ReactDOMClient = ReactDOMClient; window.ReactRouterDOM = {Link, Route, useLocation, useNavigate, useParams}; window.QueryString = QueryString; const _body = document.querySelector("body"); const STATE = Object.freeze(JSON.parse(_body.dataset.state)); const root = ReactDOMClient.createRoot(document.getElementById("root")); root.render( ); ================================================ FILE: admin-js/tests/permissions.test.js ================================================ import {within} from "@testing-library/dom"; import {screen, waitFor} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; global.pythonProcessPath = "examples/permissions.py"; describe("admin", () => { beforeAll(() => setLogin("admin", "")); test("view", async () => { const table = await screen.findByRole("table"); const headers = within(table).getAllByRole("columnheader"); expect(headers.slice(1, -1).map((e) => e.textContent)).toEqual(["Id", "Num", "Optional Num"]); const rows = within(table).getAllByRole("row"); const firstCells = within(rows[1]).getAllByRole("cell").slice(1, -1); expect(firstCells.map((e) => e.textContent)).toEqual(["1", "5", ""]); const secondCells = within(rows[2]).getAllByRole("cell").slice(1, -1); expect(secondCells.map((e) => e.textContent)).toEqual(["2", "82", "12"]); }); }); describe("filter", () => { beforeAll(() => setLogin("filter", "")); test("view", async () => { const table = await screen.findByRole("table"); const rows = within(table).getAllByRole("row").slice(1); expect(rows).toHaveLength(5); expect(rows.map(r => within(r).getAllByRole("cell")[1].textContent)).toEqual(["1", "3", "4", "5", "6"]); await userEvent.click(screen.getByRole("link", {"name": "Create"})); await waitFor(() => screen.getByText("Create Simple")); const num = screen.getByLabelText("Num *"); expect(num).toHaveAttribute("aria-disabled", "true"); expect(num).toHaveTextContent("5"); }); }); describe("admin", () => { beforeAll(() => setLogin("admin", "")); test("bulk update", async () => { const container = await screen.findByRole("columnheader", {"name": "Select all"}); const selectAll = within(container).getByRole("checkbox"); await userEvent.click(selectAll); expect(selectAll).toBeChecked(); await userEvent.click(await screen.findByRole("button", {"name": "Set to 7"})); expect(await screen.findByText("Update 6 simples")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", {"name": "Confirm"})); return; // Broken now await waitFor(() => screen.getAllByText("7")); const table = await screen.findByRole("table"); const rows = within(table).getAllByRole("row"); const firstCells = within(rows[1]).getAllByRole("cell"); const secondCells = within(rows[2]).getAllByRole("cell"); expect(firstCells[3]).toHaveTextContent("7"); expect(secondCells[3]).toHaveTextContent("7"); }); }); ================================================ FILE: admin-js/tests/relationships.test.js ================================================ import {within} from "@testing-library/dom"; import {screen, waitFor} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; global.pythonProcessPath = "examples/relationships.py"; test("datagrid works", async () => { const table = await screen.findByRole("table"); await userEvent.click(screen.getByRole("button", {"name": "Columns"})); await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Children")); await userEvent.keyboard("[Escape]"); const grid = await within(table).findByRole("table"); await sleep(0.1); const childHeaders = within(grid).getAllByRole("columnheader"); expect(childHeaders.slice(1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]); const childRows = within(grid).getAllByRole("row"); expect(childRows.length).toBe(3); const firstCells = within(childRows[1]).getAllByRole("cell"); expect(firstCells.slice(1).map((e) => e.textContent)).toEqual(["2", "Child Bar", "5"]); const secondCells = within(childRows[2]).getAllByRole("cell"); expect(secondCells.slice(1).map((e) => e.textContent)).toEqual(["1", "Child Foo", "1"]); const h = within(grid).getByRole("columnheader", {"name": "Select all"}); const check = within(h).getByRole("checkbox"); await userEvent.click(check); expect(check).toBeChecked(); // Check page hasn't redirected to show view. expect(location.href).toMatch(/\/onetomany_parent$/); }); test("onetomany child displays", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Onetomany children")); await waitFor(() => screen.getByRole("heading", {"name": "Onetomany children"})); await sleep(1); await userEvent.click(screen.getByRole("button", {"name": "Columns"})); // TODO: Remove when fixed: https://github.com/marmelab/react-admin/issues/9587 await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Parent Id")); await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Parent")); await userEvent.keyboard("[Escape]"); const table = screen.getAllByRole("table")[0]; const headers = within(table.querySelector("thead")).getAllByRole("columnheader"); expect(headers.slice(1, -1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value", "Parent Id", "Parent"]); const rows = within(table).getAllByRole("row").filter((e) => e.parentElement.parentElement === table); const firstCells = within(rows[1]).getAllByRole("cell").filter((e) => e.parentElement === rows[1]); expect(firstCells.slice(1, -2).map((e) => e.textContent)).toEqual(["1", "Child Foo", "1", "Bar"]); const secondCells = within(rows[2]).getAllByRole("cell").filter((e) => e.parentElement === rows[2]); expect(secondCells.slice(1, -2).map((e) => e.textContent)).toEqual(["2", "Child Bar", "5", "Bar"]); const grid = within(firstCells.at(-2)).getByRole("table"); const childHeaders = within(grid).getAllByRole("columnheader"); expect(childHeaders.map((e) => e.textContent)).toEqual(["Name", "Value"]); const childRows = within(grid).getAllByRole("row"); expect(childRows.length).toBe(2); const childCells = within(childRows[1]).getAllByRole("cell"); expect(childCells.map((e) => e.textContent)).toEqual(["Bar", "2"]); }); test("onetoone parents display", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Onetoone parents")); await waitFor(() => screen.getByRole("heading", {"name": "Onetoone parents"})); await sleep(1); const table = screen.getByRole("table"); await userEvent.click(within(table).getAllByRole("row")[1]); await waitFor(() => screen.getByRole("heading", {"name": "Onetoone parent Foo"})); const grid = await screen.findByRole("table"); const childHeaders = within(grid).getAllByRole("columnheader"); expect(childHeaders.map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]); const childRows = within(grid).getAllByRole("row"); expect(childRows.length).toBe(2); const childCells = within(childRows[1]).getAllByRole("cell"); expect(childCells.map((e) => e.textContent)).toEqual(["2", "Child Bar", "2"]); }); test("manytomany left displays", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Manytomany lefts")); await waitFor(() => screen.getByRole("heading", {"name": "Manytomany lefts"})); await sleep(1); await userEvent.click(screen.getByRole("button", {"name": "Columns"})); // TODO: Remove when fixed: https://github.com/marmelab/react-admin/issues/9587 await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Children")); await userEvent.keyboard("[Escape]"); const table = screen.getAllByRole("table")[0]; const headers = within(table.querySelector("thead")).getAllByRole("columnheader"); expect(headers.slice(1, -1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value", "Children"]); const rows = within(table).getAllByRole("row").filter((e) => e.parentElement.parentElement === table); const firstCells = within(rows[1]).getAllByRole("cell").filter((e) => e.parentElement === rows[1]); expect(firstCells.slice(1, -2).map((e) => e.textContent)).toEqual(["1", "Foo", "2"]); const secondCells = within(rows[2]).getAllByRole("cell").filter((e) => e.parentElement === rows[2]); expect(secondCells.slice(1, -2).map((e) => e.textContent)).toEqual(["2", "Bar", "3"]); const firstGrid = await within(firstCells.at(-2)).findByRole("table"); const firstHeaders = within(firstGrid).getAllByRole("columnheader"); await waitFor(() => firstHeaders[1].textContent.trim() != ""); expect(firstHeaders.slice(1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]); const firstRows = within(firstGrid).getAllByRole("row"); expect(firstRows.length).toBe(3); let cells = within(firstRows[1]).getAllByRole("cell"); expect(cells.slice(1).map((e) => e.textContent)).toEqual(["3", "Bar Child", "6"]); cells = within(firstRows[2]).getAllByRole("cell"); expect(cells.slice(1).map((e) => e.textContent)).toEqual(["1", "Foo Child", "5"]); const secondGrid = within(secondCells.at(-2)).getByRole("table"); const secondHeaders = within(secondGrid).getAllByRole("columnheader"); await waitFor(() => secondHeaders[1].textContent.trim() != ""); expect(secondHeaders.slice(1).map((e) => e.textContent)).toEqual(["Id", "Name", "Value"]); const secondRows = within(secondGrid).getAllByRole("row"); expect(secondRows.length).toBe(4); cells = within(secondRows[1]).getAllByRole("cell"); expect(cells.slice(1).map((e) => e.textContent)).toEqual(["3", "Bar Child", "6"]); cells = within(secondRows[2]).getAllByRole("cell"); expect(cells.slice(1).map((e) => e.textContent)).toEqual(["2", "Baz Child", "7"]); cells = within(secondRows[3]).getAllByRole("cell"); expect(cells.slice(1).map((e) => e.textContent)).toEqual(["1", "Foo Child", "5"]); }); test("composite foreign key child displays table", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Composite foreign key children")); await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key children"})); await userEvent.click(screen.getByRole("button", {"name": "Columns"})); await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Parents")); await userEvent.keyboard("[Escape]"); await sleep(0.5); const table = screen.getAllByRole("table")[0]; const rows = within(table).getAllByRole("row").filter((e) => e.parentElement.parentElement === table); const cells = within(rows[2]).getAllByRole("cell").filter((e) => e.parentElement === rows[2]); expect(cells.slice(1, -2).map((e) => e.textContent)).toEqual(["0", "1", "B"]); expect(within(rows[1]).getAllByRole("cell").at(-2)).toHaveTextContent("No results found"); const grid = within(cells.at(-2)).getByRole("table"); const childHeaders = within(grid).getAllByRole("columnheader"); expect(childHeaders.slice(1).map((e) => e.textContent)).toEqual(["Item Id", "Item Name"]); const childRows = within(grid).getAllByRole("row"); expect(childRows.length).toBe(2); const childCells = within(childRows[1]).getAllByRole("cell"); expect(childCells.slice(1).map((e) => e.textContent)).toEqual(["1", "Foo"]); }); test("composite foreign key parent displays", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Composite foreign key parents")); await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key parents"})); await sleep(1); const table = screen.getAllByRole("table")[0]; const rows = within(table).getAllByRole("row").filter((e) => e.parentElement.parentElement === table); const cells = within(rows[1]).getAllByRole("cell").filter((e) => e.parentElement === rows[1]); expect(cells.slice(1, -1).map((e) => e.textContent)).toEqual(["1", "Foo"]); await userEvent.click(rows[1]); await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key parent"})); const main = screen.getByRole("main"); expect((await within(main).findAllByRole("link", {"name": "B"}))[0]).toHaveTextContent("B"); const grid = within(main).getByRole("table"); const headers = within(grid).getAllByRole("columnheader"); expect(headers.map((e) => e.textContent)).toEqual(["Description"]); const childRows = within(grid).getAllByRole("row"); expect(childRows.length).toBe(2); const childCells = within(childRows[1]).getAllByRole("cell"); expect(childCells.map((e) => e.textContent)).toEqual(["B"]); }); test("composite foreign key reference input updates", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Composite foreign key parents")); await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key parents"})); const table = screen.getAllByRole("table")[0]; const rows = within(table).getAllByRole("row").filter((e) => e.parentElement.parentElement === table); await userEvent.click(within(rows[1]).getByRole("link", {"name": "Edit"})); await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key parent"})); // TODO: identifiers are screwed up for these inputs. const referenceInput = await screen.findByRole("combobox", {"name": "Child Id Ref Num"}); const referenceInput2 = await screen.findByRole("combobox", {"name": ""}); await waitFor(() => expect(referenceInput).not.toHaveValue("")); expect(referenceInput).toHaveValue("B"); expect(referenceInput2).toHaveValue("B"); await userEvent.click(within(referenceInput.parentElement).getByRole("button", {"name": "Open"})); const popup = await screen.findByRole("presentation"); const options = within(popup).getAllByRole("option"); expect(options.map(e => e.textContent)).toEqual(["A", "C"]); await userEvent.click(within(popup).getByRole("option", {"name": "A"})); expect(referenceInput).toHaveValue("A"); expect(referenceInput2).toHaveValue("A"); await userEvent.click(screen.getByRole("button", {"name": "Save"})); await waitFor(() => screen.getByRole("heading", {"name": "Composite foreign key parents"})); await userEvent.click(screen.getByRole("button", {"name": "Columns"})); await userEvent.click(within(screen.getByRole("presentation")).getByLabelText("Ref Num")); await userEvent.keyboard("[Escape]"); await sleep(1); const table2 = screen.getAllByRole("table")[0]; const rows2 = within(table2).getAllByRole("row").filter((e) => e.parentElement.parentElement === table2); const cells = within(rows2[1]).getAllByRole("cell").filter((e) => e.parentElement === rows2[1]); expect(cells.at(-2)).toHaveTextContent("A"); }); ================================================ FILE: admin-js/tests/setupTests.js ================================================ const http = require("http"); const {spawn} = require("child_process"); import "whatwg-fetch"; // https://github.com/jsdom/jsdom/issues/1724 import "@testing-library/jest-dom"; import failOnConsole from "jest-fail-on-console"; import {memoryStore} from "react-admin"; import {configure, render, screen} from "@testing-library/react"; import * as structuredClone from "@ungap/structured-clone"; const {App} = require("../src/App"); let pythonProcess; let STATE; jest.setTimeout(300000); // 5 mins configure({"asyncUtilTimeout": 10000}); jest.mock("react-admin", () => { const originalModule = jest.requireActual("react-admin"); return { ...originalModule, downloadCSV: jest.fn(), // Mock downloadCSV to test export button. }; }); // https://github.com/jsdom/jsdom/issues/3363#issuecomment-1387439541 global.structuredClone = structuredClone.default; // To render full-width window.matchMedia = (query) => ({ matches: true, addListener: () => {}, removeListener: () => {} }); // Ignore not implemented errors window.scrollTo = jest.fn(); global.sleep = (delay_s) => new Promise((resolve) => setTimeout(resolve, delay_s * 1000)); failOnConsole({ silenceMessage: (msg) => { return ( // Suppress act() warnings, because there's too many async changes happening. msg.includes("inside a test was not wrapped in act(...).") // Error in react-admin which doesn't actually break anything. // https://github.com/marmelab/react-admin/issues/8849 || msg.includes("Fetched record's id attribute") || msg.includes("The above error occurred in the component") ); } }); beforeAll(async() => { if (!global.pythonProcessPath) return; if (global.__coverage__) pythonProcess = spawn("coverage", ["run", "--append", "--source=examples/,aiohttp_admin/", global.pythonProcessPath], {"cwd": ".."}); else pythonProcess = spawn("python3", ["-u", global.pythonProcessPath], {"cwd": ".."}); pythonProcess.stderr.on("data", (data) => {console.error(`stderr: ${data}`);}); //pythonProcess.stdout.on("data", (data) => {console.log(`stdout: ${data}`);}); // Wait till server accepts requests await new Promise(resolve => { const cutoff = Date.now() + 10000; function alive() { http.get("http://localhost:8080/", resolve).on("error", e => Date.now() < cutoff && setTimeout(alive, 100)); } alive(); }); await new Promise(resolve => { http.get("http://localhost:8080/admin", resp => { if (resp.statusCode !== 200) throw new Error("Request failed"); let html = ""; resp.on("data", (chunk) => { html += chunk; }); resp.on("end", () => { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); STATE = JSON.parse(doc.querySelector("body").dataset.state); resolve(); }); }); }); }, 10000); afterAll(() => { if (pythonProcess) pythonProcess.kill("SIGINT"); }); let login = {"username": "admin", "password": "admin"}; global.setLogin = (username, password) => { login = {username, password}; }; beforeEach(async () => { location.href = "/"; localStorage.clear(); if (STATE) { const resp = await fetch("http://localhost:8080/admin/token", {"method": "POST", "body": JSON.stringify(login)}); localStorage.setItem("identity", resp.headers.get("X-Token")); render(); const profile = await screen.findByText(login["username"], {"exact": false}); expect(profile).toHaveAccessibleName("Profile"); } }); ================================================ FILE: admin-js/tests/simple.test.js ================================================ import {within} from "@testing-library/dom"; import {screen, waitFor} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import {downloadCSV as mockDownloadCSV} from "react-admin"; global.pythonProcessPath = "examples/simple.py"; test("login works", async () => { await userEvent.click(screen.getByRole("button", {"name": "Profile"})); await userEvent.click(await screen.findByRole("menuitem", {"name": "Logout"})); await userEvent.type(await screen.findByLabelText(/Username/), "admin"); await userEvent.type(screen.getByLabelText(/Password/), "admin"); await userEvent.click(screen.getByRole("button", {"name": "Sign in"})); expect(await screen.findByText("Admin user")).toBeInTheDocument(); }); test("data is displayed", async () => { const table = await screen.findByRole("table"); const headers = within(table).getAllByRole("columnheader"); expect(headers.slice(1, -1).map((e) => e.textContent)).toEqual(["Id", "Num", "Optional Num", "Value"]); const rows = within(table).getAllByRole("row"); const firstCells = within(rows[1]).getAllByRole("cell").slice(1, -1); expect(firstCells.map((e) => e.textContent)).toEqual(["1", "5", "", "first"]); const secondCells = within(rows[2]).getAllByRole("cell").slice(1, -1); expect(secondCells.map((e) => e.textContent)).toEqual(["2", "82", "12", "with child"]); }); test("parents are displayed", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Parents")); await waitFor(() => screen.getByText("USD")); const table = screen.getByRole("table"); const headers = within(table).getAllByRole("columnheader"); expect(headers.slice(1, -1).map((e) => e.textContent)).toEqual(["Id", "Date", "Currency"]); const rows = within(table).getAllByRole("row"); const firstCells = within(rows[1]).getAllByRole("cell").slice(1, -1); expect(firstCells.map((e) => e.textContent)).toEqual(["with child", "2/13/2023, 7:04:00 PM", "USD"]); expect(within(firstCells[0]).getByRole("link")).toBeInTheDocument(); }); test("filter labels are correct", async () => { const main = await screen.findByRole("main"); const quickSearch = main.querySelector("form"); const labels = quickSearch.querySelectorAll("label"); expect(Array.from(labels).map((e) => e.textContent)).toEqual(["Id", "Num", "Optional Num", "Value"]); }); test("parent filter labels are correct", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Parents")); await waitFor(() => screen.getByText("USD")); const main = await screen.findByRole("main"); const quickSearch = main.querySelector("form"); const labels = quickSearch.querySelectorAll("label"); expect(Array.from(labels).map((e) => e.textContent)).toEqual(["Id", "Date", "Currency"]); }); test("filters work", async () => { const main = await screen.findByRole("main"); const quickSearch = main.querySelector("form"); const table = await within(main).findByRole("table"); let rows = within(table).getAllByRole("row"); expect(rows.length).toBeGreaterThan(2); return; // Broken now await userEvent.type(within(quickSearch).getByRole("spinbutton", {"name": "Id"}), "1"); await waitFor(() => within(main).getByRole("button", {"name": "Add filter"})); await sleep(0.5); rows = within(table).getAllByRole("row"); expect(rows.length).toBe(2); expect(within(rows[1]).getAllByRole("cell")[1]).toHaveTextContent("1"); }); test("enum filter works", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Parents")); await waitFor(() => screen.getByText("USD")); const main = screen.getByRole("main"); const quickSearch = main.querySelector("form"); const table = within(main).getByRole("table"); const currencySelect = within(quickSearch).getByRole("combobox", {"name": "Currency"}); expect(within(table).getAllByRole("row").length).toBe(2); const record = within(table).getAllByRole("row")[1]; await userEvent.click(currencySelect); return; // Broken now await userEvent.click(await screen.findByRole("option", {"name": "GBP"})); expect(await within(main).findByText("No results found")).toBeInTheDocument(); expect(within(main).queryByRole("table")).not.toBeInTheDocument(); await userEvent.click(currencySelect); await userEvent.click(await screen.findByRole("option", {"name": "USD"})); await waitFor(() => expect(within(main).getByRole("table")).toBeInTheDocument()); const rows = within(within(main).getByRole("table")).getAllByRole("row"); expect(currencySelect).toHaveTextContent("USD"); expect(rows.length).toBe(2); expect(within(rows[1]).getByText("USD")).toBeInTheDocument(); }); test("edit form", async () => { await userEvent.click((await screen.findAllByLabelText("Edit"))[0]); await waitFor(() => screen.getByRole("link", {"name": "List"})); const main = screen.getByRole("main"); const edit = main.querySelector("form"); const id = within(edit).getByLabelText("Id *"); //expect(id).toBeRequired(); expect(id).toHaveValue(1); const num = within(edit).getByLabelText("Num *"); //expect(num).toBeRequired(); expect(num).toHaveValue(5); const opt = within(edit).getByLabelText("Optional Num"); expect(opt).not.toBeRequired(); expect(opt).toHaveValue(null); const value = within(edit).getByLabelText("Value *"); //expect(value).toBeRequired(); expect(value).toHaveValue("first"); }); test("reference input label", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Parents")); await waitFor(() => screen.getByText("USD")); await userEvent.click(screen.getAllByLabelText("Edit")[0]); await waitFor(() => screen.getByRole("link", {"name": "List"})); const main = screen.getByRole("main"); const edit = main.querySelector("form"); expect(within(edit).getByLabelText("Id *")).toHaveValue("with child"); }); test("reference input filter", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Parents")); await waitFor(() => screen.getByText("USD")); const main = screen.getByRole("main"); const quickSearch = main.querySelector("form"); const input = within(quickSearch).getByRole("combobox", {"name": "Id"}); const table = within(main).getByRole("table"); expect(within(table).getAllByRole("row").length).toBe(2); await userEvent.click(within(input.parentElement).getByRole("button", {"name": "Open"})); const resultsInitial = await screen.findByRole("listbox", {"name": "Id"}); const optionsInitial = within(resultsInitial).getAllByRole("option"); expect(optionsInitial.map(e => e.textContent)).toEqual(["first", "with child"]); return; // Broken now await userEvent.click(within(resultsInitial).getByRole("option", {"name": "first"})); await waitFor(() => expect(screen.queryByText("USD")).not.toBeInTheDocument()); expect(await within(main).findByText("No results found")).toBeInTheDocument(); await userEvent.type(input, "w"); const resultsFiltered = await screen.findByRole("listbox", {"name": "Id"}); const optionsFiltered = within(resultsFiltered).getAllByRole("option"); expect(optionsFiltered.map(e => e.textContent)).toEqual(["with child"]); await userEvent.click(within(resultsFiltered).getByRole("option", {"name": "with child"})); await waitFor(() => expect(screen.queryByText("USD")).toBeInTheDocument()); expect(within(table).getAllByRole("row").length).toBe(2); }); test("export works", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Export"})); await waitFor(() => expect(mockDownloadCSV).toHaveBeenCalled()); const csv = "id,num,optional_num,value\n1,5,,first\n2,82,12,with child"; expect(mockDownloadCSV).toHaveBeenCalledWith(csv, "simple"); }); test("create form works", async () => { await userEvent.click(await screen.findByLabelText("Create")); await waitFor(() => screen.getByRole("heading", {"name": "Create Simple"})); await userEvent.type(screen.getByLabelText("Num *"), "12"); await userEvent.type(screen.getByLabelText("Value *"), "Foo"); await userEvent.click(screen.getByRole("button", {"name": "Save"})); const main = await screen.findByRole("main"); expect(await within(main).findByText("3")).toBeInTheDocument(); expect(within(main).getByText("12")).toBeInTheDocument(); expect(within(main).getByText("Foo")).toBeInTheDocument(); }); test("edit submit", async () => { await userEvent.click((await screen.findAllByLabelText("Edit"))[0]); await waitFor(() => screen.getByRole("link", {"name": "List"})); const form = screen.getByRole("main").querySelector("form"); expect(within(form).getByLabelText("Id *")).toHaveValue(1); await userEvent.type(within(form).getByLabelText("Id *"), "3"); await userEvent.type(within(form).getByLabelText("Num *"), "7"); await userEvent.click(within(form).getByRole("button", {"name": "Save"})); const table = await screen.findByRole("table"); await sleep(0.2); const rows = within(table).getAllByRole("row"); const cells = within(rows.at(-1)).getAllByRole("cell").slice(1, -1); expect(cells.map((e) => e.textContent)).toEqual(["13", "57", "", "first"]); expect(within(table).queryByText("1")).not.toBeInTheDocument(); }); test("reference input edit", async () => { await userEvent.click(await screen.findByRole("button", {"name": "Open menu"})); await userEvent.click(await screen.findByText("Parents")); await waitFor(() => screen.getByText("USD")); await userEvent.click(screen.getAllByLabelText("Edit")[0]); await waitFor(() => screen.getByRole("link", {"name": "List"})); const form = screen.getByRole("main").querySelector("form"); const idInput = within(form).getByLabelText(/Id/); expect(idInput).toHaveValue("with child"); await userEvent.click(within(idInput.parentElement).getByRole("button", {"name": "Open"})); const results = await screen.findByRole("listbox", {"name": "Id"}); await userEvent.click(await within(results).findByRole("option", {"name": "first"})); expect(idInput).toHaveValue("first"); await userEvent.click(within(form).getByRole("button", {"name": "Save"})); const table = await screen.findByRole("table"); await sleep(0.2); const rows = within(table).getAllByRole("row"); expect(rows.length).toEqual(2); const idCell = within(rows[1]).getAllByRole("cell")[1]; expect(idCell).toHaveTextContent("first"); }); ================================================ FILE: admin-js/vite.config.js ================================================ import { defineConfig } from "vite"; export default defineConfig({ build: { minify: "terser", outDir: "../aiohttp_admin/static/", rollupOptions: { input: "src/admin.jsx", output: { entryFileNames: "[name].js" } }, sourcemap: true, }, }) ================================================ FILE: aiohttp_admin/__init__.py ================================================ import re import secrets from typing import Optional import aiohttp_security import aiohttp_session from aiohttp import web from aiohttp.typedefs import Handler from aiohttp_session.cookie_storage import EncryptedCookieStorage from pydantic import ValidationError from .routes import setup_resources, setup_routes from .security import AdminAuthorizationPolicy, Permissions, TokenIdentityPolicy, check from .types import (Schema, State, UserDetails, check_credentials_key, data, fk, permission_re_key, state_key) __all__ = ("Permissions", "Schema", "UserDetails", "data", "fk", "permission_re_key", "setup") __version__ = "0.1.0a3" @web.middleware async def pydantic_middleware(request: web.Request, handler: Handler) -> web.StreamResponse: try: return await handler(request) except ValidationError as e: raise web.HTTPBadRequest(text=e.json(), content_type="application/json") def setup(app: web.Application, schema: Schema, *, path: str = "/admin", secret: Optional[bytes] = None) -> web.Application: """Initialize the admin. Args: app - Parent application to add the admin sub app to. schema - Schema to define admin layout/behaviour. auth_policy - aiohttp-security auth policy. path - The path used when adding the admin sub app to app. secret - Cookie encryption key. If not provided, a random key is generated, which will result in users being logged out each time the app is restarted. To avoid this (or if using multiple servers) it is recommended to generate a random secret (e.g. secrets.token_bytes()) and save the value. Returns the admin application. """ async def on_startup(admin: web.Application) -> None: """Configuration steps which require the application to be already configured. This is very awkward, as we need the nested function to be able to reference prefixed_subapp at the end of the setup. Once we have that object the app is frozen and we can't modify the app, in order to add this startup function. Therefore, we add this function first, then we can get the reference from the enclosing scope later. """ storage._cookie_params["path"] = prefixed_subapp.canonical admin[state_key]["urls"] = { "token": str(admin.router["token"].url_for()), "logout": str(admin.router["logout"].url_for()) } def key(r: web.RouteDef) -> str: name: str = r.kwargs["name"] return name.removeprefix(m.name + "_") def value(r: web.RouteDef) -> tuple[str, str]: return (r.method, str(admin.router[r.kwargs["name"]].url_for())) for res in schema["resources"]: m = res["model"] urls = admin[state_key]["resources"][m.name]["urls"] urls.update((key(r), value(r)) for r in m.routes) schema = check(Schema, schema) if secret is None: secret = secrets.token_bytes() admin = web.Application() admin.middlewares.append(pydantic_middleware) admin.on_startup.append(on_startup) admin[check_credentials_key] = schema["security"]["check_credentials"] admin[state_key] = State({"view": schema.get("view", {}), "js_module": schema.get("js_module"), "urls": {}, "resources": {}}) max_age = schema["security"].get("max_age") secure = schema["security"].get("secure", True) storage = EncryptedCookieStorage( secret, max_age=max_age, httponly=True, samesite="Strict", secure=secure) identity_policy = TokenIdentityPolicy(storage._fernet, schema) aiohttp_session.setup(admin, storage) aiohttp_security.setup(admin, identity_policy, AdminAuthorizationPolicy(schema)) setup_routes(admin) setup_resources(admin, schema) resource_patterns = [] for r, state in admin[state_key]["resources"].items(): fields = (f.removeprefix("data.") for f in state["fields"].keys()) resource_patterns.append( r"(?#Resource name){r}" r"(?#Optional field name)(\.({f}))?" r"(?#Permission type)\.(view|edit|add|delete|\*)" r"(?#No filters if negated)(?(2)$|" r'(?#Optional filters)\|({f})=(?#JSON number or str)(\".*?\"|\d+))*'.format( r=r, f="|".join(fields))) p_re = (r"(?#Global admin permission)~?admin\.(view|edit|add|delete|\*)" r"|" r"(?#Resource permission)(~)?admin\.({})").format("|".join(resource_patterns)) admin[permission_re_key] = re.compile(p_re) prefixed_subapp = app.add_subapp(path, admin) return admin ================================================ FILE: aiohttp_admin/backends/__init__.py ================================================ ================================================ FILE: aiohttp_admin/backends/abc.py ================================================ import asyncio import json import sys from abc import ABC, abstractmethod from collections.abc import Sequence from datetime import date, datetime, time from enum import Enum from functools import cached_property, partial from types import MappingProxyType from typing import Any, Generic, Literal, Optional, TypeVar, final from aiohttp import web from aiohttp_security import check_permission, permits from pydantic import Json from ..security import check, permissions_as_dict from ..types import ComponentState, InputState, fk, resources_key if sys.version_info >= (3, 10): from typing import TypeAlias else: from typing_extensions import TypeAlias if sys.version_info >= (3, 12): from typing import TypedDict else: from typing_extensions import TypedDict _ID = TypeVar("_ID", bound=tuple[object, ...]) Record = dict[str, object] Meta = Optional[dict[str, object]] INPUT_TYPES = MappingProxyType({ "BooleanInput": bool, "DateInput": date, "DateTimeInput": datetime, "NumberInput": float, "TimeInput": time }) class Encoder(json.JSONEncoder): def default(self, o: object) -> Any: if isinstance(o, (date, time)): return str(o) if isinstance(o, Enum): return o.value if isinstance(o, bytes): return o.decode(errors="replace") return super().default(o) json_response = partial(web.json_response, dumps=partial(json.dumps, cls=Encoder)) class APIRecord(TypedDict): id: str data: Record class _Pagination(TypedDict): page: int perPage: int class _Sort(TypedDict): field: str order: Literal["ASC", "DESC"] class _Params(TypedDict, total=False): meta: Meta class GetListParams(_Params): pagination: Json[_Pagination] sort: Json[_Sort] filter: Json[dict[str, object]] class GetOneParams(_Params): id: str class GetManyParams(_Params): ids: Json[tuple[str, ...]] class GetManyRefAPIParams(_Params): target: str id: str pagination: Json[_Pagination] sort: Json[_Sort] filter: Json[dict[str, object]] class GetManyRefParams(_Params): target: tuple[str, ...] id: tuple[object, ...] pagination: Json[_Pagination] sort: Json[_Sort] filter: Json[dict[str, object]] class _CreateData(TypedDict): """Id will not be included for create calls.""" data: Record class CreateParams(_Params): data: Json[_CreateData] class UpdateParams(_Params): id: str data: Json[APIRecord] previousData: Json[APIRecord] class UpdateManyParams(_Params): ids: Json[tuple[str, ...]] data: Json[Record] class DeleteParams(_Params): id: str previousData: Json[APIRecord] class DeleteManyParams(_Params): ids: Json[tuple[str, ...]] class _ListQuery(TypedDict): sort: _Sort filter: dict[str, object] class AbstractAdminResource(ABC, Generic[_ID]): name: str fields: dict[str, ComponentState] inputs: dict[str, InputState] primary_key: tuple[str, ...] omit_fields: set[str] _id_type: type[_ID] _foreign_rows: set[tuple[str, ...]] def __init__(self, record_type: Optional[dict[str, TypeAlias]] = None) -> None: for k, c in (*self.fields.items(), *self.inputs.items()): c["props"].setdefault("key", k) # For runtime type checking only. if record_type is None: record_type = {k.removeprefix("data."): Any for k in self.inputs} self._raw_record_type = record_type self._record_type = TypedDict("RecordType", record_type, total=False) # type: ignore[misc] @final async def filter_by_permissions(self, request: web.Request, perm_type: str, record: Record, original: Optional[Record] = None) -> Record: """Return a filtered record containing permissible fields only.""" return {k: v for k, v in record.items() if await permits(request, f"admin.{self.name}.{k}.{perm_type}", context=(request, original or record))} @abstractmethod async def get_list(self, params: GetListParams) -> tuple[list[Record], int]: """Return list of records and total count available (when not paginating).""" @abstractmethod async def get_one(self, record_id: _ID, meta: Meta) -> Record: """Return the matching record.""" @abstractmethod async def get_many(self, record_ids: Sequence[_ID], meta: Meta) -> list[Record]: """Return the matching records.""" @abstractmethod async def get_many_ref(self, params: GetManyRefParams) -> tuple[list[Record], int]: """Return list of records and total count available (when not paginating).""" @abstractmethod async def update(self, record_id: _ID, data: Record, previous_data: Record, meta: Meta) -> Record: """Update the record and return the updated record.""" @abstractmethod async def update_many(self, record_ids: Sequence[_ID], data: Record, meta: Meta) -> list[_ID]: """Update multiple records and return the IDs of updated records.""" @abstractmethod async def create(self, data: Record, meta: Meta) -> Record: """Create a new record and return the created record.""" @abstractmethod async def delete(self, record_id: _ID, previous_data: Record, meta: Meta) -> Record: """Delete a record and return the deleted record.""" @abstractmethod async def delete_many(self, record_ids: Sequence[_ID], meta: Meta) -> list[_ID]: """Delete the matching records and return their IDs.""" async def get_many_ref_name(self, target: str, meta: Meta) -> str: """Return the resource name for the reference. This can be used to change which resource should be returned by get_many_ref(). For example, if we have an SQLAlchemy model called 'parent' with a relationship called children, then a normal get_many_ref_name() call would go to the 'child' model with the details from the parent, and the default behaviour would work. However, the SQLAlchemy backend uses the meta to switch this and send the request to the 'parent' model instead and then use the children ORM attribute to fetch the referenced resources, thus requiring this method to return 'child'. This allows the SQLAlchemy backend to support complex relationships (e.g. many-to-many) without needing react-admin to know the details. """ return self.name # https://marmelab.com/react-admin/DataProviderWriting.html @final async def _get_list(self, request: web.Request) -> web.Response: await check_permission(request, f"admin.{self.name}.view", context=(request, None)) query = check(GetListParams, request.query) self._process_list_query(query, request) raw_results, total = await self.get_list(query) results = [await self._convert_record(r, request) for r in raw_results if await permits(request, f"admin.{self.name}.view", context=(request, r))] return json_response({"data": results, "total": total}) @final async def _get_one(self, request: web.Request) -> web.Response: await check_permission(request, f"admin.{self.name}.view", context=(request, None)) query = check(GetOneParams, request.query) record_id = check(self._id_type, query["id"].split("|")) result = await self.get_one(record_id, query.get("meta")) if not await permits(request, f"admin.{self.name}.view", context=(request, result)): raise web.HTTPForbidden() return json_response({"data": await self._convert_record(result, request)}) @final async def _get_many(self, request: web.Request) -> web.Response: await check_permission(request, f"admin.{self.name}.view", context=(request, None)) query = check(GetManyParams, request.query) record_ids = check(tuple[self._id_type, ...], (q.split("|") for q in query["ids"])) # type: ignore[name-defined] raw_results = await self.get_many(record_ids, query.get("meta")) if not raw_results: raise web.HTTPNotFound() results = [await self._convert_record(r, request) for r in raw_results if await permits(request, f"admin.{self.name}.view", context=(request, r))] return json_response({"data": results}) @final async def _get_many_ref(self, request: web.Request) -> web.Response: query = check(GetManyRefAPIParams, request.query) meta = query["filter"].pop("__meta__", None) if meta is not None: query["meta"] = check(dict[str, object], meta) reference = await self.get_many_ref_name(query["target"], query.get("meta")) ref_model = request.app[resources_key][reference] await check_permission(request, f"admin.{ref_model.name}.view", context=(request, None)) ref_model._process_list_query(query, request) if query["target"].startswith("fk_"): target = tuple(query["target"].removeprefix("fk_").split("__")) record_id = tuple(check(self._raw_record_type[k], v) for k, v in zip(target, query["id"].split("|"))) else: target = (query["target"],) record_id = check(self._id_type, query["id"].split("|")) raw_results, total = await self.get_many_ref({**query, "target": target, "id": record_id}) results = [await ref_model._convert_record(r, request) for r in raw_results if await permits(request, f"admin.{ref_model.name}.view", context=(request, r))] return json_response({"data": results, "total": total}) @final async def _create(self, request: web.Request) -> web.Response: query = check(CreateParams, request.query) # TODO(Pydantic): Dissallow extra arguments for k in query["data"]["data"]: if k not in self.inputs: raise web.HTTPBadRequest(reason=f"Invalid field '{k}'") record = self._check_record(query["data"]["data"]) await check_permission(request, f"admin.{self.name}.add", context=(request, record)) for k, v in record.items(): if v is not None: await check_permission(request, f"admin.{self.name}.{k}.add", context=(request, record)) result = await self.create(record, query.get("meta")) return json_response({"data": await self._convert_record(result, request)}) @final async def _update(self, request: web.Request) -> web.Response: await check_permission(request, f"admin.{self.name}.edit", context=(request, None)) query = check(UpdateParams, request.query) record_id = check(self._id_type, query["id"].split("|")) # TODO(Pydantic): Dissallow extra arguments for k in query["data"]["data"]: if k not in self.inputs: raise web.HTTPBadRequest(reason=f"Invalid field '{k}'") record = self._check_record(query["data"]["data"]) previous_data = self._check_record(query["previousData"]["data"]) # Check original record is allowed by permission filters. original = await self.get_one(record_id, query.get("meta")) if not await permits(request, f"admin.{self.name}.edit", context=(request, original)): raise web.HTTPForbidden() # Filter rather than forbid because react-admin still sends fields without an # input component. The query may not be the complete dict though, so we must # pass original for testing. record = await self.filter_by_permissions(request, "edit", record, original) # Check new values are allowed by permission filters. if not await permits(request, f"admin.{self.name}.edit", context=(request, record)): raise web.HTTPForbidden() if not record: raise web.HTTPBadRequest(reason="No allowed fields to change.") result = await self.update(record_id, record, previous_data, query.get("meta")) return json_response({"data": await self._convert_record(result, request)}) @final async def _update_many(self, request: web.Request) -> web.Response: await check_permission(request, f"admin.{self.name}.edit", context=(request, None)) query = check(UpdateManyParams, request.query) record_ids = check(tuple[self._id_type, ...], (i.split("|") for i in query["ids"])) # type: ignore[name-defined] # TODO(Pydantic): Dissallow extra arguments for k in query["data"]: if k not in self.inputs: raise web.HTTPBadRequest(reason=f"Invalid field '{k}'") record = self._check_record(query["data"]) # Check original records are allowed by permission filters. originals = await self.get_many(record_ids, query.get("meta")) if not originals: raise web.HTTPNotFound() allowed = (permits(request, f"admin.{self.name}.edit", context=(request, r)) for r in originals) allowed_f = (permits(request, f"admin.{self.name}.{k}.edit", context=(request, r)) for r in originals for k in record) if not all(await asyncio.gather(*allowed, *allowed_f)): raise web.HTTPForbidden() # Check new values are allowed by permission filters. if not await permits(request, f"admin.{self.name}.edit", context=(request, record)): raise web.HTTPForbidden() ids = await self.update_many(record_ids, record, query.get("meta")) # get_many() is called above, so we can be sure there will be results here. return json_response({"data": self._convert_ids(ids)}) @final async def _delete(self, request: web.Request) -> web.Response: await check_permission(request, f"admin.{self.name}.delete", context=(request, None)) query = check(DeleteParams, request.query) record_id = check(self._id_type, query["id"].split("|")) previous_data = self._check_record(query["previousData"]["data"]) original = await self.get_one(record_id, query.get("meta")) if not await permits(request, f"admin.{self.name}.delete", context=(request, original)): raise web.HTTPForbidden() result = await self.delete(record_id, previous_data, query.get("meta")) return json_response({"data": await self._convert_record(result, request)}) @final async def _delete_many(self, request: web.Request) -> web.Response: await check_permission(request, f"admin.{self.name}.delete", context=(request, None)) query = check(DeleteManyParams, request.query) record_ids = check(tuple[self._id_type, ...], (i.split("|") for i in query["ids"])) # type: ignore[name-defined] originals = await self.get_many(record_ids, query.get("meta")) allowed = await asyncio.gather(*(permits(request, f"admin.{self.name}.delete", context=(request, r)) for r in originals)) if not all(allowed): raise web.HTTPForbidden() ids = await self.delete_many(record_ids, query.get("meta")) if not ids: raise web.HTTPNotFound() return json_response({"data": self._convert_ids(ids)}) @final def _check_record(self, record: Record) -> Record: """Check and convert input record.""" return check(self._record_type, record) @final async def _convert_record(self, record: Record, request: web.Request) -> APIRecord: """Convert record to correct output format.""" record = await self.filter_by_permissions(request, "view", record) foreign_keys = {fk(*keys): None if any(record[k] is None for k in keys) else "|".join(str(record[k]) for k in keys) for keys in self._foreign_rows if all(k in record for k in keys)} return { "id": "|".join(str(record[pk]) for pk in self.primary_key), "data": record, **foreign_keys # type: ignore[typeddict-item] } @final def _convert_ids(self, ids: Sequence[_ID]) -> tuple[str, ...]: """Convert IDs to correct output format.""" return tuple(str(i) for i in ids) def _process_list_query(self, query: _ListQuery, request: web.Request) -> None: # When sort order refers to "id", this should be translated to primary key. if query["sort"]["field"] == "id": query["sort"]["field"] = self.primary_key[0] else: query["sort"]["field"] = query["sort"]["field"].removeprefix("data.") query["filter"].update(check(dict[str, object], query["filter"].pop("data", {}))) merged_filter = {} for k, v in query["filter"].items(): if k.startswith("fk_"): v = check(str, v) for c, cv in zip(k.removeprefix("fk_").split("__"), v.split("|")): merged_filter[c] = check(self._raw_record_type[c], cv) else: merged_filter[k] = check(self._raw_record_type[k], v) query["filter"] = merged_filter # Add filters from advanced permissions. # The permissions will be cached on the request from a previous permissions check. permissions = permissions_as_dict(request["aiohttpadmin_permissions"]) filters = permissions.get(f"admin.{self.name}.view", permissions.get(f"admin.{self.name}.*", {})) for k, v in filters.items(): query["filter"][k] = v @cached_property def routes(self) -> tuple[web.RouteDef, ...]: """Routes to act on this resource. Every route returned must have a name. """ url = "/" + self.name return ( web.get(url + "/list", self._get_list, name=self.name + "_get_list"), web.get(url + "/one", self._get_one, name=self.name + "_get_one"), web.get(url, self._get_many, name=self.name + "_get_many"), web.get(url + "/ref", self._get_many_ref, name=self.name + "_get_many_ref"), web.post(url, self._create, name=self.name + "_create"), web.put(url + "/update", self._update, name=self.name + "_update"), web.put(url + "/update_many", self._update_many, name=self.name + "_update_many"), web.delete(url + "/one", self._delete, name=self.name + "_delete"), web.delete(url, self._delete_many, name=self.name + "_delete_many") ) ================================================ FILE: aiohttp_admin/backends/sqlalchemy.py ================================================ import asyncio import json import logging import operator import sys from collections.abc import Callable, Coroutine, Iterator, Sequence from types import MappingProxyType as MPT from typing import Any, Literal, Optional, TypeVar, Union, cast import sqlalchemy as sa from aiohttp import web from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from sqlalchemy.orm import (DeclarativeBase, DeclarativeBaseNoMeta, Mapper, QueryableAttribute, selectinload) from .abc import AbstractAdminResource, GetListParams, GetManyRefParams, Meta, Record from ..types import FunctionState, comp, data, fk, func, regex if sys.version_info >= (3, 10): from typing import ParamSpec else: from typing_extensions import ParamSpec _P = ParamSpec("_P") _T = TypeVar("_T") _FValues = Union[bool, int, str] _Filters = dict[Union[sa.Column[object], QueryableAttribute[Any]], Union[_FValues, Sequence[_FValues]]] _ModelOrTable = Union[sa.Table, type[DeclarativeBase], type[DeclarativeBaseNoMeta]] _SABoolExpression = sa.sql.roles.ExpressionElementRole[bool] # _RelationshipAttr = InstrumentedAttribute[Union[DeclarativeBase, DeclarativeBaseNoMeta]] logger = logging.getLogger(__name__) _FieldTypesValues = tuple[str, str, MPT[str, object], MPT[str, object]] FIELD_TYPES: MPT[type[sa.types.TypeEngine[Any]], _FieldTypesValues] = MPT({ sa.Boolean: ("BooleanField", "BooleanInput", MPT({}), MPT({})), sa.Date: ("DateField", "DateInput", MPT({"showDate": True, "showTime": False}), MPT({})), sa.DateTime: ("DateField", "DateTimeInput", MPT({"showDate": True, "showTime": True}), MPT({})), sa.Enum: ("SelectField", "SelectInput", MPT({}), MPT({})), sa.Integer: ("NumberField", "NumberInput", MPT({}), MPT({})), sa.Numeric: ("NumberField", "NumberInput", MPT({}), MPT({})), sa.String: ("TextField", "TextInput", MPT({}), MPT({})), sa.Time: ("TimeField", "TimeInput", MPT({}), MPT({})), sa.Uuid: ("TextField", "TextInput", MPT({}), MPT({})), # TODO: validators # TODO: Set fields for below types. # sa.sql.sqltypes._AbstractInterval: (), # sa.types._Binary: (), # sa.types.PickleType: (), # sa.ARRAY: (), # sa.JSON: (), # sa.dialects.postgresql.AbstractRange: (), # sa.dialects.postgresql.BIT: (), # sa.dialects.postgresql.CIDR: (), # sa.dialects.postgresql.HSTORE: (), # sa.dialects.postgresql.INET: (), # sa.dialects.postgresql.MACADDR: (), # sa.dialects.postgresql.MACADDR8: (), # sa.dialects.postgresql.MONEY: (), # sa.dialects.postgresql.OID: (), # sa.dialects.postgresql.REGCONFIG: (), # sa.dialects.postgresql.REGCLASS: (), # sa.dialects.postgresql.TSQUERY: (), # sa.dialects.postgresql.TSVECTOR: (), # sa.dialects.mysql.BIT: (), # sa.dialects.mysql.YEAR: (), # sa.dialects.oracle.ROWID: (), # sa.dialects.mssql.MONEY: (), # sa.dialects.mssql.SMALLMONEY: (), # sa.dialects.mssql.SQL_VARIANT: (), }) _Components = tuple[str, str, dict[str, object], dict[str, object]] def get_components(t: sa.types.TypeEngine[object]) -> _Components: for key, (field, inp, field_props, input_props) in FIELD_TYPES.items(): if isinstance(t, key): return (field, inp, field_props.copy(), input_props.copy()) return ("TextField", "TextInput", {}, {}) def handle_errors( f: Callable[_P, Coroutine[None, None, _T]] ) -> Callable[_P, Coroutine[None, None, _T]]: async def inner(*args: _P.args, **kwargs: _P.kwargs) -> _T: try: return await f(*args, **kwargs) except sa.exc.IntegrityError as e: raise web.HTTPBadRequest(reason=e.args[0]) except sa.exc.NoResultFound: logger.warning("No result found (%s)", args, exc_info=True) raise web.HTTPNotFound() except sa.exc.CompileError as e: logger.warning("CompileError (%s)", args, exc_info=True) raise web.HTTPBadRequest(reason=str(e)) return inner def permission_for(sa_obj: Union[sa.Table, type[DeclarativeBase], sa.Column[object], QueryableAttribute[Any]], perm_type: Literal["view", "edit", "add", "delete", "*"] = "*", *, filters: Optional[_Filters] = None, negated: bool = False) -> str: """Returns a permission string for the given sa_obj. Args: sa_obj: A SQLAlchemy object to grant permission to (table/model/column/attribute). perm_type: The type of permission to grant acces to. filters: Filters to restrict the permisson to (can't be used with negated). e.g. {User.type: "admin", User.active: True} only permits access if `User.type == "admin" and User.active`. {Post.type: ("news", "sports")} only permits access if `Post.type in ("news", "sports")`. negated: True if result should restrict access from sa_obj. """ if filters and negated: raise ValueError("Can't use filters on negated permissions.") if perm_type not in {"view", "edit", "add", "delete", "*"}: raise ValueError(f"Invalid perm_type: '{perm_type}'") field = None if isinstance(sa_obj, sa.Table): table = sa_obj elif isinstance(sa_obj, (sa.Column, QueryableAttribute)): table = sa_obj.table field = sa_obj.name else: if not isinstance(sa_obj.__table__, sa.Table): raise ValueError("Non-table mappings are not supported.") table = sa_obj.__table__ p = "{}admin.{}".format("~" if negated else "", table.name) if field: p = f"{p}.{field}" p = f"{p}.{perm_type}" if filters: for col, value in filters.items(): if col.table is not table: raise ValueError("Filter key not an attribute/column of sa_obj.") # Sequences should be treated as multiple filter values for that key. if not isinstance(value, Sequence) or isinstance(value, str): value = (value,) for v in value: v = json.dumps(v) p += f"|{col.name}={v}" return p def create_filters(columns: sa.ColumnCollection[str, sa.Column[object]], filters: dict[str, object]) -> Iterator[_SABoolExpression]: return (columns[k].in_(v) if isinstance(v, list) else columns[k].ilike(f"%{v}%") if isinstance(v, str) else columns[k] == v for k, v in filters.items()) # ID is based on PK, which we can't infer from types, so must use Any here. class SAResource(AbstractAdminResource[tuple[Any, ...]]): _model: Union[type[DeclarativeBase], type[DeclarativeBaseNoMeta], None] = None def __init__(self, db: AsyncEngine, model_or_table: _ModelOrTable): if isinstance(model_or_table, sa.Table): table = model_or_table else: if not isinstance(model_or_table.__table__, sa.Table): raise ValueError("Non-table mappings are not supported.") table = model_or_table.__table__ self._model = model_or_table self._db = db self._table = table self.name = table.name self.primary_key = tuple(filter(lambda c: table.c[c].primary_key, self._table.c.keys())) if not self.primary_key: self.primary_key = tuple(self._table.c.keys()) pk_types = tuple(table.c[pk].type.python_type for pk in self.primary_key) self._id_type = tuple.__class_getitem__(pk_types) # type: ignore[assignment] self.fields = {} self.inputs = {} self.omit_fields = set() self._foreign_rows = {tuple(c.column_keys) for c in table.foreign_key_constraints} record_type = {} for c in table.c.values(): if c.foreign_keys: field = "ReferenceField" inp = "ReferenceInput" constraint = next(cn for cn in table.foreign_key_constraints if cn.contains_column(c)) key = next(iter(c.foreign_keys)) label = c.name.replace("_", " ").title() keys = tuple((col.name, next(iter(col.foreign_keys)).column.name) for col in constraint.columns) field_props: dict[str, Any] = {} inp_props: dict[str, Any] = {"referenceKeys": keys} props: dict[str, Any] = {"reference": key.column.table.name, "target": key.column.name, "label": label, "source": fk(*constraint.column_keys)} else: field, inp, field_props, inp_props = get_components(c.type) props = {"source": data(c.name)} if inp == "BooleanInput" and c.nullable: inp = "NullableBooleanInput" if isinstance(c.type, sa.Enum): props["choices"] = tuple({"id": e.value, "name": e.name} for e in c.type.python_type) length = getattr(c.type, "length", 0) if length is None or length > 31: props["fullWidth"] = True if length is None or length > 127: inp_props["multiline"] = True if isinstance(c.default, sa.ColumnDefault): props["placeholder"] = c.default.arg if c.comment: props["helperText"] = c.comment field_props.update(props) self.fields[c.name] = comp(field, field_props) if c.computed is None: # TODO: Allow custom props (e.g. disabled, multiline, rows etc.) inp_props.update(props) show = c is not table.autoincrement_column inp_props["validate"] = self._get_validators(table, c) if inp == "NumberInput": for v in inp_props["validate"]: if v["name"] == "minValue": inp_props["min"] = v["args"][0] elif v["name"] == "maxValue": inp_props["max"] = v["args"][0] self.inputs[c.name] = comp(inp, inp_props) # type: ignore[assignment] self.inputs[c.name]["show_create"] = show field_type: Any = c.type.python_type if c.nullable: field_type = Optional[field_type] record_type[c.name] = field_type if not isinstance(model_or_table, sa.Table): # Append fields to represent ORM relationships. # Mypy doesn't handle union well here. mapper = cast(Union[Mapper[DeclarativeBase], Mapper[DeclarativeBaseNoMeta]], sa.inspect(model_or_table)) assert mapper is not None # noqa: S101 for name, relationship in mapper.relationships.items(): # https://github.com/sqlalchemy/sqlalchemy/discussions/10161#discussioncomment-6583442 assert relationship.local_remote_pairs # noqa: S101 if not isinstance(relationship.entity.persist_selectable, sa.Table): continue local, remotes = zip(*relationship.local_remote_pairs) self._foreign_rows.add(tuple(c.name for c in local)) props = {"reference": relationship.entity.persist_selectable.name, "label": name.title(), "source": fk(*(c.name for c in local)), "target": fk(*(r.name for r in remotes)), "sortable": False} if any(c.foreign_keys for c in local): t = "ReferenceField" props["link"] = "show" elif relationship.uselist: t = "ReferenceManyField" props["reference"] = self.name props["target"] = name props["source"] = "id" props["filter"] = {"__meta__": {"orm": True}} else: t = "ReferenceOneField" props["link"] = "show" children = [] for kc in relationship.target.c.values(): if kc in remotes: # Skip the foreign key continue field, _inp, c_fprops, _inp_props = get_components(kc.type) c_fprops["source"] = data(kc.name) children.append(comp(field, c_fprops)) container = "Datagrid" if t == "ReferenceManyField" else "DatagridSingle" datagrid = comp(container, {"children": children, "rowClick": "show"}) if t == "ReferenceManyField": datagrid["props"]["bulkActionButtons"] = comp( "BulkDeleteButton", {"mutationMode": "pessimistic"}) props["children"] = datagrid self.fields[name] = comp(t, props) self.omit_fields.add(name) super().__init__(record_type) @handle_errors async def get_list(self, params: GetListParams) -> tuple[list[Record], int]: per_page = params["pagination"]["perPage"] offset = (params["pagination"]["page"] - 1) * per_page filters = params["filter"] query = sa.select(self._table) if filters: query = query.where(*create_filters(self._table.c, filters)) async def get_count() -> int: async with self._db.connect() as conn: count = await conn.scalar(sa.select(sa.func.count()).select_from(query.subquery())) if count is None: raise RuntimeError("Failed to get count.") return count async def get_entities() -> list[Record]: async with self._db.connect() as conn: sort_dir = sa.asc if params["sort"]["order"] == "ASC" else sa.desc order_by: sa.UnaryExpression[object] = sort_dir(params["sort"]["field"]) stmt = query.offset(offset).limit(per_page).order_by(order_by) return [r._asdict() for r in await conn.execute(stmt)] return await asyncio.gather(get_entities(), get_count()) @handle_errors async def get_one(self, record_id: tuple[Any, ...], meta: Meta) -> Record: async with self._db.connect() as conn: stmt = sa.select(self._table).where(*self._cmp_pk(record_id)) result = await conn.execute(stmt) return result.one()._asdict() @handle_errors async def get_many(self, record_ids: Sequence[tuple[Any, ...]], meta: Meta) -> list[Record]: async with self._db.connect() as conn: stmt = sa.select(self._table).where(self._cmp_pk_many(record_ids)) result = await conn.execute(stmt) return [r._asdict() for r in result] async def get_many_ref_name(self, target: str, meta: Meta) -> str: if meta and meta.get("orm", False): # TODO(pydantic): arbitrary_types_allowed=True check(_RelationshipAttr, ...) relationship = getattr(self._model, target) return relationship.entity.persist_selectable.name # type: ignore[no-any-return] return self.name @handle_errors async def get_many_ref(self, params: GetManyRefParams) -> tuple[list[Record], int]: meta = params.get("meta") if meta and meta.get("orm", False): if self._model is None: raise web.HTTPBadRequest(reason="Not an ORM model.") # Use an ORM relationship to get the records (essentially the inverse of a # normal manyReference request). This makes it easy to support complex # relationships (such as many-to-many) without react-admin needing the details target = params["target"][0] reverse = params["sort"]["order"] == "DESC" # TODO(pydantic): arbitrary_types_allowed=True check(_RelationshipAttr, ...) relationship = getattr(self._model, target) async with AsyncSession(self._db) as sess: result = await sess.get(self._model, params["id"], options=(selectinload(relationship),)) records = [{c.name: getattr(r, c.name) for c in r.__table__.c} for r in getattr(result, target)] records.sort(key=lambda r: r[params["sort"]["field"]], reverse=reverse) return records, len(records) for k, v in zip(params["target"], params["id"]): params["filter"][k] = v return await self.get_list(params) @handle_errors async def create(self, data: Record, meta: Meta) -> Record: async with self._db.begin() as conn: stmt = sa.insert(self._table).values(data).returning(*self._table.c) try: row = await conn.execute(stmt) except sa.exc.IntegrityError: logger.warning("IntegrityError (%s)", data, exc_info=True) raise web.HTTPBadRequest(reason="Integrity error (element already exists?)") return row.one()._asdict() @handle_errors async def update(self, record_id: tuple[Any, ...], data: Record, previous_data: Record, meta: Meta) -> Record: async with self._db.begin() as conn: stmt = sa.update(self._table).where(*self._cmp_pk(record_id)) stmt = stmt.values(data).returning(*self._table.c) row = await conn.execute(stmt) return row.one()._asdict() @handle_errors async def update_many(self, record_ids: Sequence[tuple[Any, ...]], data: Record, meta: Meta) -> list[tuple[Any, ...]]: async with self._db.begin() as conn: stmt = sa.update(self._table).where(self._cmp_pk_many(record_ids)) stmt = stmt.values(data).returning(*(self._table.c[pk] for pk in self.primary_key)) return list(await conn.scalars(stmt)) @handle_errors async def delete(self, record_id: tuple[Any, ...], previous_data: Record, meta: Meta) -> Record: async with self._db.begin() as conn: stmt = sa.delete(self._table).where(*self._cmp_pk(record_id)) row = await conn.execute(stmt.returning(*self._table.c)) return row.one()._asdict() @handle_errors async def delete_many(self, record_ids: Sequence[tuple[Any, ...]], meta: Meta) -> list[tuple[Any, ...]]: async with self._db.begin() as conn: stmt = sa.delete(self._table).where(self._cmp_pk_many(record_ids)) r = await conn.scalars(stmt.returning(*(self._table.c[pk] for pk in self.primary_key))) return list(r) def _cmp_pk(self, record_id: tuple[Any, ...]) -> Iterator[_SABoolExpression]: return (self._table.c[pk] == r_id for pk, r_id in zip(self.primary_key, record_id)) def _cmp_pk_many(self, record_ids: Sequence[tuple[Any, ...]]) -> _SABoolExpression: return sa.tuple_(*(self._table.c[pk] for pk in self.primary_key)).in_(record_ids) def _get_validators(self, table: sa.Table, c: sa.Column[object]) -> list[FunctionState]: validators: list[FunctionState] = [] if c.default is None and c.server_default is None and not c.nullable: validators.append(func("required", ())) max_length = getattr(c.type, "length", None) if max_length: validators.append(func("maxLength", (max_length,))) for constr in table.constraints: if not isinstance(constr, sa.CheckConstraint): continue if isinstance(constr.sqltext, sa.BooleanClauseList): if constr.sqltext.operator is not operator.and_: continue exprs = constr.sqltext.clauses else: exprs = (constr.sqltext,) for expr in exprs: if isinstance(expr, sa.BinaryExpression): left = expr.left right = expr.right op = expr.operator if left.expression is c: if not isinstance(right, sa.BindParameter) or right.value is None: continue if op is operator.ge: validators.append(func("minValue", (right.value,))) elif op is operator.gt: validators.append(func("minValue", (right.value + 1,))) elif op is operator.le: validators.append(func("maxValue", (right.value,))) elif op is operator.lt: validators.append(func("maxValue", (right.value - 1,))) elif isinstance(left, sa.Function): if left.name == "char_length": if next(iter(left.clauses)) is not c: continue if not isinstance(right, sa.BindParameter) or right.value is None: continue if op is operator.ge: validators.append(func("minLength", (right.value,))) elif op is operator.gt: validators.append(func("minLength", (right.value + 1,))) elif isinstance(expr, sa.Function): if expr.name in ("regexp", "regexp_like"): clauses = tuple(expr.clauses) if clauses[0] is not c or not isinstance(clauses[1], sa.BindParameter): continue if clauses[1].value is None: continue validators.append(func("regex", (regex(clauses[1].value),))) return validators ================================================ FILE: aiohttp_admin/py.typed ================================================ ================================================ FILE: aiohttp_admin/routes.py ================================================ """Setup routes for admin app.""" import copy from pathlib import Path from aiohttp import web from . import views from .backends.abc import AbstractAdminResource from .types import Schema, _ResourceState, data, resources_key, state_key def setup_resources(admin: web.Application, schema: Schema) -> None: resources: dict[str, AbstractAdminResource[tuple[object, ...]]] = {} for r in schema["resources"]: m = r["model"] resources[m.name] = m admin.router.add_routes(m.routes) try: omit_fields = m.fields.keys() - r["display"] except KeyError: omit_fields = m.omit_fields else: if not all(f in m.fields for f in r["display"]): raise ValueError(f"Display includes non-existent field {r['display']}") # TODO: Use label: https://github.com/marmelab/react-admin/issues/9587 omit_fields = tuple(m.fields[f]["props"].get("source") for f in omit_fields) repr_field = r.get("repr", data(m.primary_key[0])) if repr_field.removeprefix("data.") not in m.fields: raise ValueError(f"repr not a valid field name: {repr_field}") # Don't modify the resource. fields = copy.deepcopy(m.fields) inputs = copy.deepcopy(m.inputs) validators = r.get("validators", {}) input_props = r.get("input_props", {}) for k, v in inputs.items(): k = k.removeprefix("data.") if k not in omit_fields: v["props"]["alwaysOn"] = "alwaysOn" # Always display filter if k in validators: v["props"]["validate"] = (tuple(v["props"].get("validate", ())) + tuple(validators[k])) v["props"].update(input_props.get(k, {})) for name, props in r.get("field_props", {}).items(): fields[name]["props"].update(props) state: _ResourceState = { "fields": fields, "inputs": inputs, "list_omit": tuple(omit_fields), "repr": repr_field, "label": r.get("label"), "icon": r.get("icon"), "bulk_update": r.get("bulk_update", {}), "urls": {}, "show_actions": r.get("show_actions", ())} admin[state_key]["resources"][m.name] = state admin[resources_key] = resources def setup_routes(admin: web.Application) -> None: """Add routes to the admin application.""" admin.router.add_get("", views.index, name="index") admin.router.add_post("/token", views.token, name="token") admin.router.add_delete("/logout", views.logout, name="logout") admin.router.add_static("/static", path=Path(__file__).with_name("static"), name="static") ================================================ FILE: aiohttp_admin/security.py ================================================ import json from collections.abc import Collection, Mapping, Sequence from enum import Enum from functools import lru_cache from typing import Optional, Type, TypeVar, Union from aiohttp import web from aiohttp_security import AbstractAuthorizationPolicy, SessionIdentityPolicy from cryptography.fernet import Fernet, InvalidToken from pydantic import Json, TypeAdapter, ValidationError from .types import IdentityDict, Schema, UserDetails _T = TypeVar("_T") @lru_cache # https://github.com/python/typeshed/issues/6347 def _get_schema(t: Type[_T]) -> TypeAdapter[_T]: # type: ignore[misc] return TypeAdapter(t) def check(t: Type[_T], value: object) -> _T: """Validate value is of static type t.""" # https://github.com/python/mypy/issues/11470 return _get_schema(t).validate_python(value) # type: ignore[arg-type,no-any-return] class Permissions(str, Enum): view = "admin.view" edit = "admin.edit" add = "admin.add" delete = "admin.delete" all = "admin.*" def has_permission(p: Union[str, Enum], permissions: Mapping[str, Mapping[str, Sequence[object]]], context: Optional[Mapping[str, object]]) -> bool: # TODO(PY311): StrEnum *parts, ptype = p.split(".") # type: ignore[union-attr] # Negative permissions. for i in range(len(parts), 0, -1): for t in (ptype, "*"): perm = ".".join((*parts[:i], t)) if "~" + perm in permissions: return False # Positive permissions. for i in range(len(parts), 0, -1): for t in (ptype, "*"): perm = ".".join((*parts[:i], t)) if perm in permissions: if not context: return True filters = permissions[perm] for attr, vals in filters.items(): if context.get(attr) not in vals: return False return True return False def permissions_as_dict(permissions: Collection[str]) -> dict[str, dict[str, list[object]]]: p_dict: dict[str, dict[str, list[object]]] = {} for p in permissions: perm, *filters = p.split("|") p_dict[perm] = {} for f in filters: k, v = f.split("=", maxsplit=1) p_dict[perm].setdefault(k, []).append(json.loads(v)) return p_dict class AdminAuthorizationPolicy(AbstractAuthorizationPolicy): def __init__(self, schema: Schema): super().__init__() self._identity_callback = schema["security"].get("identity_callback") async def authorized_userid(self, identity: str) -> str: return identity async def permits( self, identity: Optional[str], permission: Union[str, Enum], context: Optional[tuple[web.Request, Optional[Mapping[str, object]]]] = None ) -> bool: # TODO: https://github.com/aio-libs/aiohttp-security/issues/677 assert context is not None # noqa: S101 if identity is None: return False try: request, record = context except (TypeError, ValueError): raise TypeError("Context must be `(request, record)` or `(request, None)`") permissions: Optional[Collection[str]] = request.get("aiohttpadmin_permissions") if permissions is None: if self._identity_callback is None: permissions = (Permissions.all,) else: user = await self._identity_callback(identity) permissions = user["permissions"] # Cache permissions per request to avoid potentially dozens of DB calls. request["aiohttpadmin_permissions"] = permissions return has_permission(permission, permissions_as_dict(permissions), record) class TokenIdentityPolicy(SessionIdentityPolicy): def __init__(self, fernet: Fernet, schema: Schema): super().__init__() self._fernet = fernet config = schema["security"] self._identity_callback = config.get("identity_callback") self._max_age = config.get("max_age") async def identify(self, request: web.Request) -> Optional[str]: """Return the identity of an authorised user.""" # Validate JS token hdr = request.headers.get("Authorization") try: identity_data = check(Json[IdentityDict], hdr) except ValidationError: return None auth = identity_data["auth"].encode("utf-8") try: token_identity = self._fernet.decrypt(auth, ttl=self._max_age).decode("utf-8") except InvalidToken: return None # Validate cookie token cookie_identity = await super().identify(request) # Both identites must match. return token_identity if token_identity == cookie_identity else None async def remember(self, request: web.Request, response: web.StreamResponse, identity: str, **kwargs: object) -> None: """Send auth tokens to client for authentication.""" # For proper security we send a token for JS to store and an HTTP only cookie: # https://www.redotheweb.com/2015/11/09/api-security.html # Send token that will be saved in local storage by the JS client. response.headers["X-Token"] = json.dumps(await self.user_identity_dict(request, identity)) # Send httponly cookie, which will be invisible to JS. await super().remember(request, response, identity, **kwargs) # type: ignore[arg-type] async def forget(self, request: web.Request, response: web.StreamResponse) -> None: """Delete session cookie (JS client should choose to delete its token).""" await super().forget(request, response) async def user_identity_dict(self, request: web.Request, identity: str) -> IdentityDict: """Create the identity information sent back to the admin client. The 'auth' key will be used for the server authentication, everything else is just information that the client can use. For example, 'permissions' will be returned by the react-admin's getPermissions() and some values like 'fullName' or 'avatar' will be automatically used: https://marmelab.com/react-admin/AuthProviderWriting.html#getidentity All details (except auth) can be specified using the identity callback. """ if self._identity_callback is None: user_details: UserDetails = {"permissions": (Permissions.all,)} else: user_details = await self._identity_callback(identity) if "auth" in user_details: raise ValueError("Callback should not return a dict with 'auth' key.") auth = self._fernet.encrypt(identity.encode("utf-8")).decode("utf-8") identity_dict: IdentityDict = {"auth": auth, "fullName": "Admin user", "permissions": {}} # We change type of permissions below, so need to ignore this type error. identity_dict.update(user_details) # type: ignore[typeddict-item] identity_dict["permissions"] = permissions_as_dict(user_details["permissions"]) return identity_dict ================================================ FILE: aiohttp_admin/types.py ================================================ import re import sys from collections.abc import Callable, Collection, Sequence from typing import Any, Awaitable, Literal, Mapping, NewType, Optional from aiohttp.web import AppKey if sys.version_info >= (3, 12): from typing import TypedDict else: from typing_extensions import TypedDict Data = NewType("Data", str) FK = NewType("FK", str) def data(key: str) -> Data: return Data(f"data.{key}") def fk(*keys: str) -> FK: return FK("fk_{}".format("__".join(sorted(keys)))) class ComponentState(TypedDict): __type__: Literal["component"] type: str props: dict[str, object] class FunctionState(TypedDict): __type__: Literal["function"] name: str args: Optional[Sequence[object]] class RegexState(TypedDict): __type__: Literal["regexp"] value: str class InputState(ComponentState): # Whether to show this input in the create form. show_create: bool class _IdentityDict(TypedDict, total=False): avatar: str class IdentityDict(_IdentityDict): auth: str fullName: str permissions: dict[str, dict[str, list[object]]] class UserDetails(TypedDict, total=False): # https://marmelab.com/react-admin/AuthProviderWriting.html#getidentity fullName: str avatar: str # https://marmelab.com/react-admin/AuthProviderWriting.html#getpermissions permissions: Collection[str] class __SecuritySchema(TypedDict, total=False): # Callback that receives identity and should return user details for the admin to use. identity_callback: Callable[[str], Awaitable[UserDetails]] # max_age value for cookies/tokens, defaults to None. max_age: Optional[int] # Secure flag for cookies, defaults to True. secure: bool class _SecuritySchema(__SecuritySchema): # Callback that receives request.config_dict, username and password and returns # True if authorised, False otherwise. check_credentials: Callable[[str, str], Awaitable[bool]] class _ViewSchema(TypedDict, total=False): # Path to favicon. icon: str # Name for the project (shown in the title), defaults to the package name. name: str class _Resource(TypedDict, total=False): # List of field names that should be shown in the list view by default. display: Sequence[str] # Display label in admin. label: str # URL path to custom icon. icon: str # name of the field that should be used for repr # (e.g. when displaying a foreign key reference). repr: str # Bulk update actions (which appear when selecting rows in the list view). # Format: {"Button Label": {"field_to_update": "value_to_set"}} # e.g. {"Reset Views": {"views": 0}} bulk_update: dict[str, dict[str, Any]] # Custom validators to add to inputs. validators: dict[str, Sequence[FunctionState]] # Custom props to add to fields. field_props: dict[str, dict[str, Any]] # Custom props to add to inputs. input_props: dict[str, dict[str, Any]] # Custom components to add to the actions in the show view. show_actions: Sequence[ComponentState] class Resource(_Resource): # The admin resource model. model: Any # TODO(pydantic): AbstractAdminResource class _Schema(TypedDict, total=False): view: _ViewSchema js_module: str class Schema(_Schema): security: _SecuritySchema resources: Sequence[Resource] class _ResourceState(TypedDict): fields: dict[str, ComponentState] inputs: dict[str, InputState] show_actions: Sequence[ComponentState] repr: str icon: Optional[str] urls: dict[str, tuple[str, str]] # (method, url) bulk_update: dict[str, dict[str, Any]] list_omit: tuple[str, ...] label: Optional[str] class State(TypedDict): resources: dict[str, _ResourceState] urls: dict[str, str] view: _ViewSchema js_module: Optional[str] def comp(t: str, props: Optional[Mapping[str, object]] = None) -> ComponentState: """Use a component of type t with the given props.""" props = dict(props or {}) # Set default label, otherwise react-admin will use a label with the prefix. if "label" not in props and "source" in props: s = props["source"] assert isinstance(s, str) # noqa: S101 props["label"] = s.removeprefix("fk_").removeprefix("data.").replace("_", " ").title() return {"__type__": "component", "type": t, "props": props} def func(name: str, args: Optional[Sequence[object]] = None) -> FunctionState: """Use the function with matching name. If args are provided, the function will be called with those arguments. Otherwise, the function itself will be passed in the frontend. e.g. To use the 'required' validator, use func("required", ()) Or, to pass a custom function directly as a prop, use func("myFunction") """ return {"__type__": "function", "name": name, "args": args} def regex(value: str) -> RegexState: """Convert value to a RegExp object on the frontend.""" return {"__type__": "regexp", "value": value} check_credentials_key = AppKey[Callable[[str, str], Awaitable[bool]]]("check_credentials") permission_re_key = AppKey("permission_re", re.Pattern[str]) resources_key = AppKey("resources", dict[str, Any]) # TODO(pydantic): AbstractAdminResource state_key = AppKey("state", State) ================================================ FILE: aiohttp_admin/views.py ================================================ import __main__ import json import sys from aiohttp import web from aiohttp_security import forget, remember from pydantic import Json from .security import check from .types import check_credentials_key, state_key if sys.version_info >= (3, 12): from typing import TypedDict else: from typing_extensions import TypedDict class _Login(TypedDict): username: str password: str INDEX_TEMPLATE = """ {name} Admin
""" async def index(request: web.Request) -> web.Response: """Root page which loads react-admin.""" static = request.app.router["static"] js = static.url_for(filename="admin.js") state = json.dumps(request.app[state_key]) # __package__ can be None, despite what the documentation claims. package_name = __main__.__package__ or "My" # Common convention is to have _app suffix for package name, so try and strip that. package_name = package_name.removesuffix("_app").replace("_", " ").title() name = request.app[state_key]["view"].get("name", package_name) icon = request.app[state_key]["view"].get("icon", static.url_for(filename="favicon.svg")) output = INDEX_TEMPLATE.format(name=name, icon=icon, js=js, state=state) return web.Response(text=output, content_type="text/html") async def token(request: web.Request) -> web.Response: """Validate user credentials and log the user in.""" data = check(Json[_Login], await request.read()) check_credentials = request.app[check_credentials_key] if not await check_credentials(data["username"], data["password"]): raise web.HTTPUnauthorized(text="Wrong username or password") response = web.Response() await remember(request, response, data["username"]) return response async def logout(request: web.Request) -> web.Response: """Log the user out.""" response = web.json_response() await forget(request, response) return response ================================================ FILE: docs/Makefile ================================================ # Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " applehelp to make an Apple Help Book" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " epub3 to make an epub3" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " coverage to run coverage check of the documentation (if enabled)" @echo " dummy to check syntax errors of document sources" .PHONY: clean clean: rm -rf $(BUILDDIR)/* .PHONY: html html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." .PHONY: dirhtml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." .PHONY: singlehtml singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." .PHONY: pickle pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." .PHONY: json json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." .PHONY: htmlhelp htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." .PHONY: qthelp qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/aiohttp-admin.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/aiohttp-admin.qhc" .PHONY: applehelp applehelp: $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp @echo @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." @echo "N.B. You won't be able to view it unless you put it in" \ "~/Library/Documentation/Help or install it in your application" \ "bundle." .PHONY: devhelp devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/aiohttp-admin" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/aiohttp-admin" @echo "# devhelp" .PHONY: epub epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." .PHONY: epub3 epub3: $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 @echo @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." .PHONY: latex latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." .PHONY: latexpdf latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: latexpdfja latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: text text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." .PHONY: man man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." .PHONY: texinfo texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." .PHONY: info info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." .PHONY: gettext gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." .PHONY: changes changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." .PHONY: linkcheck linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." .PHONY: doctest doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." .PHONY: coverage coverage: $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage @echo "Testing of coverage in the sources finished, look at the " \ "results in $(BUILDDIR)/coverage/python.txt." .PHONY: xml xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." .PHONY: pseudoxml pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." .PHONY: dummy dummy: $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy @echo @echo "Build finished. Dummy builder generates no files." ================================================ FILE: docs/README.md ================================================ # The docs for the new admin realization ## Library Installation ``` pip install aiohttp_django ``` ## ModelAdmin ```python @schema.register class Tags(models.ModelAdmin): fields = ('id', 'name', 'published', ) class Meta: resource_type = PGResource table = tag ``` `ModelAdmin.fields` by default it's primary key `ModelAdmin.can_create` if it's `True` then show create button (by default is `True`) `ModelAdmin.can_edit` if it's `True` then show edit button (by default is `True`) `ModelAdmin.can_delete` if it's `True` then show delete button (by default is `True`) `ModelAdmin.per_page` count of items in a list page (by default is `10`) ================================================ FILE: docs/api.rst ================================================ ================================================ FILE: docs/changelog.rst ================================================ .. module:: aiohttp-admin .. include:: ../CHANGES.txt ================================================ FILE: docs/conf.py ================================================ #!/usr/bin/env python3 # aiohttp-admin documentation build configuration file, created by # sphinx-quickstart on Sun Nov 13 21:04:19 2016. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The encoding of source files. # # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'aiohttp-admin' copyright = '2016, Nikolay Novik' author = 'Nikolay Novik' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '0.0.1' # The full version, including alpha/beta/rc tags. release = '0.0.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # # today = '' # # Else, today_fmt is used as the format for a strftime call. # # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # The reST default role (used for this markup: `text`) to use for all # documents. # # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. # " v documentation" by default. # # html_title = 'aiohttp-admin v0.0.1' # A shorter title for the navigation bar. Default is the same as html_title. # # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # # html_logo = None # The name of an image file (relative to this directory) to use as a favicon of # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # # html_extra_path = [] # If not None, a 'Last updated on:' timestamp is inserted at every page # bottom, using the given strftime format. # The empty string is equivalent to '%b %d, %Y'. # # html_last_updated_fmt = None # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # # html_additional_pages = {} # If false, no module index is generated. # # html_domain_indices = True # If false, no index is generated. # # html_use_index = True # If true, the index is split into individual pages for each letter. # # html_split_index = False # If true, links to the reST sources are added to the pages. # # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' # # html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # 'ja' uses this config value. # 'zh' user can custom change `jieba` dictionary path. # # html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. # # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'aiohttp-admindoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'aiohttp-admin.tex', 'aiohttp-admin Documentation', 'Nikolay Novik', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # # latex_use_parts = False # If true, show page references after internal links. # # latex_show_pagerefs = False # If true, show URL addresses after external links. # # latex_show_urls = False # Documents to append as an appendix to all manuals. # # latex_appendices = [] # It false, will not define \strong, \code, itleref, \crossref ... but only # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added # packages. # # latex_keep_old_macro_names = True # If false, no module index is generated. # # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'aiohttp-admin', 'aiohttp-admin Documentation', [author], 1) ] # If true, show URL addresses after external links. # # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'aiohttp-admin', 'aiohttp-admin Documentation', author, 'aiohttp-admin', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. # # texinfo_appendices = [] # If false, no module index is generated. # # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # # texinfo_no_detailmenu = False # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = {'https://docs.python.org/': None} ================================================ FILE: docs/contents.rst.inc ================================================ Documentation ------------- .. toctree:: :maxdepth: 2 contributing design api Additional Information ---------------------- .. toctree:: :maxdepth: 1 changelog ================================================ FILE: docs/contributing.rst ================================================ .. include:: ../CONTRIBUTING.rst ================================================ FILE: docs/design.rst ================================================ Design ------ **aiohttp_admin** using following design philosophy: - backend and frontend of admin views are decoupled by REST API as result it is possible to change admin views without changing any **python** code. On browser side user interacts with single page application (ng-admin). - admin views are database agnostic, if it is possible to implement REST API it should be strait forward to add admin views. Some filtering features may be disabled if database do not support some kind of filtering. .. image:: diagram2.svg :align: center ================================================ FILE: docs/index.rst ================================================ .. aiohttp-admin documentation master file, created by sphinx-quickstart on Sun Nov 13 21:04:19 2016. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to aiohttp-admin! ========================= .. image:: https://travis-ci.org/aio-libs/aiohttp_admin.svg?branch=master :target: https://travis-ci.org/aio-libs/aiohttp_admin .. image:: https://codecov.io/gh/aio-libs/aiohttp_admin/branch/master/graph/badge.svg :target: https://codecov.io/gh/aio-libs/aiohttp_admin **aiohttp_admin** will help you on building an admin interface on top of an existing data model. Library designed to be database agnostic and decoupled of any ORM or datbase layer. Admin module relies on async/await syntax (PEP492) thus *not* compatible with Python older than 3.5. **What is aiohttp-admin use cases?** - For small web applications or micro services, where custom admin interface is overkill. - To give a manager something to play with while proper admin interface is not ready. - Could be solution if you absolutely hate to write a lot of js/html but have to .. image:: demo.gif :align: center Features -------- - designed to be used with aiohttp; - library supports multiple database, out of the box MySQL, PostgreSQL, Mongodb; - clear separation of backend and frontend layers; - no WTForms, frontend is SPA; - uvloop_ compatible, tests executed with both: default and uvloop - database agnostic, if you can represent your entities with REST api, you can build admin views. .. include:: contents.rst.inc Ask Question ------------ Please feel free to ask question in `mail list `_ or raise issue on `github `_ Requirements ------------ * Python_ 3.5+ * PostgreSQL with, aiopg_ and sqlalchemy.core_ * MySQL with aiomysql_ and sqlalchemy.core_ * Mongodb with motor_ .. _Python: https://www.python.org .. _asyncio: http://docs.python.org/3.4/library/asyncio.html .. _uvloop: https://github.com/MagicStack/uvloop .. _aiopg: https://github.com/aio-libs/aiopg .. _aiomysql: https://github.com/aio-libs/aiomysql .. _motor: https://github.com/mongodb/motor .. _sqlalchemy.core: http://www.sqlalchemy.org/ .. _PEP492: https://www.python.org/dev/peps/pep-0492/ .. _docker: https://www.docker.com/ .. _instruction: https://docs.docker.com/engine/installation/linux/ubuntulinux/ .. _docker-machine: https://docs.docker.com/machine/ Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` ================================================ FILE: examples/demo/README ================================================ To build a custom component: First we need to replace some of our dependencies with a shim (ensure shim/ is copied to your project directory). In package.json, update the dependencies which are available in the shim/ directory: "react": "file:./shim/react", "react-admin": "file:./shim/react-admin", "react-dom": "file:./shim/react-dom", Also repeat these in a 'resolutions' config: "resolutions": { "react": "file:./shim/react", "react-admin": "file:./shim/react-admin", "react-dom": "file:./shim/react-dom", "react-router-dom": "file:./shim/react-router-dom", "query-string": "file:./shim/query-string" }, Using the shim for atleast react-admin is required, otherwise the components will end up using different contexts to the application and will fail to function. Using the shim for other libraries is recommended as it will significantly reduce the size of your compiled module. Second, we need to ensure that it is built as an ES6 module. To achieve this, add craco to the dependencies: "@craco/craco": "^7.1.0", Then create a craco.config.js file: module.exports = { webpack: { configure: { output: { library: { type: "module" } }, experiments: {outputModule: true} } } } And replace `react-scripts` with `craco` in the 'scripts' config: "scripts": { "start": "craco start", "build": "craco build", "test": "craco test", "eject": "craco eject" }, Then the components can be built as normal: yarn install yarn build ================================================ FILE: examples/demo/admin-js/craco.config.js ================================================ module.exports = { webpack: { configure: { output: { library: { type: 'module' } }, experiments: {outputModule: true} } } } ================================================ FILE: examples/demo/admin-js/package.json ================================================ { "name": "admin-js", "version": "0.1.0", "private": true, "dependencies": { "@craco/craco": "^7.1.0", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.14", "@mui/material": "^5.14.14", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", "query-string": "file:./shim/query-string", "react": "file:./shim/react", "react-admin": "file:./shim/react-admin", "react-dom": "file:./shim/react-dom", "react-router-dom": "file:./shim/react-router-dom", "react-scripts": "5.0.1", "web-vitals": "^2.1.0" }, "resolutions": { "react": "file:./shim/react", "react-admin": "file:./shim/react-admin", "react-dom": "file:./shim/react-dom", "react-router-dom": "file:./shim/react-router-dom", "query-string": "file:./shim/query-string" }, "scripts": { "start": "craco start", "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/", "test": "craco test", "eject": "craco eject" }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } } ================================================ FILE: examples/demo/admin-js/public/index.html ================================================ ================================================ FILE: examples/demo/admin-js/shim/query-string/index.js ================================================ module.exports = window.QueryString; ================================================ FILE: examples/demo/admin-js/shim/react/index.js ================================================ module.exports = window.React; ================================================ FILE: examples/demo/admin-js/shim/react/jsx-runtime.js ================================================ module.exports = window.ReactJSXRuntime; ================================================ FILE: examples/demo/admin-js/shim/react-admin/index.js ================================================ module.exports = window.ReactAdmin; ================================================ FILE: examples/demo/admin-js/shim/react-dom/index.js ================================================ module.exports = window.ReactDOM; ================================================ FILE: examples/demo/admin-js/shim/react-router-dom/index.js ================================================ module.exports = window.ReactRouterDOM; ================================================ FILE: examples/demo/admin-js/src/index.js ================================================ import { memo } from 'react'; import Queue from '@mui/icons-material/Queue'; import { Link } from 'react-router-dom'; import { stringify } from 'query-string'; import { useResourceContext, useRecordContext, useCreatePath, Button } from 'react-admin'; export const CustomCloneButton = (props: CloneButtonProps) => { const { label = 'CUSTOM CLONE', scrollToTop = true, icon = defaultIcon, ...rest } = props; const resource = useResourceContext(props); const record = useRecordContext(props); const createPath = useCreatePath(); const pathname = createPath({ resource, type: 'create' }); return ( ); }; const defaultIcon = ; const stopPropagation = e => e.stopPropagation(); const omitId = ({ id, ...rest }) => rest; const sanitizeRestProps = ({ resource, record, ...rest }) => rest; export const components = {CustomCloneButton: memo(CustomCloneButton)}; ================================================ FILE: examples/demo/app.py ================================================ """Demo application. When running this file, admin will be accessible at /admin. """ import sqlalchemy as sa from aiohttp import web from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column import aiohttp_admin from aiohttp_admin.backends.sqlalchemy import SAResource from aiohttp_admin.types import comp class Base(DeclarativeBase): """Base model.""" class User(Base): __tablename__ = "user" id: Mapped[int] = mapped_column(primary_key=True) username: Mapped[str] = mapped_column(sa.String(32)) email: Mapped[str | None] note: Mapped[str | None] votes: Mapped[int] = mapped_column() __table_args__ = (sa.CheckConstraint(sa.func.char_length(username) >= 3), sa.CheckConstraint(votes >= 1), sa.CheckConstraint(votes < 6), sa.CheckConstraint(votes % 2 == 1)) async def check_credentials(username: str, password: str) -> bool: return username == "admin" and password == "admin" async def create_app() -> web.Application: engine = create_async_engine("sqlite+aiosqlite:///:memory:") session = async_sessionmaker(engine, expire_on_commit=False) # Create some sample data async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with session.begin() as sess: sess.add(User(username="Foo", votes=5)) sess.add(User(username="Spam", votes=1, note="Second user")) app = web.Application() app["static_root_url"] = "/static" app.router.add_static("/static", "static", name="static") # This is the setup required for aiohttp-admin. schema: aiohttp_admin.Schema = { "security": { "check_credentials": check_credentials, "secure": False }, "resources": ({"model": SAResource(engine, User), "show_actions": (comp("CustomCloneButton"),)},), # Use our JS module to include our custom validator. "js_module": str(app.router["static"].url_for(filename="admin.js")) } aiohttp_admin.setup(app, schema) return app if __name__ == "__main__": web.run_app(create_app()) ================================================ FILE: examples/permissions.py ================================================ """Example to demonstrate usage of permissions. When running this file, admin will be accessible at /admin. Check near the bottom of the file for valid usernames (and their respective permissions), login will work with any password. """ import json from datetime import datetime from functools import partial import sqlalchemy as sa from aiohttp import web from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column import aiohttp_admin from aiohttp_admin import Permissions, UserDetails, permission_re_key from aiohttp_admin.backends.sqlalchemy import SAResource, permission_for as p db = web.AppKey("db", async_sessionmaker[AsyncSession]) class Base(DeclarativeBase): """Base model.""" class Simple(Base): __tablename__ = "simple" id: Mapped[int] = mapped_column(primary_key=True) num: Mapped[int] optional_num: Mapped[float | None] value: Mapped[str] class SimpleParent(Base): __tablename__ = "parent" id: Mapped[int] = mapped_column(sa.ForeignKey(Simple.id, ondelete="CASCADE"), primary_key=True) date: Mapped[datetime] class User(Base): __tablename__ = "user" username: Mapped[str] = mapped_column(primary_key=True) permissions: Mapped[str] async def check_credentials(app: web.Application, username: str, password: str) -> bool: """Allow login to any user account regardless of password.""" async with app[db]() as sess: user = await sess.get(User, username.lower()) return user is not None async def identity_callback(app: web.Application, identity: str) -> UserDetails: async with app[db]() as sess: user = await sess.get(User, identity) if not user: raise ValueError("No user found for given identity") return {"permissions": json.loads(user.permissions), "fullName": user.username.title()} async def create_app() -> web.Application: engine = create_async_engine("sqlite+aiosqlite:///:memory:") session = async_sessionmaker(engine, expire_on_commit=False) # Create some sample data async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with session.begin() as sess: sess.add(Simple(num=5, value="first")) p_simple = Simple(num=82, optional_num=12, value="with child") sess.add(p_simple) sess.add(Simple(num=5, value="second")) sess.add(Simple(num=5, value="3")) sess.add(Simple(num=5, optional_num=42, value="4")) sess.add(Simple(num=5, value="5")) async with session.begin() as sess: sess.add(SimpleParent(id=p_simple.id, date=datetime(2023, 2, 13, 19, 4))) app = web.Application() app[db] = session # This is the setup required for aiohttp-admin. schema: aiohttp_admin.Schema = { "security": { "check_credentials": partial(check_credentials, app), "identity_callback": partial(identity_callback, app), "secure": False }, "resources": ( {"model": SAResource(engine, Simple), "display": (Simple.id.name, Simple.num.name, Simple.optional_num.name), "bulk_update": {"Set to 7": {Simple.optional_num.name: 7}}}, {"model": SAResource(engine, SimpleParent)} ) } admin = aiohttp_admin.setup(app, schema) # Create users with various permissions. async with session.begin() as sess: sess.add(User(username="admin", permissions=json.dumps((Permissions.all,)))) sess.add(User(username="view", permissions=json.dumps((Permissions.view,)))) sess.add(User(username="add", permissions=json.dumps( (Permissions.view, Permissions.add,)))) sess.add(User(username="edit", permissions=json.dumps( (Permissions.view, Permissions.edit)))) sess.add(User(username="delete", permissions=json.dumps( (Permissions.view, Permissions.delete)))) users = { "simple": (p(Simple),), "mixed": (p(Simple, "view"), p(Simple, "edit"), p(SimpleParent, "view")), "negated": (Permissions.all, p(SimpleParent, negated=True), p(Simple, "edit", negated=True)), "field": (Permissions.all, p(Simple.optional_num, negated=True)), "field_edit": (Permissions.all, p(Simple.optional_num, "edit", negated=True)), "filter": (Permissions.all, p(Simple, filters={Simple.num: 5})), "filter_edit": (Permissions.all, p(Simple, "edit", filters={Simple.num: 5})), "filter_add": (Permissions.all, p(Simple, "add", filters={Simple.num: 5})), "filter_delete": (Permissions.all, p(Simple, "delete", filters={Simple.num: 5})), "filter_field": (Permissions.all, p(Simple.optional_num, filters={Simple.num: 5})), "filter_field_edit": (Permissions.all, p(Simple.optional_num, "edit", filters={Simple.num: 5})) } for name, permissions in users.items(): if any(admin[permission_re_key].fullmatch(p) is None for p in permissions): raise ValueError("Not a valid permission.") sess.add(User(username=name, permissions=json.dumps(permissions))) return app if __name__ == "__main__": web.run_app(create_app()) ================================================ FILE: examples/relationships.py ================================================ """Example that demonstrates use of various foreign key relationships. An example of each SQLAlchemy relationship is included. However, the many to many relationship requires the react-admin enterprise-edition (not currently supported by aiohttp-admin). https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html When running this file, admin will be accessible at /admin. """ import sqlalchemy as sa from aiohttp import web from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship import aiohttp_admin from aiohttp_admin.backends.sqlalchemy import SAResource class Base(DeclarativeBase): """Base model.""" class OneToManyParent(Base): __tablename__ = "onetomany_parent" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] value: Mapped[int] children: Mapped[list["OneToManyChild"]] = relationship(back_populates="parent") class OneToManyChild(Base): __tablename__ = "onetomany_child" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] value: Mapped[int] parent_id: Mapped[int] = mapped_column(sa.ForeignKey(OneToManyParent.id)) parent: Mapped[OneToManyParent] = relationship(back_populates="children") class ManyToOneParent(Base): __tablename__ = "manytoone_parent" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] value: Mapped[int] child_id: Mapped[int | None] = mapped_column(sa.ForeignKey("manytoone_child.id")) child: Mapped["ManyToOneChild | None"] = relationship(back_populates="parents") class ManyToOneChild(Base): __tablename__ = "manytoone_child" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] value: Mapped[int] parents: Mapped[list[ManyToOneParent]] = relationship(back_populates="child") class OneToOneParent(Base): __tablename__ = "onetoone_parent" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] value: Mapped[int] child: Mapped["OneToOneChild"] = relationship(back_populates="parent") class OneToOneChild(Base): __tablename__ = "onetoone_child" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] value: Mapped[int] parent_id: Mapped[int] = mapped_column(sa.ForeignKey(OneToOneParent.id)) parent: Mapped[OneToOneParent] = relationship(back_populates="child") association_table = sa.Table( "association_table", Base.metadata, sa.Column("left_id", sa.ForeignKey("manytomany_left.id"), primary_key=True), sa.Column("right_id", sa.ForeignKey("manytomany_right.id"), primary_key=True), ) class ManyToManyParent(Base): __tablename__ = "manytomany_left" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] value: Mapped[int] children: Mapped[list["ManyToManyChild"]] = relationship(secondary=association_table, back_populates="parents") class ManyToManyChild(Base): __tablename__ = "manytomany_right" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] value: Mapped[int] parents: Mapped[list[ManyToManyParent]] = relationship(secondary=association_table, back_populates="children") class CompositeForeignKeyChild(Base): __tablename__ = "composite_foreign_key_child" num: Mapped[int] = mapped_column(primary_key=True) ref_num: Mapped[int] = mapped_column(primary_key=True) description: Mapped[str] = mapped_column(sa.String(64)) parents: Mapped[list["CompositeForeignKeyParent"]] = relationship(back_populates="child") class CompositeForeignKeyParent(Base): __tablename__ = "composite_foreign_key_parent" item_id: Mapped[int] = mapped_column(primary_key=True) item_name: Mapped[str] = mapped_column(sa.String(64)) child_id: Mapped[int] ref_num: Mapped[int] child: Mapped[CompositeForeignKeyChild] = relationship(back_populates="parents") @sa.orm.declared_attr.directive @classmethod def __table_args__(cls) -> tuple[sa.schema.SchemaItem, ...]: return (sa.ForeignKeyConstraint( ["child_id", "ref_num"], ["composite_foreign_key_child.num", "composite_foreign_key_child.ref_num"] ),) async def check_credentials(username: str, password: str) -> bool: return username == "admin" and password == "admin" async def create_app() -> web.Application: engine = create_async_engine("sqlite+aiosqlite:///:memory:") session = async_sessionmaker(engine, expire_on_commit=False) # Create some sample data async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with session.begin() as sess: sess.add(OneToManyParent(name="Foo", value=1)) onetomany_1 = OneToManyParent(name="Bar", value=2) sess.add(onetomany_1) manytoone_1 = ManyToOneChild(name="Child Foo", value=4) sess.add(manytoone_1) onetoone_1 = OneToOneParent(name="Foo", value=3) sess.add(onetoone_1) onetoone_2 = OneToOneParent(name="Bar", value=5) sess.add(onetoone_2) manytomany_p1 = ManyToManyParent(name="Foo", value=2) manytomany_p2 = ManyToManyParent(name="Bar", value=3) manytomany_c1 = ManyToManyChild(name="Foo Child", value=5) manytomany_c2 = ManyToManyChild(name="Bar Child", value=6) manytomany_c3 = ManyToManyChild(name="Baz Child", value=7) manytomany_p1.children.append(manytomany_c1) manytomany_p1.children.append(manytomany_c2) manytomany_p2.children.append(manytomany_c1) manytomany_p2.children.append(manytomany_c2) manytomany_p2.children.append(manytomany_c3) sess.add(manytomany_p1) sess.add(manytomany_p2) sess.add(manytomany_c1) sess.add(manytomany_c2) composite_child_1 = CompositeForeignKeyChild(num=0, ref_num=0, description="A") composite_child_2 = CompositeForeignKeyChild(num=0, ref_num=1, description="B") composite_child_3 = CompositeForeignKeyChild(num=1, ref_num=0, description="C") sess.add(composite_child_1) sess.add(composite_child_2) sess.add(composite_child_3) sess.add(CompositeForeignKeyParent(item_name="Foo", child_id=0, ref_num=1)) sess.add(CompositeForeignKeyParent(item_name="Bar", child_id=1, ref_num=0)) async with session.begin() as sess: sess.add(OneToManyChild(name="Child Foo", value=1, parent_id=onetomany_1.id)) sess.add(OneToManyChild(name="Child Bar", value=5, parent_id=onetomany_1.id)) sess.add(ManyToOneParent(name="Foo", value=5, child_id=manytoone_1.id)) sess.add(ManyToOneParent(name="Bar", value=3)) sess.add(OneToOneChild(name="Child Foo", value=0, parent_id=onetoone_2.id)) sess.add(OneToOneChild(name="Child Bar", value=2, parent_id=onetoone_1.id)) app = web.Application() # This is the setup required for aiohttp-admin. schema: aiohttp_admin.Schema = { "security": { "check_credentials": check_credentials, "secure": False }, "resources": ( {"model": SAResource(engine, OneToManyParent), "repr": aiohttp_admin.data("name")}, {"model": SAResource(engine, OneToManyChild)}, {"model": SAResource(engine, ManyToOneParent), "repr": aiohttp_admin.data("name")}, {"model": SAResource(engine, ManyToOneChild)}, {"model": SAResource(engine, OneToOneParent), "repr": aiohttp_admin.data("name")}, {"model": SAResource(engine, OneToOneChild)}, {"model": SAResource(engine, ManyToManyParent)}, {"model": SAResource(engine, ManyToManyChild)}, {"model": SAResource(engine, CompositeForeignKeyChild), "repr": aiohttp_admin.data("description")}, {"model": SAResource(engine, CompositeForeignKeyParent)} ) } aiohttp_admin.setup(app, schema) return app if __name__ == "__main__": web.run_app(create_app()) ================================================ FILE: examples/simple.py ================================================ """Minimal example with simple database models. When running this file, admin will be accessible at /admin. """ from datetime import datetime from enum import Enum import sqlalchemy as sa from aiohttp import web from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column import aiohttp_admin from aiohttp_admin.backends.sqlalchemy import SAResource # Example DB models class Currency(Enum): EUR = 1 GBP = 2 USD = 3 class Base(DeclarativeBase): """Base model.""" class Simple(Base): __tablename__ = "simple" id: Mapped[int] = mapped_column(primary_key=True) num: Mapped[int] optional_num: Mapped[float | None] value: Mapped[str] class SimpleParent(Base): __tablename__ = "parent" id: Mapped[int] = mapped_column(sa.ForeignKey(Simple.id, ondelete="CASCADE"), primary_key=True) date: Mapped[datetime] currency: Mapped[Currency] = mapped_column(default="USD") async def check_credentials(username: str, password: str) -> bool: return username == "admin" and password == "admin" async def create_app() -> web.Application: engine = create_async_engine("sqlite+aiosqlite:///:memory:") session = async_sessionmaker(engine, expire_on_commit=False) # Create some sample data async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with session.begin() as sess: sess.add(Simple(num=5, value="first")) p = Simple(num=82, optional_num=12, value="with child") sess.add(p) async with session.begin() as sess: sess.add(SimpleParent(id=p.id, date=datetime(2023, 2, 13, 19, 4))) app = web.Application() # This is the setup required for aiohttp-admin. schema: aiohttp_admin.Schema = { "security": { "check_credentials": check_credentials, "secure": False }, "resources": ( {"model": SAResource(engine, Simple), "repr": aiohttp_admin.data("value")}, {"model": SAResource(engine, SimpleParent)} ) } aiohttp_admin.setup(app, schema) return app if __name__ == "__main__": web.run_app(create_app()) ================================================ FILE: examples/validators.py ================================================ """Minimal example with simple database models. When running this file, admin will be accessible at /admin. """ import sqlalchemy as sa from aiohttp import web from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column import aiohttp_admin from aiohttp_admin.backends.sqlalchemy import SAResource from aiohttp_admin.types import func, regex JS = """ const odd = (value, allValues) => { if (value % 2 === 0) return "Votes must be an odd number"; return undefined; }; export const functions = {odd}; """ class Base(DeclarativeBase): """Base model.""" class User(Base): __tablename__ = "user" id: Mapped[int] = mapped_column(primary_key=True) username: Mapped[str] = mapped_column(sa.String(32)) email: Mapped[str | None] note: Mapped[str | None] votes: Mapped[int] = mapped_column() __table_args__ = (sa.CheckConstraint(sa.func.char_length(username) >= 3), sa.CheckConstraint(votes >= 1), sa.CheckConstraint(votes < 6), sa.CheckConstraint(votes % 2 == 1)) async def check_credentials(username: str, password: str) -> bool: return username == "admin" and password == "admin" async def serve_js(request: web.Request) -> web.Response: return web.Response(text=JS, content_type="text/javascript") async def create_app() -> web.Application: engine = create_async_engine("sqlite+aiosqlite:///:memory:") session = async_sessionmaker(engine, expire_on_commit=False) # Create some sample data async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with session.begin() as sess: sess.add(User(username="Foo", votes=5)) sess.add(User(username="Spam", votes=1, note="Second user")) app = web.Application() app.router.add_get("/js", serve_js, name="js") # This is the setup required for aiohttp-admin. schema: aiohttp_admin.Schema = { "security": { "check_credentials": check_credentials, "secure": False }, "resources": ({"model": SAResource(engine, User), "validators": {User.username.name: (func("regex", (regex(r"^[A-Z][a-z]+$"),)),), User.email.name: (func("email", ()),), # Custom validator from our JS module. # Min/Max validators are automatically included. User.votes.name: (func("odd"),)}},), # Use our JS module to include our custom validator. "js_module": str(app.router["js"].url_for()) } aiohttp_admin.setup(app, schema) return app if __name__ == "__main__": web.run_app(create_app()) ================================================ FILE: pytest.ini ================================================ [pytest] addopts = # show 10 slowest invocations: --durations=10 # a bit of verbosity doesn't hurt: -v # report all the things == -rxXs: -ra # show values of the local vars in errors: --showlocals # coverage reports --cov=aiohttp_admin/ --cov=tests/ --cov-report term asyncio_mode = auto filterwarnings = error testpaths = tests/ xfail_strict = true ================================================ FILE: requirements-dev.txt ================================================ -r requirements.txt flake8==7.3.0 flake8-bandit==4.1.1 flake8-bugbear==25.11.29 flake8-import-order==0.19.2 flake8-requirements==2.3.0 mypy==1.19.1 sphinx==7.4.7 ================================================ FILE: requirements.txt ================================================ -e . aiohttp==3.13.5 aiohttp-security==0.5.0 aiohttp-session[secure]==2.12.1 aiosqlite==0.21.0 cryptography==46.0.7 pydantic==2.12.5 pytest==8.4.2 pytest-aiohttp==1.1.0 pytest-cov==7.1.0 sqlalchemy==2.0.49 typing_extensions>=4.15.0; python_version<"3.12" ================================================ FILE: setup.py ================================================ import re import sys from pathlib import Path from setuptools import find_packages, setup if not sys.version_info >= (3, 9): raise RuntimeError("aiohttp_admin doesn't support Python earlier than 3.9") def read_version(): regexp = re.compile(r'^__version__\W*=\W*"([\d.abrc]+)"') init_py = Path(__file__).parent / "aiohttp_admin" / "__init__.py" with init_py.open() as f: for line in f: match = regexp.match(line) if match is not None: return match.group(1) raise RuntimeError("Cannot find version in aiohttp_admin/__init__.py") classifiers = ( "License :: OSI Approved :: Apache Software License", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Development Status :: 3 - Alpha", "Topic :: Internet :: WWW/HTTP", "Framework :: AsyncIO", "Framework :: aiohttp", ) setup(name="aiohttp-admin", version=read_version(), description="admin interface for aiohttp application", long_description="\n\n".join((Path("README.rst").read_text(), Path("CHANGES.rst").read_text())), classifiers=classifiers, url="https://github.com/aio-libs/aiohttp-admin", download_url="https://github.com/aio-libs/aiohttp-admin", license="Apache 2", packages=find_packages(), install_requires=("aiohttp>=3.9", "aiohttp_security", "aiohttp_session", "cryptography", "pydantic>2,<3", 'typing_extensions>=3.10; python_version<"3.12"'), extras_require={"sa": ["sqlalchemy>=2.0.4,<3"]}, include_package_data=True) ================================================ FILE: tests/_auth.py ================================================ async def check_credentials(username: str, password: str) -> bool: return username == "admin" and password == "admin123" ================================================ FILE: tests/_resources.py ================================================ from typing import Sequence from aiohttp_admin.backends.abc import (AbstractAdminResource, GetListParams, GetManyRefParams, Meta, Record) from aiohttp_admin.types import ComponentState, InputState class DummyResource(AbstractAdminResource[tuple[str]]): def __init__(self, name: str, fields: dict[str, ComponentState], inputs: dict[str, InputState], primary_key: str): self.name = name self.fields = fields self.inputs = inputs self.primary_key = (primary_key,) self.omit_fields = set() self._id_type = tuple[str] # type: ignore[assignment] self._foreign_rows = set() super().__init__() async def get_list(self, params: GetListParams) -> tuple[list[Record], int]: # pragma: no cover raise NotImplementedError() async def get_one(self, record_id: tuple[str], meta: Meta) -> Record: # pragma: no cover raise NotImplementedError() async def get_many(self, record_ids: Sequence[tuple[str]], meta: Meta) -> list[Record]: # pragma: no cover raise NotImplementedError() async def get_many_ref(self, params: GetManyRefParams) -> tuple[list[Record], int]: # pragma: no cover raise NotImplementedError() async def update( # pragma: no cover self, record_id: tuple[str], data: Record, previous_data: Record, meta: Meta ) -> Record: raise NotImplementedError() async def update_many( # pragma: no cover self, record_ids: Sequence[tuple[str]], data: Record, meta: Meta ) -> list[tuple[str]]: raise NotImplementedError() async def create(self, data: Record, meta: Meta) -> Record: # pragma: no cover raise NotImplementedError() async def delete(self, record_id: tuple[str], previous_data: Record, meta: Meta) -> Record: # pragma: no cover raise NotImplementedError() async def delete_many( # pragma: no cover self, record_ids: Sequence[tuple[str]], meta: Meta ) -> list[tuple[str]]: raise NotImplementedError() ================================================ FILE: tests/conftest.py ================================================ from collections.abc import Awaitable, Callable from typing import Optional from unittest.mock import AsyncMock, create_autospec import pytest import sqlalchemy as sa from aiohttp import web from aiohttp.test_utils import TestClient from pytest_aiohttp import AiohttpClient from sqlalchemy.ext.asyncio import (AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine) from sqlalchemy.orm import DeclarativeBaseNoMeta, Mapped, mapped_column, relationship import aiohttp_admin from _auth import check_credentials from aiohttp_admin.backends.sqlalchemy import SAResource IdentityCallback = Callable[[Optional[str]], Awaitable[aiohttp_admin.UserDetails]] _Client = TestClient[web.Request, web.Application] class Base(DeclarativeBaseNoMeta): """Base model.""" class DummyModel(Base): __tablename__ = "dummy" id: Mapped[int] = mapped_column(primary_key=True) foreigns: Mapped[list["ForeignModel"]] = relationship() class Dummy2Model(Base): __tablename__ = "dummy2" id: Mapped[int] = mapped_column(primary_key=True) msg: Mapped[Optional[str]] class ForeignModel(Base): __tablename__ = "foreign" id: Mapped[int] = mapped_column(primary_key=True) dummy: Mapped[int] = mapped_column(sa.ForeignKey(DummyModel.id)) model = web.AppKey[type[DummyModel]]("model") model2 = web.AppKey[type[Dummy2Model]]("model2") db = web.AppKey("db", async_sessionmaker[AsyncSession]) admin = web.AppKey("admin", web.Application) @pytest.fixture def mock_engine() -> AsyncMock: return create_autospec(AsyncEngine, instance=True, spec_set=True) # type: ignore[no-any-return] # noqa: B950 @pytest.fixture def create_admin_client( aiohttp_client: AiohttpClient ) -> Callable[[Optional[IdentityCallback]], Awaitable[_Client]]: async def admin_client(identity_callback: Optional[IdentityCallback] = None) -> _Client: app = web.Application() app[model] = DummyModel app[model2] = Dummy2Model engine = create_async_engine("sqlite+aiosqlite:///:memory:") app[db] = async_sessionmaker(engine, expire_on_commit=False) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with app[db].begin() as sess: dummy = DummyModel() sess.add(dummy) sess.add(Dummy2Model(msg="Test")) sess.add(Dummy2Model(msg="Test")) sess.add(Dummy2Model(msg="Other")) async with app[db].begin() as sess: sess.add(ForeignModel(dummy=dummy.id)) schema: aiohttp_admin.Schema = { "security": { "check_credentials": check_credentials, "secure": False }, "resources": ( {"model": SAResource(engine, DummyModel)}, {"model": SAResource(engine, Dummy2Model)}, {"model": SAResource(engine, ForeignModel)} ) } if identity_callback: schema["security"]["identity_callback"] = identity_callback app[admin] = aiohttp_admin.setup(app, schema) return await aiohttp_client(app) return admin_client @pytest.fixture async def admin_client(create_admin_client: Callable[[], Awaitable[_Client]]) -> _Client: return await create_admin_client() @pytest.fixture def login() -> Callable[[_Client], Awaitable[dict[str, str]]]: async def do_login(admin_client: _Client) -> dict[str, str]: assert admin_client.app url = admin_client.app[admin].router["token"].url_for() login = {"username": "admin", "password": "admin123"} async with admin_client.post(url, json=login) as resp: assert resp.status == 200 token = resp.headers["X-Token"] return {"Authorization": token} return do_login ================================================ FILE: tests/test_admin.py ================================================ import pytest from aiohttp import web import aiohttp_admin from _auth import check_credentials from _resources import DummyResource from aiohttp_admin.types import comp, func, permission_re_key, state_key def test_path() -> None: app = web.Application() schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, "resources": ()} admin = aiohttp_admin.setup(app, schema) assert str(admin.router["index"].url_for()) == "/admin" admin = aiohttp_admin.setup(app, schema, path="/another/admin") assert str(admin.router["index"].url_for()) == "/another/admin" def test_js_module() -> None: app = web.Application() schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, "resources": (), "js_module": "/custom_js.js"} admin = aiohttp_admin.setup(app, schema) assert admin[state_key]["js_module"] == "/custom_js.js" def test_no_js_module() -> None: app = web.Application() schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, "resources": ()} admin = aiohttp_admin.setup(app, schema) assert admin[state_key]["js_module"] is None def test_validators() -> None: dummy = DummyResource( "dummy", {"id": {"__type__": "component", "type": "NumberField", "props": {}}}, {"id": {"__type__": "component", "type": "NumberInput", "props": {"validate": ({"__type__": "function", "name": "required", "args": ()},)}, "show_create": True}}, "id") app = web.Application() schema: aiohttp_admin.Schema = { "security": {"check_credentials": check_credentials}, "resources": ({"model": dummy, "validators": {"id": (func("minValue", (3,)),)}},)} admin = aiohttp_admin.setup(app, schema) validators = admin[state_key]["resources"]["dummy"]["inputs"]["id"]["props"]["validate"] assert validators == (func("required", ()), func("minValue", (3,))) assert ("minValue", 3) not in dummy.inputs["id"]["props"]["validate"] # type: ignore[operator] def test_re() -> None: test_re = DummyResource( "testre", {"id": comp("NumberField"), "value": comp("TextField")}, {}, "id") app = web.Application() schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, "resources": ({"model": test_re},)} admin = aiohttp_admin.setup(app, schema) r = admin[permission_re_key] assert r.fullmatch("admin.*") assert r.fullmatch("admin.view") assert r.fullmatch("~admin.edit") assert r.fullmatch("admin.testre.*") assert r.fullmatch("admin.testre.add") assert r.fullmatch("admin.testre.id.*") assert r.fullmatch("admin.testre.value.edit") assert r.fullmatch("~admin.testre.id.edit") assert r.fullmatch("admin.testre.edit|id=5") assert r.fullmatch('admin.testre.add|id=1|value="4"|value="7"') assert r.fullmatch('admin.testre.value.*|value="foo"') assert r.fullmatch("admin.testre.value.delete|id=5|id=3") assert r.fullmatch("testre.edit") is None assert r.fullmatch("admin.create") is None assert r.fullmatch("admin.nottest.*") is None assert r.fullmatch("admin.*|id=1") is None assert r.fullmatch("admin.testre.edit|other=5") is None assert r.fullmatch("admin.testre.value.*|value=unquoted") is None assert r.fullmatch("~admin.testre.edit|id=5") is None assert r.fullmatch('~admin.testre.value.delete|value="1"') is None def test_display() -> None: app = web.Application() model = DummyResource( "test", {"id": comp("TextField", {"source": "id"}), "foo": comp("TextField", {"source": "foo"})}, {"id": comp("TextInput", {"validate": (func("required", ()),)}) | {"show_create": False}, # type: ignore[dict-item] "foo": comp("TextInput") | {"show_create": True}}, # type: ignore[dict-item] "id") schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, "resources": ({"model": model, "display": ("foo",)},)} admin = aiohttp_admin.setup(app, schema) test_state = admin[state_key]["resources"]["test"] assert test_state["list_omit"] == ("id",) assert test_state["inputs"]["id"]["props"] == {"key": "id", "validate": (func("required", ()),)} assert test_state["inputs"]["foo"]["props"] == {"key": "foo", "alwaysOn": "alwaysOn"} def test_display_invalid() -> None: app = web.Application() model = DummyResource("test", {"id": comp("TextField"), "foo": comp("TextField")}, {}, "id") schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, "resources": ({"model": model, "display": ("bar",)},)} with pytest.raises(ValueError, match=r"Display includes non-existent field \('bar',\)"): aiohttp_admin.setup(app, schema) def test_extra_props() -> None: app = web.Application() model = DummyResource( "test", {"id": comp("TextField", {"textAlign": "right", "placeholder": "foo"})}, {"id": comp("TextInput", {"resettable": False, "type": "text"}) | {"show_create": False}}, # type: ignore[dict-item] "id") schema: aiohttp_admin.Schema = { "security": {"check_credentials": check_credentials}, "resources": ({ "model": model, "field_props": {"id": {"textAlign": "left", "label": "Spam"}}, "input_props": {"id": {"type": "email", "multiline": True}} },)} admin = aiohttp_admin.setup(app, schema) test_state = admin[state_key]["resources"]["test"] assert test_state["fields"]["id"]["props"] == {"textAlign": "left", "placeholder": "foo", "label": "Spam", "key": "id"} assert test_state["inputs"]["id"]["props"] == {"alwaysOn": "alwaysOn", "type": "email", "multiline": True, "resettable": False, "key": "id"} def test_invalid_repr() -> None: app = web.Application() model = DummyResource("test", {"id": comp("TextField"), "foo": comp("TextField")}, {}, "id") schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials}, "resources": ({"model": model, "repr": "bar"},)} with pytest.raises(ValueError, match=r"not a valid field name: bar"): aiohttp_admin.setup(app, schema) ================================================ FILE: tests/test_backends_abc.py ================================================ import json from collections.abc import Awaitable, Callable from aiohttp import web from aiohttp.test_utils import TestClient from conftest import admin _Client = TestClient[web.Request, web.Application] _Login = Callable[[_Client], Awaitable[dict[str, str]]] async def test_create_with_null(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy2_create"].url_for() p = {"data": json.dumps({"data": {"msg": None}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 200, await resp.text() assert await resp.json() == {"data": {"id": "4", "data": {"id": 4, "msg": None}}} async def test_invalid_field(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy2_create"].url_for() p = {"data": json.dumps({"data": {"incorrect": "foo"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 400, await resp.text() assert "Invalid field 'incorrect'" in await resp.text() ================================================ FILE: tests/test_backends_sqlalchemy.py ================================================ import json from collections.abc import Awaitable, Callable from datetime import date, datetime from typing import Optional, Union import pytest import sqlalchemy as sa from aiohttp import web from aiohttp.test_utils import TestClient from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.sql.type_api import TypeEngine from sqlalchemy.types import TypeDecorator import aiohttp_admin from _auth import check_credentials from aiohttp_admin.backends.sqlalchemy import FIELD_TYPES, SAResource, permission_for from aiohttp_admin.types import comp, data, fk, func, regex from conftest import admin _Client = TestClient[web.Request, web.Application] _Login = Callable[[_Client], Awaitable[dict[str, str]]] @pytest.fixture def base() -> type[DeclarativeBase]: class Base(DeclarativeBase): """Base model.""" return Base def test_no_subtypes() -> None: """We don't want any subtypes in the lookup, as this would depend on test ordering.""" assert all({TypeEngine, TypeDecorator} & set(t.__bases__) for t in FIELD_TYPES) def test_pk(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None: class TestModel(base): # type: ignore[misc,valid-type] __tablename__ = "dummy" id: Mapped[int] = mapped_column(primary_key=True) num: Mapped[str] r = SAResource(mock_engine, TestModel) assert r.name == "dummy" assert r.primary_key == ("id",) assert r.fields == {"id": comp("NumberField", {"source": "data.id", "key": "id"}), "num": comp("TextField", {"source": "data.num", "key": "num", "fullWidth": True})} # Autoincremented PK should not be in create form assert r.inputs == { "id": comp("NumberInput", {"source": "data.id", "key": "id", "validate": [func("required", ())]}) | {"show_create": False}, "num": comp("TextInput", { "source": "data.num", "fullWidth": True, "multiline": True, "key": "num", "validate": [func("required", ())]}) | {"show_create": True} } def test_table(mock_engine: AsyncEngine) -> None: dummy_table = sa.Table("dummy", sa.MetaData(), sa.Column("id", sa.Integer, primary_key=True), sa.Column("num", sa.String(30))) r = SAResource(mock_engine, dummy_table) assert r.name == "dummy" assert r.primary_key == ("id",) assert r.fields == { "id": comp("NumberField", {"source": "data.id", "key": "id"}), "num": comp("TextField", {"source": "data.num", "key": "num"}) } # Autoincremented PK should not be in create form assert r.inputs == { "id": comp("NumberInput", {"source": "data.id", "key": "id", "validate": [func("required", ())]}) | {"show_create": False}, "num": comp("TextInput", {"source": "data.num", "key": "num", "validate": [func("maxLength", (30,))]}) | {"show_create": True} } def test_extra_props(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None: class TestModel(base): # type: ignore[misc,valid-type] __tablename__ = "dummy" id: Mapped[int] = mapped_column(primary_key=True) num: Mapped[str] = mapped_column(sa.String(128), comment="Foo", default="Bar") r = SAResource(mock_engine, TestModel) assert r.fields["num"]["props"] == { "source": "data.num", "key": "num", "label": "Num", "fullWidth": True, "placeholder": "Bar", "helperText": "Foo"} assert r.inputs["num"]["props"] == { "source": "data.num", "key": "num", "label": "Num", "fullWidth": True, "multiline": True, "placeholder": "Bar", "helperText": "Foo", "validate": [func("maxLength", (128,))]} async def test_binary( base: DeclarativeBase, aiohttp_client: Callable[[web.Application], Awaitable[_Client]], login: _Login ) -> None: class TestModel(base): # type: ignore[misc,valid-type] __tablename__ = "test" id: Mapped[int] = mapped_column(primary_key=True) binary: Mapped[bytes] app = web.Application() engine = create_async_engine("sqlite+aiosqlite:///:memory:") db = async_sessionmaker(engine, expire_on_commit=False) async with engine.begin() as conn: await conn.run_sync(base.metadata.create_all) async with db.begin() as sess: sess.add(TestModel(binary=b"foo")) sess.add(TestModel(binary=b"\x01\xFF\x02")) schema: aiohttp_admin.Schema = { "security": { "check_credentials": check_credentials, "secure": False }, "resources": ({"model": SAResource(engine, TestModel)},) } app[admin] = aiohttp_admin.setup(app, schema) admin_client = await aiohttp_client(app) assert admin_client.app h = await login(admin_client) url = app[admin].router["test_get_one"].url_for() async with admin_client.get(url, params={"id": 1}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "data": {"id": 1, "binary": "foo"}}} async with admin_client.get(url, params={"id": 2}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "2", "data": {"id": 2, "binary": "\x01�\x02"}}} def test_fk(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None: class TestModel(base): # type: ignore[misc,valid-type] __tablename__ = "dummy" id: Mapped[int] = mapped_column(primary_key=True) class TestChildModel(base): # type: ignore[misc,valid-type] __tablename__ = "child" id: Mapped[int] = mapped_column(sa.ForeignKey(TestModel.id), primary_key=True) r = SAResource(mock_engine, TestChildModel) assert r.name == "child" assert r.primary_key == ("id",) assert r.fields == {"id": comp("ReferenceField", {"reference": "dummy", "source": fk("id"), "key": "id", "label": "Id", "target": "id"})} # PK with FK constraint should be shown in create form. assert r.inputs == {"id": comp( "ReferenceInput", {"validate": [func("required", ())], "reference": "dummy", "key": "id", "source": fk("id"), "target": "id", "label": "Id", "referenceKeys": (("id", "id"),)}) | {"show_create": True}} async def test_fk_output( base: DeclarativeBase, aiohttp_client: Callable[[web.Application], Awaitable[_Client]], login: _Login ) -> None: class TestModel(base): # type: ignore[misc,valid-type] __tablename__ = "test" id: Mapped[int] = mapped_column(primary_key=True) class TestModelParent(base): # type: ignore[misc,valid-type] __tablename__ = "parent" id: Mapped[int] = mapped_column(primary_key=True) child_id: Mapped[int] = mapped_column(sa.ForeignKey(TestModel.id)) app = web.Application() engine = create_async_engine("sqlite+aiosqlite:///:memory:") db = async_sessionmaker(engine, expire_on_commit=False) async with engine.begin() as conn: await conn.run_sync(base.metadata.create_all) async with db.begin() as sess: child = TestModel() sess.add(child) async with db.begin() as sess: sess.add(TestModelParent(child_id=child.id)) schema: aiohttp_admin.Schema = { "security": { "check_credentials": check_credentials, "secure": False }, "resources": ({"model": SAResource(engine, TestModel)}, {"model": SAResource(engine, TestModelParent)}) } app[admin] = aiohttp_admin.setup(app, schema) admin_client = await aiohttp_client(app) assert admin_client.app h = await login(admin_client) url = app[admin].router["parent_get_one"].url_for() async with admin_client.get(url, params={"id": 1}, headers=h) as resp: assert resp.status == 200 # child_id must be converted to str ID. assert await resp.json() == {"data": {"id": "1", "fk_child_id": "1", "data": {"id": 1, "child_id": 1}}} def test_relationship(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None: class TestMany(base): # type: ignore[misc,valid-type] __tablename__ = "many" id: Mapped[int] = mapped_column(primary_key=True) foo: Mapped[int] ones: Mapped[list["TestOne"]] = relationship(back_populates="many") class TestOne(base): # type: ignore[misc,valid-type] __tablename__ = "one" id: Mapped[int] = mapped_column(primary_key=True) many_id: Mapped[int] = mapped_column(sa.ForeignKey(TestMany.id)) many: Mapped[TestMany] = relationship(back_populates="ones") r = SAResource(mock_engine, TestMany) assert r.name == "many" assert r.fields["ones"] == comp( "ReferenceManyField", {"children": comp("Datagrid", { "rowClick": "show", "children": [comp("NumberField", {"source": data("id")})], "bulkActionButtons": comp("BulkDeleteButton", {"mutationMode": "pessimistic"})}), "label": "Ones", "reference": "many", "source": "id", "target": "ones", "sortable": False, "key": "ones", "filter": {"__meta__": {"orm": True}}}) assert "ones" not in r.inputs r = SAResource(mock_engine, TestOne) assert r.name == "one" assert r.fields["many"] == comp( "ReferenceField", {"children": comp("DatagridSingle", { "rowClick": "show", "children": [comp("NumberField", {"source": data("foo")})]}), "label": "Many", "reference": "many", "source": fk("many_id"), "target": fk("id"), "sortable": False, "link": "show", "key": "many"}) assert "many" not in r.inputs def test_relationship_onetoone(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None: class TestA(base): # type: ignore[misc,valid-type] __tablename__ = "test_a" id: Mapped[int] = mapped_column(primary_key=True) str: Mapped[str] other: Mapped["TestB"] = relationship(back_populates="linked") class TestB(base): # type: ignore[misc,valid-type] __tablename__ = "test_b" id: Mapped[int] = mapped_column(primary_key=True) a_id: Mapped[int] = mapped_column(sa.ForeignKey(TestA.id)) linked: Mapped[TestA] = relationship(back_populates="other") r = SAResource(mock_engine, TestA) assert r.name == "test_a" assert r.fields["other"] == comp( "ReferenceOneField", {"children": comp("DatagridSingle", { "rowClick": "show", "children": [comp("NumberField", {"source": data("id")})]}), "label": "Other", "reference": "test_b", "source": fk("id"), "target": fk("a_id"), "sortable": False, "link": "show", "key": "other"}) assert "other" not in r.inputs r = SAResource(mock_engine, TestB) assert r.name == "test_b" assert r.fields["linked"] == comp( "ReferenceField", {"children": comp("DatagridSingle", { "rowClick": "show", "children": [comp("TextField", {"source": data("str")})]}), "label": "Linked", "reference": "test_a", "source": fk("a_id"), "target": fk("id"), "sortable": False, "link": "show", "key": "linked"}) assert "linked" not in r.inputs def test_check_constraints(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None: class TestCC(base): # type: ignore[misc,valid-type] __tablename__ = "test" pk: Mapped[int] = mapped_column(primary_key=True) default: Mapped[int] = mapped_column(default=5) server_default: Mapped[int] = mapped_column(server_default="4") nullable: Mapped[Union[int, None]] not_nullable: Mapped[int] max_length: Mapped[str] = mapped_column(sa.String(16)) gt: Mapped[int] = mapped_column() gte: Mapped[int] = mapped_column() lt: Mapped[int] = mapped_column() lte: Mapped[Union[int, None]] = mapped_column() min_length: Mapped[str] = mapped_column() min_length_gt: Mapped[str] = mapped_column() regex: Mapped[str] = mapped_column() with_and: Mapped[int] = mapped_column() with_or: Mapped[int] = mapped_column() __table_args__ = (sa.CheckConstraint(gt > 3), sa.CheckConstraint(gte >= 3), sa.CheckConstraint(lt < 3), sa.CheckConstraint(lte <= 3), sa.CheckConstraint(sa.func.char_length(min_length) >= 5), sa.CheckConstraint(sa.func.char_length(min_length_gt) > 5), sa.CheckConstraint(sa.func.regexp(regex, r"abc.*")), sa.CheckConstraint(sa.and_(with_and > 7, with_and < 12)), sa.CheckConstraint(sa.or_(with_or > 7, with_or < 12))) r = SAResource(mock_engine, TestCC) f = r.inputs required = func("required", ()) assert f["pk"]["props"]["validate"] == [required] assert f["default"]["props"]["validate"] == [] assert f["server_default"]["props"]["validate"] == [] assert f["nullable"]["props"]["validate"] == [] assert f["not_nullable"]["props"]["validate"] == [required] assert f["max_length"]["props"]["validate"] == [required, func("maxLength", (16,))] assert f["gt"]["props"]["validate"] == [required, func("minValue", (4,))] assert f["gte"]["props"]["validate"] == [required, func("minValue", (3,))] assert f["lt"]["props"]["validate"] == [required, func("maxValue", (2,))] assert f["lte"]["props"]["validate"] == [func("maxValue", (3,))] assert f["min_length"]["props"]["validate"] == [required, func("minLength", (5,))] assert f["min_length_gt"]["props"]["validate"] == [required, func("minLength", (6,))] assert f["regex"]["props"]["validate"] == [required, func("regex", (regex("abc.*"),))] assert f["with_and"]["props"]["validate"] == [ required, func("minValue", (8,)), func("maxValue", (11,))] assert f["with_or"]["props"]["validate"] == [required] async def test_nonid_pk(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None: class TestModel(base): # type: ignore[misc,valid-type] __tablename__ = "test" num: Mapped[int] = mapped_column(primary_key=True) other: Mapped[str] = mapped_column(sa.String(64)) r = SAResource(mock_engine, TestModel) assert r.name == "test" assert r.primary_key == ("num",) assert r.fields == { "num": comp("NumberField", {"source": data("num"), "key": "num"}), "other": comp("TextField", {"source": data("other"), "key": "other", "fullWidth": True}) } assert r.inputs == { "num": comp("NumberInput", {"source": data("num"), "key": "num", "validate": [func("required", ())]}) | {"show_create": False}, "other": comp("TextInput", { "fullWidth": True, "source": data("other"), "key": "other", "validate": [func("required", ()), func("maxLength", (64,))]}) | {"show_create": True} } async def test_id_nonpk(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None: class NotPK(base): # type: ignore[misc,valid-type] __tablename__ = "notpk" name: Mapped[str] = mapped_column(primary_key=True) id: Mapped[int] class CompositePK(base): # type: ignore[misc,valid-type] __tablename__ = "compound" id: Mapped[int] = mapped_column(primary_key=True) other: Mapped[int] = mapped_column(primary_key=True) r = SAResource(mock_engine, CompositePK) assert r.name == "compound" assert r.primary_key == ("id", "other") assert r.fields == { "id": comp("NumberField", {"source": data("id"), "key": "id"}), "other": comp("NumberField", {"source": data("other"), "key": "other"}) } assert r.inputs == { "id": comp("NumberInput", {"source": data("id"), "key": "id", "validate": [func("required", ())]}) | {"show_create": True}, "other": comp("NumberInput", { "source": data("other"), "key": "other", "validate": [func("required", ())]}) | {"show_create": True} } async def test_nonid_pk_api( base: DeclarativeBase, aiohttp_client: Callable[[web.Application], Awaitable[_Client]], login: _Login ) -> None: class TestModel(base): # type: ignore[misc,valid-type] __tablename__ = "test" num: Mapped[int] = mapped_column(primary_key=True) other: Mapped[str] app = web.Application() engine = create_async_engine("sqlite+aiosqlite:///:memory:") db = async_sessionmaker(engine, expire_on_commit=False) async with engine.begin() as conn: await conn.run_sync(base.metadata.create_all) async with db.begin() as sess: sess.add(TestModel(num=5, other="foo")) sess.add(TestModel(num=8, other="bar")) schema: aiohttp_admin.Schema = { "security": { "check_credentials": check_credentials, "secure": False }, "resources": ({"model": SAResource(engine, TestModel)},) } app[admin] = aiohttp_admin.setup(app, schema) admin_client = await aiohttp_client(app) assert admin_client.app h = await login(admin_client) url = app[admin].router["test_get_list"].url_for() p = {"pagination": json.dumps({"page": 1, "perPage": 10}), "sort": json.dumps({"field": "id", "order": "DESC"}), "filter": "{}"} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [ {"id": "8", "data": {"num": 8, "other": "bar"}}, {"id": "5", "data": {"num": 5, "other": "foo"}}], "total": 2} url = app[admin].router["test_get_one"].url_for() async with admin_client.get(url, params={"id": 8}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "8", "data": {"num": 8, "other": "bar"}}} url = app[admin].router["test_get_many"].url_for() async with admin_client.get(url, params={"ids": '["5", "8"]'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [{"id": "5", "data": {"num": 5, "other": "foo"}}, {"id": "8", "data": {"num": 8, "other": "bar"}}]} url = app[admin].router["test_create"].url_for() p = {"data": json.dumps({"data": {"num": 12, "other": "this"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "12", "data": {"num": 12, "other": "this"}}} url = app[admin].router["test_update"].url_for() p1 = {"id": "5", "data": json.dumps({"id": "5", "data": {"other": "that"}}), "previousData": json.dumps({"id": "5", "data": {}})} async with admin_client.put(url, params=p1, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "5", "data": {"num": 5, "other": "that"}}} async def test_datetime( base: DeclarativeBase, aiohttp_client: Callable[[web.Application], Awaitable[_Client]], login: _Login ) -> None: class TestModel(base): # type: ignore[misc,valid-type] __tablename__ = "test" id: Mapped[int] = mapped_column(primary_key=True) date: Mapped[date] time: Mapped[datetime] app = web.Application() engine = create_async_engine("sqlite+aiosqlite:///:memory:") db = async_sessionmaker(engine, expire_on_commit=False) async with engine.begin() as conn: await conn.run_sync(base.metadata.create_all) async with db.begin() as sess: sess.add(TestModel(date=date(2023, 4, 23), time=datetime(2023, 1, 2, 3, 4))) schema: aiohttp_admin.Schema = { "security": { "check_credentials": check_credentials, "secure": False }, "resources": ({"model": SAResource(engine, TestModel)},) } app[admin] = aiohttp_admin.setup(app, schema) admin_client = await aiohttp_client(app) assert admin_client.app h = await login(admin_client) url = app[admin].router["test_get_one"].url_for() async with admin_client.get(url, params={"id": 1}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "data": { "id": 1, "date": "2023-04-23", "time": "2023-01-02 03:04:00"}}} url = app[admin].router["test_create"].url_for() p = {"data": json.dumps({"data": {"date": "2024-05-09", "time": "2020-11-12 03:04:05"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "2", "data": { "id": 2, "date": "2024-05-09", "time": "2020-11-12 03:04:05"}}} def test_permission_for(base: type[DeclarativeBase]) -> None: class M(base): # type: ignore[misc,valid-type] __tablename__ = "test" id: Mapped[int] = mapped_column(primary_key=True) cat: Mapped[int] val: Mapped[str] t = M.__table__ assert permission_for(M) == "admin.test.*" assert permission_for(M, "view") == "admin.test.view" assert permission_for(M, "add", negated=True) == "~admin.test.add" assert permission_for(M.cat, "edit") == "admin.test.cat.edit" assert permission_for(t.c["val"], "*", negated=True) == "~admin.test.val.*" assert permission_for(M, filters={M.cat: 5, M.val: "Foo"}) == 'admin.test.*|cat=5|val="Foo"' assert permission_for( t, "delete", filters={t.c["val"]: "bar"}) == 'admin.test.delete|val="bar"' assert permission_for(M.val, filters={M.id: (3, 4)}) == "admin.test.val.*|id=3|id=4" assert permission_for( M.cat, "edit", filters={M.cat: [1, 5]}) == "admin.test.cat.edit|cat=1|cat=5" with pytest.raises(ValueError, match="Can't use filters on negated"): permission_for(M, filters={M.id: 1}, negated=True) with pytest.raises(ValueError, match="foo"): permission_for(M, "foo") # type: ignore[arg-type] class Wrong(base): # type: ignore[misc,valid-type] __tablename__ = "wrong" id: Mapped[int] = mapped_column(primary_key=True) with pytest.raises(ValueError, match="not an attribute"): permission_for(M, filters={Wrong.id: 1}) async def test_record_type( base: DeclarativeBase, aiohttp_client: Callable[[web.Application], Awaitable[_Client]], login: _Login ) -> None: class TestModel(base): # type: ignore[misc,valid-type] __tablename__ = "test" id: Mapped[int] = mapped_column(primary_key=True) foo: Mapped[Optional[bool]] bar: Mapped[int] app = web.Application() engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(base.metadata.create_all) schema: aiohttp_admin.Schema = { "security": { "check_credentials": check_credentials, "secure": False }, "resources": ({"model": SAResource(engine, TestModel)},) } app[admin] = aiohttp_admin.setup(app, schema) admin_client = await aiohttp_client(app) assert admin_client.app h = await login(admin_client) url = app[admin].router["test_create"].url_for() p = {"data": json.dumps({"data": {"foo": True, "bar": 5}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "data": {"id": 1, "foo": True, "bar": 5}}} p = {"data": json.dumps({"data": {"foo": None, "bar": -1}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "2", "data": {"id": 2, "foo": None, "bar": -1}}} p = {"data": json.dumps({"data": {"foo": 5, "bar": "foo"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 400 errors = await resp.json() assert any(e["loc"] == ["foo"] and e["type"] == "bool_parsing" for e in errors) assert any(e["loc"] == ["bar"] and e["type"] == "int_parsing" for e in errors) p = {"data": json.dumps({"data": {"foo": "foo", "bar": None}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 400 errors = await resp.json() assert any(e["loc"] == ["foo"] and e["type"] == "bool_parsing" for e in errors) assert any(e["loc"] == ["bar"] and e["type"] == "int_type" for e in errors) ================================================ FILE: tests/test_security.py ================================================ import json from collections.abc import Awaitable, Callable from typing import Optional from unittest import mock from aiohttp import web from aiohttp.test_utils import TestClient from aiohttp_admin import Permissions, UserDetails from conftest import IdentityCallback, admin, db, model2 _Client = TestClient[web.Request, web.Application] _CreateClient = Callable[[IdentityCallback], Awaitable[_Client]] _Login = Callable[[_Client], Awaitable[dict[str, str]]] async def test_no_token(admin_client: _Client) -> None: assert admin_client.app url = admin_client.app[admin].router["dummy_get_list"].url_for() async with admin_client.get(url) as resp: assert resp.status == 401 assert await resp.text() == "401: Unauthorized" async def test_invalid_token(admin_client: _Client) -> None: assert admin_client.app url = admin_client.app[admin].router["dummy_get_one"].url_for() h = {"Authorization": "invalid"} async with admin_client.get(url, headers=h) as resp: assert resp.status async def test_valid_login_logout(admin_client: _Client) -> None: assert admin_client.app url = admin_client.app[admin].router["token"].url_for() login = {"username": "admin", "password": "admin123"} async with admin_client.post(url, json=login) as resp: assert resp.status == 200 token = resp.headers["X-Token"] get_one_url = admin_client.app[admin].router["dummy_get_one"].url_for() p = {"id": 1} h = {"Authorization": token} async with admin_client.get(get_one_url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "fk_id": "1", "data": {"id": 1}}} # Continue to test logout logout_url = admin_client.app[admin].router["logout"].url_for() async with admin_client.delete(logout_url, headers=h) as resp: assert resp.status == 200 async with admin_client.get(get_one_url, params=p, headers=h) as resp: assert resp.status == 401 async def test_missing_token(admin_client: _Client) -> None: assert admin_client.app url = admin_client.app[admin].router["token"].url_for() login = {"username": "admin", "password": "admin123"} async with admin_client.post(url, json=login) as resp: assert resp.status == 200 cookies = tuple(admin_client.session.cookie_jar) assert len(cookies) == 1 assert cookies[0]["path"] == "/admin" url = admin_client.app[admin].router["dummy_get_one"].url_for() p = {"id": 1} async with admin_client.get(url, params=p) as resp: assert resp.status == 401 async def test_missing_cookie(admin_client: _Client) -> None: assert admin_client.app url = admin_client.app[admin].router["token"].url_for() login = {"username": "admin", "password": "admin123"} async with admin_client.post(url, json=login) as resp: assert resp.status == 200 token = resp.headers["X-Token"] admin_client.session.cookie_jar.clear() url = admin_client.app[admin].router["dummy_get_one"].url_for() p = {"id": 1} h = {"Authorization": token} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 401 async def test_login_invalid_payload(admin_client: _Client) -> None: assert admin_client.app url = admin_client.app[admin].router["token"].url_for() async with admin_client.post(url, json={"foo": "bar", "password": None}) as resp: assert resp.status == 400 result = await resp.json() assert len(result) == 2 assert result[0]["loc"] == ["username"] assert result[0]["msg"] == "Field required" assert result[0]["input"] == {"foo": "bar", "password": None} assert result[1]["loc"] == ["password"] assert result[1]["msg"] == "Input should be a valid string" assert result[1]["input"] is None async def test_list_without_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {Permissions.edit, Permissions.delete}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy_get_list"].url_for() p = {"pagination": json.dumps({"page": 1, "perPage": 10}), "sort": json.dumps({"field": "id", "order": "DESC"}), "filter": "{}"} h = await login(admin_client) async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 403 # TODO(aiohttp-security05) # expected = "403: User does not have 'admin.dummy.view' permission" # assert await resp.text() == expected async def test_get_resource_with_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.dummy.view"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy_get_one"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"id": "1"}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "fk_id": "1", "data": {"id": 1}}} async def test_get_fk_with_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.foreign.view"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["foreign_get_one"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"id": 1}, headers=h) as resp: assert resp.status == 200 expected = {"id": "1", "fk_dummy": "1", "data": {"id": 1, "dummy": 1}} assert await resp.json() == {"data": expected} async def test_get_fk_without_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.foreign.view", "~admin.foreign.dummy.*"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["foreign_get_one"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"id": 1}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "data": {"id": 1}}} async def test_get_resource_with_wildcard_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.dummy.*"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy_get_one"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"id": "1"}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "fk_id": "1", "data": {"id": 1}}} async def test_get_resource_with_negative_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.*", "~admin.dummy.*", "~admin.dummy2.add"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy_get_one"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"id": 1}, headers=h) as resp: assert resp.status == 403 # TODO(aiohttp-security05) # expected = "403: User does not have 'admin.dummy.view' permission" # assert await resp.text() == expected url = admin_client.app[admin].router["dummy2_get_one"].url_for() async with admin_client.get(url, params={"id": "1"}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "data": {"id": 1, "msg": "Test"}}} url = admin_client.app[admin].router["dummy2_create"].url_for() p = {"data": '{"data": {"msg": "Foo"}}'} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 403 # TODO(aiohttp-security05) # expected = "403: User does not have 'admin.dummy2.create' permission" async def test_list_resource_finegrained_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.*", "~admin.dummy2.msg.view"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_list"].url_for() h = await login(admin_client) p = {"pagination": json.dumps({"page": 1, "perPage": 10}), "sort": json.dumps({"field": "id", "order": "DESC"}), "filter": "{}"} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [{"id": "3", "data": {"id": 3}}, {"id": "2", "data": {"id": 2}}, {"id": "1", "data": {"id": 1}}], "total": 3} async def test_get_resource_finegrained_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.*", "~admin.dummy2.msg.view"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_one"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"id": "1"}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "data": {"id": 1}}} async def test_get_many_resource_finegrained_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.*", "~admin.dummy2.msg.view"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_many"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"ids": '["1"]'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [{"id": "1", "data": {"id": 1}}]} async def test_create_resource_finegrained_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.*", "~admin.dummy2.msg.add"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_create"].url_for() h = await login(admin_client) p = {"data": json.dumps({"data": {"msg": "ABC"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 403 # TODO(aiohttp-security05) # expected = "403: User does not have 'admin.dummy2.msg.create' permission" async with admin_client.post(url, params={"data": '{"data": {}}'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "4", "data": {"id": 4, "msg": None}}} async def test_create_resource_filtered_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.*", "~admin.dummy2.msg.*"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_create"].url_for() h = await login(admin_client) p = {"data": json.dumps({"data": {"msg": "ABC"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 403 # TODO(aiohttp-security05) # expected = "403: User does not have 'admin.dummy2.msg.create' permission" async with admin_client.post(url, params={"data": '{"data": {}}'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "4", "data": {"id": 4}}} async def test_update_resource_finegrained_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.*", "~admin.dummy2.msg.edit"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_update"].url_for() h = await login(admin_client) p = {"id": "1", "data": json.dumps({"id": "222", "data": {"id": 222, "msg": "ABC"}}), "previousData": '{"id": "1", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "222", "data": {"id": 222, "msg": "Test"}}} async def test_update_resource_filtered_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.*", "~admin.dummy2.msg.*"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_update"].url_for() h = await login(admin_client) p = {"id": "1", "data": json.dumps({"id": "222", "data": {"id": 222, "msg": "ABC"}}), "previousData": '{"id": "1", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "222", "data": {"id": 222}}} async with admin_client.app[db]() as sess: r = await sess.get(admin_client.app[model2], 1) assert r is None r = await sess.get(admin_client.app[model2], 222) assert r is not None assert r.msg == "Test" async def test_update_many_resource_finegrained_permission( create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.*", "~admin.dummy2.msg.edit"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_update_many"].url_for() h = await login(admin_client) p = {"ids": '["1"]', "data": json.dumps({"msg": "ABC"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 403 # TODO(aiohttp-security05) # expected = "403: User does not have 'admin.dummy2.msg.edit' permission" async def test_delete_resource_filtered_permission(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: assert identity == "admin" return {"permissions": {"admin.*", "~admin.dummy2.msg.view"}} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_delete"].url_for() h = await login(admin_client) p = {"id": "1", "previousData": '{"id": "1", "data": {}}'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "data": {"id": 1}}} async def test_permissions_cached(create_admin_client: _CreateClient, login: _Login) -> None: identity_callback = mock.AsyncMock(spec_set=(), return_value={"permissions": {"admin.*"}}) admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_list"].url_for() h = await login(admin_client) identity_callback.assert_called_once() identity_callback.reset_mock() p = {"pagination": json.dumps({"page": 1, "perPage": 10}), "sort": json.dumps({"field": "id", "order": "DESC"}), "filter": "{}"} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 identity_callback.assert_called_once() async def test_permission_filter_list(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.*|msg="Test"|msg="Foo"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app async with admin_client.app[db].begin() as sess: sess.add(admin_client.app[model2](msg="Foo")) url = admin_client.app[admin].router["dummy2_get_list"].url_for() p = {"pagination": json.dumps({"page": 1, "perPage": 10}), "sort": json.dumps({"field": "id", "order": "DESC"}), "filter": "{}"} h = await login(admin_client) async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == { "data": [{"id": "4", "data": {"id": 4, "msg": "Foo"}}, {"id": "2", "data": {"id": 2, "msg": "Test"}}, {"id": "1", "data": {"id": 1, "msg": "Test"}}], "total": 3} async def test_permission_filter_list2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.view|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_list"].url_for() p = {"pagination": json.dumps({"page": 1, "perPage": 10}), "sort": json.dumps({"field": "id", "order": "DESC"}), "filter": "{}"} h = await login(admin_client) async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == { "data": [{"id": "2", "data": {"id": 2, "msg": "Test"}}, {"id": "1", "data": {"id": 1, "msg": "Test"}}], "total": 2} async def test_permission_filter_get_one(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.*|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_one"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"id": "2"}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "2", "data": {"id": 2, "msg": "Test"}}} async with admin_client.get(url, params={"id": "3"}, headers=h) as resp: assert resp.status == 403 async def test_permission_filter_get_one2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.view|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_one"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"id": "2"}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "2", "data": {"id": 2, "msg": "Test"}}} async with admin_client.get(url, params={"id": "3"}, headers=h) as resp: assert resp.status == 403 async def test_permission_filter_get_many(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.*|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_many"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"ids": '["2", "3"]'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [{"id": "2", "data": {"id": 2, "msg": "Test"}}]} async with admin_client.get(url, params={"ids": '["3"]'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": []} async def test_permission_filter_get_many2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.view|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_many"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"ids": '["2", "3"]'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [{"id": "2", "data": {"id": 2, "msg": "Test"}}]} async with admin_client.get(url, params={"ids": '["3"]'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": []} async def test_permission_filter_create(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.*|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_create"].url_for() h = await login(admin_client) p = {"data": json.dumps({"data": {"msg": "Test"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "4", "data": {"id": 4, "msg": "Test"}}} p = {"data": json.dumps({"data": {"msg": "Foo"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 403 async def test_permission_filter_create2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.add|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_create"].url_for() h = await login(admin_client) p = {"data": json.dumps({"data": {"msg": "Test"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "4", "data": {"id": 4, "msg": "Test"}}} p = {"data": json.dumps({"data": {"msg": "Foo"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 403 async def test_permission_filter_update(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.*|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_update"].url_for() h = await login(admin_client) p = {"id": "3", "data": json.dumps({"id": "3", "data": {"msg": "Test"}}), "previousData": '{"id": "3", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"id": "1", "data": json.dumps({"id": "3", "data": {"msg": "Foo"}}), "previousData": '{"id": "1", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"id": "1", "data": json.dumps({"id": "3", "data": {"msg": "Test"}}), "previousData": '{"id": "1", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 async def test_permission_filter_update2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.edit|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_update"].url_for() h = await login(admin_client) p = {"id": "3", "data": json.dumps({"id": "3", "data": {"msg": "Test"}}), "previousData": '{"id": "3", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"id": "1", "data": json.dumps({"id": "1", "data": {"msg": "Foo"}}), "previousData": '{"id": "1", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"id": "1", "data": json.dumps({"id": "1", "data": {"msg": "Test"}}), "previousData": '{"id": "1", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 async def test_permission_filter_update_many( create_admin_client: _CreateClient, login: _Login ) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.*|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_update_many"].url_for() h = await login(admin_client) p = {"ids": '["3"]', "data": json.dumps({"msg": "Test"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"ids": '["1"]', "data": json.dumps({"msg": "Foo"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"ids": '["1", "2"]', "data": json.dumps({"msg": "Test"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 async def test_permission_filter_update_many2( create_admin_client: _CreateClient, login: _Login ) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.edit|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_update_many"].url_for() h = await login(admin_client) p = {"ids": '["3"]', "data": json.dumps({"msg": "Test"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"ids": '["1"]', "data": json.dumps({"msg": "Foo"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"ids": '["1", "2"]', "data": json.dumps({"msg": "Test"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 async def test_permission_filter_delete(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.*|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_delete"].url_for() h = await login(admin_client) p = {"id": "3", "previousData": '{"id": "3", "data": {}}'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"id": "1", "previousData": '{"id": "3", "data": {}}'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 200 async def test_permission_filter_delete2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.delete|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_delete"].url_for() h = await login(admin_client) p = {"id": "3", "previousData": '{"id": "3", "data": {}}'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"id": "1", "previousData": '{"id": "1", "data": {}}'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 200 async def test_permission_filter_delete_many(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.*|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_delete_many"].url_for() h = await login(admin_client) p = {"ids": '["2", "3"]'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"ids": '["3"]'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"ids": '["1", "2"]'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": ["1", "2"]} async def test_permission_filter_delete_many2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", 'admin.dummy2.delete|msg="Test"')} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_delete_many"].url_for() h = await login(admin_client) p = {"ids": '["2", "3"]'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"ids": '["3"]'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"ids": '["1", "2"]'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": ["1", "2"]} async def test_permission_filter_field_list(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.*|id=1|id=2")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_list"].url_for() p = {"pagination": json.dumps({"page": 1, "perPage": 10}), "sort": json.dumps({"field": "id", "order": "DESC"}), "filter": "{}"} h = await login(admin_client) async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == { "data": [{"id": "3", "data": {"id": 3}}, {"id": "2", "data": {"id": 2, "msg": "Test"}}, {"id": "1", "data": {"id": 1, "msg": "Test"}}], "total": 3} async def test_permission_filter_field_list2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.view|id=1|id=3")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_list"].url_for() p = {"pagination": json.dumps({"page": 1, "perPage": 10}), "sort": json.dumps({"field": "id", "order": "DESC"}), "filter": "{}"} h = await login(admin_client) async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == { "data": [{"id": "3", "data": {"id": 3, "msg": "Other"}}, {"id": "2", "data": {"id": 2}}, {"id": "1", "data": {"id": 1, "msg": "Test"}}], "total": 3} async def test_permission_filter_field_get_one(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.*|id=1|id=2")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_one"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"id": "1"}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "data": {"id": 1, "msg": "Test"}}} async with admin_client.get(url, params={"id": "3"}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "3", "data": {"id": 3}}} async def test_permission_filter_field_get_one2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.view|id=1|id=2")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_one"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"id": "1"}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "data": {"id": 1, "msg": "Test"}}} async with admin_client.get(url, params={"id": "3"}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "3", "data": {"id": 3}}} async def test_permission_filter_field_get_many(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.*|id=1|id=2")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_many"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"ids": '["2", "3"]'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [{"id": "2", "data": {"id": 2, "msg": "Test"}}, {"id": "3", "data": {"id": 3}}]} async def test_permission_filter_field_get_many2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.view|id=1|id=2")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_get_many"].url_for() h = await login(admin_client) async with admin_client.get(url, params={"ids": '["1", "3"]'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [{"id": "1", "data": {"id": 1, "msg": "Test"}}, {"id": "3", "data": {"id": 3}}]} async def test_permission_filter_field_create(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.*|id=1|id=2")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_create"].url_for() h = await login(admin_client) p = {"data": json.dumps({"data": {"msg": "Spam"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 403 async with admin_client.post(url, params={"data": '{"data": {}}'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "4", "data": {"id": 4}}} async with admin_client.app[db]() as sess: r = await sess.get(admin_client.app[model2], 4) assert r is not None assert r.msg is None async def test_permission_filter_field_create2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.add|id=1|id=2")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_create"].url_for() h = await login(admin_client) p = {"data": json.dumps({"data": {"msg": "Spam"}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 403 async with admin_client.post(url, params={"data": '{"data": {}}'}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "4", "data": {"id": 4, "msg": None}}} async with admin_client.app[db]() as sess: r = await sess.get(admin_client.app[model2], 4) assert r is not None assert r.msg is None async def test_permission_filter_field_update(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.*|id=1|id=2")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_update"].url_for() h = await login(admin_client) p = {"id": "3", "data": json.dumps({"id": "3", "data": {"msg": "Spam"}}), "previousData": '{"id": "3", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 400 assert await resp.text() == "400: No allowed fields to change." p = {"id": "1", "data": json.dumps({"id": "1", "data": {"msg": "Spam"}}), "previousData": '{"id": "1", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "data": {"id": 1, "msg": "Spam"}}} p = {"id": "2", "data": json.dumps({"id": "5", "data": {"id": 5}}), "previousData": '{"id": "2", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "5", "data": {"id": 5}}} async with admin_client.app[db]() as sess: r = await sess.get(admin_client.app[model2], 2) assert r is None r = await sess.get(admin_client.app[model2], 5) assert r is not None assert r.msg == "Test" async def test_permission_filter_field_update2(create_admin_client: _CreateClient, login: _Login) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.edit|id=1|id=2")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_update"].url_for() h = await login(admin_client) p = {"id": "3", "data": json.dumps({"id": "3", "data": {"msg": "Spam"}}), "previousData": '{"id": "3", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 400 assert await resp.text() == "400: No allowed fields to change." p = {"id": "1", "data": json.dumps({"id": "1", "data": {"msg": "Spam"}}), "previousData": '{"id": "1", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "data": {"id": 1, "msg": "Spam"}}} p = {"id": "2", "data": json.dumps({"id": "5", "data": {"id": 5}}), "previousData": '{"id": "2", "data": {}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "5", "data": {"id": 5, "msg": "Test"}}} async with admin_client.app[db]() as sess: r = await sess.get(admin_client.app[model2], 2) assert r is None r = await sess.get(admin_client.app[model2], 5) assert r is not None assert r.msg == "Test" async def test_permission_filter_field_update_many( create_admin_client: _CreateClient, login: _Login ) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.*|id=1|id=2")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_update_many"].url_for() h = await login(admin_client) p = {"ids": '["3"]', "data": json.dumps({"msg": "Spam"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"ids": '["1", "2"]', "data": json.dumps({"msg": "Spam"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": ["1", "2"]} async def test_permission_filter_field_update_many2( create_admin_client: _CreateClient, login: _Login ) -> None: async def identity_callback(identity: Optional[str]) -> UserDetails: return {"permissions": ("admin.*", "admin.dummy2.msg.edit|id=1|id=2")} admin_client = await create_admin_client(identity_callback) assert admin_client.app url = admin_client.app[admin].router["dummy2_update_many"].url_for() h = await login(admin_client) p = {"ids": '["3"]', "data": json.dumps({"msg": "Spam"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 403 p = {"ids": '["1", "2"]', "data": json.dumps({"msg": "Spam"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": ["1", "2"]} ================================================ FILE: tests/test_views.py ================================================ import json import re from collections.abc import Awaitable, Callable import pytest import sqlalchemy as sa from aiohttp import web from aiohttp.test_utils import TestClient from aiohttp_admin.types import comp, data, func from conftest import admin, db, model, model2 _Client = TestClient[web.Request, web.Application] _Login = Callable[[_Client], Awaitable[dict[str, str]]] async def test_admin_view(admin_client: _Client) -> None: assert admin_client.app url = admin_client.app[admin].router["index"].url_for() async with admin_client.get(url) as resp: assert resp.status == 200 html = await resp.text() m = re.search("(.*)", html) assert m is not None assert m.group(1) == "My Admin" m = re.search('', html) assert m is not None assert m.group(1) == "/admin/static/admin.js" m = re.search("", html) assert m is not None state = json.loads(m.group(1)) r = state["resources"]["dummy"] # TODO: https://github.com/marmelab/react-admin/issues/9587 assert r["list_omit"] == ["id"] assert r["fields"].keys() == {"id", "foreigns"} assert r["fields"]["id"] == comp("NumberField", {"source": data("id"), "key": "id"}) assert r["inputs"] == { "id": comp("NumberInput", {"source": data("id"), "key": "id", # "alwaysOn": "alwaysOn", "validate": [func("required", [])]}) | {"show_create": False}} assert r["repr"] == data("id") assert state["urls"] == {"token": "/admin/token", "logout": "/admin/logout"} async def test_list_pagination(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app async with admin_client.app[db].begin() as sess: for _ in range(25): sess.add(admin_client.app[model]()) url = admin_client.app[admin].router["dummy_get_list"].url_for() p = {"pagination": '{"page": 1, "perPage": 30}', "sort": '{"field": "id", "order": "ASC"}', "filter": '{}'} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 all_rows = await resp.json() assert len(all_rows["data"]) == all_rows["total"] == 26 assert tuple(r["id"] for r in all_rows["data"]) == tuple(str(i) for i in range(1, 27)) p = {"pagination": '{"page": 2, "perPage": 12}', "sort": '{"field": "id", "order": "DESC"}', "filter": '{}'} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 page = await resp.json() assert page["total"] == 26 assert tuple(r["id"] for r in page["data"]) == tuple(str(i) for i in range(14, 2, -1)) p = {"pagination": '{"page": 20, "perPage": 10}', "sort": '{"field": "id", "order": "DESC"}', "filter": '{}'} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 page = await resp.json() assert page["data"] == [] assert page["total"] == 26 async def test_list_filtering_by_pk(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app async with admin_client.app[db].begin() as sess: for _ in range(15): sess.add(admin_client.app[model]()) url = admin_client.app[admin].router["dummy_get_list"].url_for() p = {"pagination": '{"page": 1, "perPage": 10}', "sort": '{"field": "id", "order": "ASC"}', "filter": '{"id": 3}'} exp_rec = {"id": "3", "fk_id": "3", "data": {"id": 3}} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [exp_rec], "total": 1} @pytest.mark.xfail(reason="Need to implement #668 to make this work properly") async def test_list_text_like_filtering(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app async with admin_client.app[db].begin() as sess: for _ in range(15): sess.add(admin_client.app[model]()) url = admin_client.app[admin].router["dummy_get_list"].url_for() p = {"pagination": '{"page": 1, "perPage": 10}', "sort": '{"field": "id", "order": "ASC"}', "filter": '{"id": "3"}'} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [{"id": "3"}, {"id": "13"}], "total": 2} async def test_get_one(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_get_one"].url_for() async with admin_client.get(url, params={"id": 1}, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "fk_id": "1", "data": {"id": 1}}} async def test_get_one_not_exists(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_get_one"].url_for() async with admin_client.get(url, params={"id": 5}, headers=h) as resp: assert resp.status == 404 async def test_get_many(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app async with admin_client.app[db].begin() as sess: for _ in range(15): sess.add(admin_client.app[model]()) url = admin_client.app[admin].router["dummy_get_many"].url_for() p = {"ids": '["3", "7", "12"]'} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [{"id": "3", "fk_id": "3", "data": {"id": 3}}, {"id": "7", "fk_id": "7", "data": {"id": 7}}, {"id": "12", "fk_id": "12", "data": {"id": 12}}]} async def test_get_many_not_exists(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app async with admin_client.app[db].begin() as sess: for _ in range(5): sess.add(admin_client.app[model]()) url = admin_client.app[admin].router["dummy_get_many"].url_for() p = {"ids": '["3", "4", "8"]'} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": [{"id": "3", "fk_id": "3", "data": {"id": 3}}, {"id": "4", "fk_id": "4", "data": {"id": 4}}]} p = {"ids": '["9", "10", "11"]'} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 404 async def test_get_many_ref(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["foreign_get_many_ref"].url_for() page = json.dumps({"page": 1, "perPage": 10}) sort = json.dumps({"field": "id", "order": "DESC"}) p = {"target": "dummy", "id": "1", "pagination": page, "sort": sort, "filter": "{}"} expected_record = {"id": "1", "fk_dummy": "1", "data": {"id": 1, "dummy": 1}} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200, await resp.text() assert await resp.json() == {"data": [expected_record], "total": 1} async def test_get_many_ref_orm(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_get_many_ref"].url_for() page = json.dumps({"page": 1, "perPage": 10}) sort = json.dumps({"field": "id", "order": "DESC"}) f = json.dumps({"__meta__": {"orm": True}}) p = {"target": "foreigns", "id": "1", "pagination": page, "sort": sort, "filter": f} expected_record = {"id": "1", "fk_dummy": "1", "data": {"id": 1, "dummy": 1}} async with admin_client.get(url, params=p, headers=h) as resp: assert resp.status == 200, await resp.text() assert await resp.json() == {"data": [expected_record], "total": 1} async def test_create(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_create"].url_for() p = {"data": json.dumps({"data": {}})} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "2", "fk_id": "2", "data": {"id": 2}}} async with admin_client.app[db]() as sess: r = await sess.get(admin_client.app[model], 2) assert r is not None assert r.id == 2 async def test_create_duplicate_id(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_create"].url_for() p = {"data": '{"id": 1}'} async with admin_client.post(url, params=p, headers=h) as resp: assert resp.status == 400 async def test_update(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_update"].url_for() p = {"id": 1, "data": json.dumps({"id": "4", "data": {"id": 4}}), "previousData": json.dumps({"id": "1", "data": {"id": 1}})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "4", "fk_id": "4", "data": {"id": 4}}} async with admin_client.app[db]() as sess: r = await sess.get(admin_client.app[model], 4) assert r is not None assert r.id == 4 assert await sess.get(admin_client.app[model], 1) is None assert await sess.get(admin_client.app[model], 2) is None async def test_update_deleted_entity(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_update"].url_for() p = {"id": "2", "data": '{"id": "4", "data": {"id": 4}}', "previousData": '{"id": "2", "data": {"id": 2}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 404 async def test_update_invalid_attributes(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_update"].url_for() p = {"id": "1", "data": '{"id": "4", "data": {"foo": "invalid"}}', "previousData": '{"id": "1", "data": {"id": 1}}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 400 assert "foo" in await resp.text() async def test_update_many(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy2_update_many"].url_for() p = {"ids": '["1", "2"]', "data": json.dumps({"msg": "ABC"})} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": ["1", "2"]} async with admin_client.app[db]() as sess: r = await sess.get(admin_client.app[model2], 1) assert r is not None assert r.msg == "ABC" r = await sess.get(admin_client.app[model2], 2) assert r is not None assert r.msg == "ABC" async def test_update_many_deleted_entity(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_update_many"].url_for() p = {"ids": '["2"]', "data": '{"id": 4}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 404 async def test_update_many_invalid_attributes(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_update_many"].url_for() p = {"ids": '["1"]', "data": '{"foo": "invalid"}'} async with admin_client.put(url, params=p, headers=h) as resp: assert resp.status == 400 assert "foo" in await resp.text() async def test_delete(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_delete"].url_for() p = {"id": "1", "previousData": '{"id": "1", "data": {"id": 1}}'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": {"id": "1", "fk_id": "1", "data": {"id": 1}}} async with admin_client.app[db]() as sess: assert await sess.get(admin_client.app[model], 1) is None r = await sess.scalars(sa.select(admin_client.app[model])) assert len(r.all()) == 0 async def test_delete_entity_not_exists(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app url = admin_client.app[admin].router["dummy_delete"].url_for() p = {"id": "5", "previousData": '{"id": "5", "data": {"id": 5}}'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 404 async def test_delete_many(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app async with admin_client.app[db].begin() as sess: for _ in range(5): sess.add(admin_client.app[model]()) url = admin_client.app[admin].router["dummy_delete_many"].url_for() p = {"ids": '["2", "3", "5"]'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": ["2", "3", "5"]} async with admin_client.app[db]() as sess: r = await sess.scalars(sa.select(admin_client.app[model])) models = r.all() assert len(models) == 3 assert {m.id for m in models} == {1, 4, 6} async def test_delete_many_not_exists(admin_client: _Client, login: _Login) -> None: h = await login(admin_client) assert admin_client.app async with admin_client.app[db].begin() as sess: for _ in range(5): sess.add(admin_client.app[model]()) url = admin_client.app[admin].router["dummy_delete_many"].url_for() p = {"ids": '["2", "3", "9"]'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 200 assert await resp.json() == {"data": ["2", "3"]} async with admin_client.app[db]() as sess: r = await sess.scalars(sa.select(admin_client.app[model])) models = r.all() assert len(models) == 4 assert {m.id for m in models} == {1, 4, 5, 6} url = admin_client.app[admin].router["dummy_delete_many"].url_for() p = {"ids": '["12", "13"]'} async with admin_client.delete(url, params=p, headers=h) as resp: assert resp.status == 404 async with admin_client.app[db]() as sess: r = await sess.scalars(sa.select(admin_client.app[model])) assert len(r.all()) == 4