Full Code of aio-libs/aiohttp_admin for AI

master abd2214bd785 cached
70 files
285.4 KB
74.2k tokens
280 symbols
1 requests
Download .txt
Showing preview only (304K chars total). Download the full file or copy to clipboard to get everything.
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+) <https://github.com/aio-libs/aiohttp-admin/issues/\\1>`"
        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: ["<rootDir>/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) => (
    <WithRecord {...props} render={
        (record) => <DateField {...props} showDate={false} showTime={true}
                     record={{...record, [props["source"]]: record[props["source"]] === null ? null : "2020-01-01T" + record[props["source"]]}} />
    } />
);

const _TimeInput = (props) => (<TimeInput format={(v) => 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 (
        <ReferenceInput sort={{"field": repr, "order": "ASC"}} {...innerProps}>
            <AutocompleteInput filterToQuery={s => ({[repr]: s})} label={props["label"]} onChange={change} validate={validate} />
        </ReferenceInput>
    );
};

/** Display a single record in a Datagrid-like view (e.g. for ReferenceField). */
const DatagridSingle = (props) => (
    <WithRecord {...props} render={
        (record) => <Datagrid {...props} data={[record]} bulkActionButtons={false}
                     hover={false} rowClick={false} setSort={null}
                     sort={{field: "id", order: "DESC"}} />
    } />
);

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 <C {...props}>{evaluate(children)}</C>;
        return <C {...props} />;
    }
    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(<WithRecord key={c.key} {...withRecordProps} render={
            (record) => hasPermission(`${name}.${field}.view`, permissions, record) ? c : <VisibilityOffIcon />
        } />);
    }
    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(
                <SelectInput label={state["props"]["label"]} source={state["props"]["source"]} key={state["props"]["key"]} choices={choices} defaultValue={nullable < 0 && fvalues[0]}
                    validate={nullable < 0 && required()} disabled={disabled} />);
        } 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(<WithRecord label={c.props.label} source={c.props.source} key={c.key} render={
                    (record) => 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(<BulkUpdateButton mutationMode="pessimistic" key={label} label={label} data={data} />);
    }
    return buttons;
}

const AiohttpList = (resource, name, permissions) => {
    const exporter = (records) => {
        jsonExport(exportRecords(records), (err, csv) => downloadCSV(csv, name));
    };
    const ListActions = () => (
        <TopToolbar>
            <SelectColumnsButton />
            <FilterButton />
            {hasPermission(`${name}.add`, permissions) && <CreateButton />}
            <ExportButton />
        </TopToolbar>
    );
    const BulkActionButtons = () => (
        <>
            {hasPermission(`${name}.edit`, permissions) && createBulkUpdates(resource, name, permissions)}
            <BulkExportButton />
            {hasPermission(`${name}.delete`, permissions) && <BulkDeleteButton mutationMode="pessimistic" />}
        </>
    );
    const filters = createInputs(resource, name, "view", permissions);
    // Remove inputs with duplicate sources.
    const filterSources = filters.map(c => c["props"]["source"]);

    return (
        <List actions={<ListActions />} exporter={exporter} filters={filters.filter((v, i) => filterSources.indexOf(v["props"]["source"]) === i)}>
            <DatagridConfigurable omit={resource["list_omit"]} rowClick="show" bulkActionButtons={<BulkActionButtons />}>
                {createFields(resource["fields"], name, permissions)}
                <WithRecord label="[Edit]" render={(record) => hasPermission(`${name}.edit`, permissions, record) && <EditButton />} />
            </DatagridConfigurable>
        </List>
    );
}

const AiohttpShow = (resource, name, permissions) => {
    const ShowActions = () => (
        <TopToolbar>
            {resource["show_actions"].map(evaluate)}
            <WithRecord render={(record) => hasPermission(`${name}.edit`, permissions, record) && <EditButton />} />
        </TopToolbar>
    );

    return (
        <Show actions={<ShowActions />}>
            <SimpleShowLayout>
                {createFields(resource["fields"], name, permissions)}
            </SimpleShowLayout>
        </Show>
    );
}

const AiohttpEdit = (resource, name, permissions) => {
    const EditActions = () => (
        <TopToolbar>
            <CloneButton />
            <ShowButton />
            <ListButton />
        </TopToolbar>
    );

    const AiohttpEditToolbar = props => (
        <Toolbar {...props} sx={{ display: "flex", justifyContent: "space-between" }}>
            <SaveButton />
            <WithRecord render={
                (record) => hasPermission(`${name}.delete`, permissions, record) && <DeleteButton />
            } />
        </Toolbar>
    );

    return(
        <Edit actions={<EditActions />} mutationMode="pessimistic">
            <SimpleForm toolbar={<AiohttpEditToolbar />} sanitizeEmptyValues warnWhenUnsavedChanges>
                {createInputs(resource, name, "edit", permissions)}
            </SimpleForm>
        </Edit>
    );
}

const AiohttpCreate = (resource, name, permissions) => (
    <Create redirect="show">
        <SimpleForm sanitizeEmptyValues warnWhenUnsavedChanges>
            {createInputs(resource, name, "add", permissions)}
        </SimpleForm>
    </Create>
);

/** 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 (
        <img src={path} alt="" class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-vubbuv" aria-hidden="true" />
    );
};

function createResources(resources, permissions) {
    let components = [];
    for (const [name, r] of Object.entries(resources)) {
        components.push(<Resource
            name={name}
            create={hasPermission(`${name}.add`, permissions) ? AiohttpCreate(r, name, permissions) : null}
            edit={hasPermission(`${name}.edit`, permissions) ? AiohttpEdit(r, name, permissions) : null}
            list={hasPermission(`${name}.view`, permissions) ? AiohttpList(r, name, permissions) : null}
            show={hasPermission(`${name}.view`, permissions) ? AiohttpShow(r, name, permissions) : null}
            options={{ label: r["label"] }}
            recordRepresentation={r["repr"]}
            icon={r["icon"] ? () => AiohttpIcon(r["icon"]) : null}
        />);
    }
    return components;
}

const AiohttpAppBar = () => (
    <AppBar>
        <TitlePortal />
        <InspectorButton />
    </AppBar>
);

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 <progress></progress>;
    }

    return (
        <Admin {...adminProps} dataProvider={dataProvider} authProvider={authProvider} title={STATE["view"]["name"]}
               layout={(props) => <Layout {...props} appBar={AiohttpAppBar} />} disableTelemetry requireAuth>
            {permissions => createResources(STATE["resources"], permissions)}
        </Admin>
    );
};

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(
    <React.StrictMode>
        <App aiohttpState={STATE} />
    </React.StrictMode>
);


================================================
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 <EditBase> 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(<App aiohttpState={STATE} store={memoryStore()} />);
        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 = """<!doctype html>
<html>
<head>
    <meta charset="utf-8" />
    <link rel="icon" href="{icon}" />
    <title>{name} Admin</title>
    <script src="{js}" defer="defer"></script>
</head>
<body data-state='{state}'>
    <noscript>You need to enable JavaScript to access this page.</noscript>
    <div id="root"><progress></progress></div>
</body>
</html>"""


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 <target>' where <target> 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.
# "<project> v<release> 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 <link> 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 <https://groups.google.com/forum/#!forum/aio-libs>`_
or raise issue on `github <https://github.com/aio-libs/aiohttp_admin/issues>`_

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 (
        <Button
            component={Link}
            to={
                record
                    ? {
                          pathname,
                          search: stringify({
                              source: JSON.stringify(omitId(record)),
                          }),
                          state: { _scrollToTop: scrollToTop },
                      }
                    : pathname
            }
            label={label}
            onClick={stopPropagation}
            {...sanitizeRestProps(rest)}
        >
            {icon}
        </Button>
    );
};

const defaultIcon = <Queue />;

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=("
Download .txt
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
Download .txt
SYMBOL INDEX (280 symbols across 24 files)

FILE: admin-js/src/App.jsx
  constant STATE (line 49) | let STATE;
  constant COMPONENTS (line 94) | const COMPONENTS = {
  constant FUNCTIONS (line 106) | const FUNCTIONS = {exportRecords, email, maxLength, maxValue, minLength,...
  function apiRequest (line 109) | function apiRequest(url, options) {
  function dataRequest (line 125) | function dataRequest(resource, endpoint, params) {
  function evaluate (line 178) | function evaluate(obj) {
  function createFields (line 210) | function createFields(fields, name, permissions) {
  function createInputs (line 228) | function createInputs(resource, name, perm_type, permissions) {
  function createBulkUpdates (line 269) | function createBulkUpdates(resource, name, permissions) {
  function getFilters (line 371) | function getFilters(name, perm_type, permissions) {
  function hasPermission (line 384) | function hasPermission(p, permissions, context=null) {
  function createResources (line 423) | function createResources(resources, permissions) {

FILE: admin-js/src/admin.jsx
  constant STATE (line 18) | const STATE = Object.freeze(JSON.parse(_body.dataset.state));

FILE: admin-js/tests/setupTests.js
  constant STATE (line 13) | let STATE;
  function alive (line 69) | function alive() {

FILE: aiohttp_admin/__init__.py
  function pydantic_middleware (line 22) | async def pydantic_middleware(request: web.Request, handler: Handler) ->...
  function setup (line 29) | def setup(app: web.Application, schema: Schema, *, path: str = "/admin",

FILE: aiohttp_admin/backends/abc.py
  class Encoder (line 42) | class Encoder(json.JSONEncoder):
    method default (line 43) | def default(self, o: object) -> Any:
  class APIRecord (line 57) | class APIRecord(TypedDict):
  class _Pagination (line 62) | class _Pagination(TypedDict):
  class _Sort (line 67) | class _Sort(TypedDict):
  class _Params (line 72) | class _Params(TypedDict, total=False):
  class GetListParams (line 76) | class GetListParams(_Params):
  class GetOneParams (line 82) | class GetOneParams(_Params):
  class GetManyParams (line 86) | class GetManyParams(_Params):
  class GetManyRefAPIParams (line 90) | class GetManyRefAPIParams(_Params):
  class GetManyRefParams (line 98) | class GetManyRefParams(_Params):
  class _CreateData (line 106) | class _CreateData(TypedDict):
  class CreateParams (line 111) | class CreateParams(_Params):
  class UpdateParams (line 115) | class UpdateParams(_Params):
  class UpdateManyParams (line 121) | class UpdateManyParams(_Params):
  class DeleteParams (line 126) | class DeleteParams(_Params):
  class DeleteManyParams (line 131) | class DeleteManyParams(_Params):
  class _ListQuery (line 135) | class _ListQuery(TypedDict):
  class AbstractAdminResource (line 140) | class AbstractAdminResource(ABC, Generic[_ID]):
    method __init__ (line 149) | def __init__(self, record_type: Optional[dict[str, TypeAlias]] = None)...
    method filter_by_permissions (line 160) | async def filter_by_permissions(self, request: web.Request, perm_type:...
    method get_list (line 168) | async def get_list(self, params: GetListParams) -> tuple[list[Record],...
    method get_one (line 172) | async def get_one(self, record_id: _ID, meta: Meta) -> Record:
    method get_many (line 176) | async def get_many(self, record_ids: Sequence[_ID], meta: Meta) -> lis...
    method get_many_ref (line 180) | async def get_many_ref(self, params: GetManyRefParams) -> tuple[list[R...
    method update (line 184) | async def update(self, record_id: _ID, data: Record, previous_data: Re...
    method update_many (line 189) | async def update_many(self, record_ids: Sequence[_ID], data: Record, m...
    method create (line 193) | async def create(self, data: Record, meta: Meta) -> Record:
    method delete (line 197) | async def delete(self, record_id: _ID, previous_data: Record, meta: Me...
    method delete_many (line 201) | async def delete_many(self, record_ids: Sequence[_ID], meta: Meta) -> ...
    method get_many_ref_name (line 204) | async def get_many_ref_name(self, target: str, meta: Meta) -> str:
    method _get_list (line 224) | async def _get_list(self, request: web.Request) -> web.Response:
    method _get_one (line 235) | async def _get_one(self, request: web.Request) -> web.Response:
    method _get_many (line 246) | async def _get_many(self, request: web.Request) -> web.Response:
    method _get_many_ref (line 260) | async def _get_many_ref(self, request: web.Request) -> web.Response:
    method _create (line 287) | async def _create(self, request: web.Request) -> web.Response:
    method _update (line 304) | async def _update(self, request: web.Request) -> web.Response:
    method _update_many (line 335) | async def _update_many(self, request: web.Request) -> web.Response:
    method _delete (line 364) | async def _delete(self, request: web.Request) -> web.Response:
    method _delete_many (line 378) | async def _delete_many(self, request: web.Request) -> web.Response:
    method _check_record (line 395) | def _check_record(self, record: Record) -> Record:
    method _convert_record (line 400) | async def _convert_record(self, record: Record, request: web.Request) ...
    method _convert_ids (line 414) | def _convert_ids(self, ids: Sequence[_ID]) -> tuple[str, ...]:
    method _process_list_query (line 418) | def _process_list_query(self, query: _ListQuery, request: web.Request)...
    method routes (line 446) | def routes(self) -> tuple[web.RouteDef, ...]:

FILE: aiohttp_admin/backends/sqlalchemy.py
  function get_components (line 80) | def get_components(t: sa.types.TypeEngine[object]) -> _Components:
  function handle_errors (line 88) | def handle_errors(
  function permission_for (line 105) | def permission_for(sa_obj: Union[sa.Table, type[DeclarativeBase],
  function create_filters (line 157) | def create_filters(columns: sa.ColumnCollection[str, sa.Column[object]],
  class SAResource (line 165) | class SAResource(AbstractAdminResource[tuple[Any, ...]]):
    method __init__ (line 168) | def __init__(self, db: AsyncEngine, model_or_table: _ModelOrTable):
    method get_list (line 300) | async def get_list(self, params: GetListParams) -> tuple[list[Record],...
    method get_one (line 326) | async def get_one(self, record_id: tuple[Any, ...], meta: Meta) -> Rec...
    method get_many (line 333) | async def get_many(self, record_ids: Sequence[tuple[Any, ...]], meta: ...
    method get_many_ref_name (line 339) | async def get_many_ref_name(self, target: str, meta: Meta) -> str:
    method get_many_ref (line 348) | async def get_many_ref(self, params: GetManyRefParams) -> tuple[list[R...
    method create (line 374) | async def create(self, data: Record, meta: Meta) -> Record:
    method update (line 385) | async def update(self, record_id: tuple[Any, ...], data: Record, previ...
    method update_many (line 394) | async def update_many(self, record_ids: Sequence[tuple[Any, ...]], dat...
    method delete (line 402) | async def delete(self, record_id: tuple[Any, ...], previous_data: Record,
    method delete_many (line 410) | async def delete_many(self, record_ids: Sequence[tuple[Any, ...]],
    method _cmp_pk (line 417) | def _cmp_pk(self, record_id: tuple[Any, ...]) -> Iterator[_SABoolExpre...
    method _cmp_pk_many (line 420) | def _cmp_pk_many(self, record_ids: Sequence[tuple[Any, ...]]) -> _SABo...
    method _get_validators (line 423) | def _get_validators(self, table: sa.Table, c: sa.Column[object]) -> li...

FILE: aiohttp_admin/routes.py
  function setup_resources (line 13) | def setup_resources(admin: web.Application, schema: Schema) -> None:
  function setup_routes (line 61) | def setup_routes(admin: web.Application) -> None:

FILE: aiohttp_admin/security.py
  function _get_schema (line 18) | def _get_schema(t: Type[_T]) -> TypeAdapter[_T]:  # type: ignore[misc]
  function check (line 22) | def check(t: Type[_T], value: object) -> _T:
  class Permissions (line 28) | class Permissions(str, Enum):
  function has_permission (line 36) | def has_permission(p: Union[str, Enum], permissions: Mapping[str, Mappin...
  function permissions_as_dict (line 64) | def permissions_as_dict(permissions: Collection[str]) -> dict[str, dict[...
  class AdminAuthorizationPolicy (line 75) | class AdminAuthorizationPolicy(AbstractAuthorizationPolicy):
    method __init__ (line 76) | def __init__(self, schema: Schema):
    method authorized_userid (line 80) | async def authorized_userid(self, identity: str) -> str:
    method permits (line 83) | async def permits(
  class TokenIdentityPolicy (line 109) | class TokenIdentityPolicy(SessionIdentityPolicy):
    method __init__ (line 110) | def __init__(self, fernet: Fernet, schema: Schema):
    method identify (line 117) | async def identify(self, request: web.Request) -> Optional[str]:
    method remember (line 138) | async def remember(self, request: web.Request, response: web.StreamRes...
    method forget (line 148) | async def forget(self, request: web.Request, response: web.StreamRespo...
    method user_identity_dict (line 152) | async def user_identity_dict(self, request: web.Request, identity: str...

FILE: aiohttp_admin/types.py
  function data (line 17) | def data(key: str) -> Data:
  function fk (line 21) | def fk(*keys: str) -> FK:
  class ComponentState (line 25) | class ComponentState(TypedDict):
  class FunctionState (line 31) | class FunctionState(TypedDict):
  class RegexState (line 37) | class RegexState(TypedDict):
  class InputState (line 42) | class InputState(ComponentState):
  class _IdentityDict (line 47) | class _IdentityDict(TypedDict, total=False):
  class IdentityDict (line 51) | class IdentityDict(_IdentityDict):
  class UserDetails (line 57) | class UserDetails(TypedDict, total=False):
  class __SecuritySchema (line 65) | class __SecuritySchema(TypedDict, total=False):
  class _SecuritySchema (line 74) | class _SecuritySchema(__SecuritySchema):
  class _ViewSchema (line 80) | class _ViewSchema(TypedDict, total=False):
  class _Resource (line 87) | class _Resource(TypedDict, total=False):
  class Resource (line 111) | class Resource(_Resource):
  class _Schema (line 116) | class _Schema(TypedDict, total=False):
  class Schema (line 121) | class Schema(_Schema):
  class _ResourceState (line 126) | class _ResourceState(TypedDict):
  class State (line 138) | class State(TypedDict):
  function comp (line 145) | def comp(t: str, props: Optional[Mapping[str, object]] = None) -> Compon...
  function func (line 157) | def func(name: str, args: Optional[Sequence[object]] = None) -> Function...
  function regex (line 168) | def regex(value: str) -> RegexState:

FILE: aiohttp_admin/views.py
  class _Login (line 18) | class _Login(TypedDict):
  function index (line 38) | async def index(request: web.Request) -> web.Response:
  function token (line 56) | async def token(request: web.Request) -> web.Response:
  function logout (line 69) | async def logout(request: web.Request) -> web.Response:

FILE: examples/demo/app.py
  class Base (line 16) | class Base(DeclarativeBase):
  class User (line 20) | class User(Base):
  function check_credentials (line 34) | async def check_credentials(username: str, password: str) -> bool:
  function create_app (line 38) | async def create_app() -> web.Application:

FILE: examples/permissions.py
  class Base (line 24) | class Base(DeclarativeBase):
  class Simple (line 28) | class Simple(Base):
  class SimpleParent (line 37) | class SimpleParent(Base):
  class User (line 45) | class User(Base):
  function check_credentials (line 52) | async def check_credentials(app: web.Application, username: str, passwor...
  function identity_callback (line 59) | async def identity_callback(app: web.Application, identity: str) -> User...
  function create_app (line 67) | async def create_app() -> web.Application:

FILE: examples/relationships.py
  class Base (line 20) | class Base(DeclarativeBase):
  class OneToManyParent (line 24) | class OneToManyParent(Base):
  class OneToManyChild (line 33) | class OneToManyChild(Base):
  class ManyToOneParent (line 43) | class ManyToOneParent(Base):
  class ManyToOneChild (line 53) | class ManyToOneChild(Base):
  class OneToOneParent (line 62) | class OneToOneParent(Base):
  class OneToOneChild (line 71) | class OneToOneChild(Base):
  class ManyToManyParent (line 89) | class ManyToManyParent(Base):
  class ManyToManyChild (line 99) | class ManyToManyChild(Base):
  class CompositeForeignKeyChild (line 109) | class CompositeForeignKeyChild(Base):
  class CompositeForeignKeyParent (line 119) | class CompositeForeignKeyParent(Base):
    method __table_args__ (line 131) | def __table_args__(cls) -> tuple[sa.schema.SchemaItem, ...]:
  function check_credentials (line 138) | async def check_credentials(username: str, password: str) -> bool:
  function create_app (line 142) | async def create_app() -> web.Application:

FILE: examples/simple.py
  class Currency (line 20) | class Currency(Enum):
  class Base (line 26) | class Base(DeclarativeBase):
  class Simple (line 30) | class Simple(Base):
  class SimpleParent (line 39) | class SimpleParent(Base):
  function check_credentials (line 48) | async def check_credentials(username: str, password: str) -> bool:
  function create_app (line 52) | async def create_app() -> web.Application:

FILE: examples/validators.py
  class Base (line 26) | class Base(DeclarativeBase):
  class User (line 30) | class User(Base):
  function check_credentials (line 44) | async def check_credentials(username: str, password: str) -> bool:
  function serve_js (line 48) | async def serve_js(request: web.Request) -> web.Response:
  function create_app (line 52) | async def create_app() -> web.Application:

FILE: setup.py
  function read_version (line 11) | def read_version():

FILE: tests/_auth.py
  function check_credentials (line 1) | async def check_credentials(username: str, password: str) -> bool:

FILE: tests/_resources.py
  class DummyResource (line 8) | class DummyResource(AbstractAdminResource[tuple[str]]):
    method __init__ (line 9) | def __init__(self, name: str, fields: dict[str, ComponentState],
    method get_list (line 20) | async def get_list(self, params: GetListParams) -> tuple[list[Record],...
    method get_one (line 23) | async def get_one(self, record_id: tuple[str], meta: Meta) -> Record: ...
    method get_many (line 26) | async def get_many(self, record_ids: Sequence[tuple[str]], meta: Meta)...
    method get_many_ref (line 29) | async def get_many_ref(self, params: GetManyRefParams) -> tuple[list[R...
    method update (line 32) | async def update(  # pragma: no cover
    method update_many (line 37) | async def update_many(  # pragma: no cover
    method create (line 42) | async def create(self, data: Record, meta: Meta) -> Record:  # pragma:...
    method delete (line 45) | async def delete(self, record_id: tuple[str], previous_data: Record, m...
    method delete_many (line 48) | async def delete_many(  # pragma: no cover

FILE: tests/conftest.py
  class Base (line 22) | class Base(DeclarativeBaseNoMeta):
  class DummyModel (line 26) | class DummyModel(Base):
  class Dummy2Model (line 34) | class Dummy2Model(Base):
  class ForeignModel (line 41) | class ForeignModel(Base):
  function mock_engine (line 55) | def mock_engine() -> AsyncMock:
  function create_admin_client (line 60) | def create_admin_client(
  function admin_client (line 101) | async def admin_client(create_admin_client: Callable[[], Awaitable[_Clie...
  function login (line 106) | def login() -> Callable[[_Client], Awaitable[dict[str, str]]]:

FILE: tests/test_admin.py
  function test_path (line 10) | def test_path() -> None:
  function test_js_module (line 23) | def test_js_module() -> None:
  function test_no_js_module (line 32) | def test_no_js_module() -> None:
  function test_validators (line 41) | def test_validators() -> None:
  function test_re (line 59) | def test_re() -> None:
  function test_display (line 92) | def test_display() -> None:
  function test_display_invalid (line 112) | def test_display_invalid() -> None:
  function test_extra_props (line 122) | def test_extra_props() -> None:
  function test_invalid_repr (line 148) | def test_invalid_repr() -> None:

FILE: tests/test_backends_abc.py
  function test_create_with_null (line 13) | async def test_create_with_null(admin_client: _Client, login: _Login) ->...
  function test_invalid_field (line 23) | async def test_invalid_field(admin_client: _Client, login: _Login) -> None:

FILE: tests/test_backends_sqlalchemy.py
  function base (line 26) | def base() -> type[DeclarativeBase]:
  function test_no_subtypes (line 33) | def test_no_subtypes() -> None:
  function test_pk (line 38) | def test_pk(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None:
  function test_table (line 62) | def test_table(mock_engine: AsyncEngine) -> None:
  function test_extra_props (line 85) | def test_extra_props(base: type[DeclarativeBase], mock_engine: AsyncEngi...
  function test_binary (line 101) | async def test_binary(
  function test_fk (line 142) | def test_fk(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None:
  function test_fk_output (line 165) | async def test_fk_output(
  function test_relationship (line 211) | def test_relationship(base: type[DeclarativeBase], mock_engine: AsyncEng...
  function test_relationship_onetoone (line 246) | def test_relationship_onetoone(base: type[DeclarativeBase], mock_engine:...
  function test_check_constraints (line 280) | def test_check_constraints(base: type[DeclarativeBase], mock_engine: Asy...
  function test_nonid_pk (line 329) | async def test_nonid_pk(base: type[DeclarativeBase], mock_engine: AsyncE...
  function test_id_nonpk (line 353) | async def test_id_nonpk(base: type[DeclarativeBase], mock_engine: AsyncE...
  function test_nonid_pk_api (line 382) | async def test_nonid_pk_api(
  function test_datetime (line 447) | async def test_datetime(
  function test_permission_for (line 492) | def test_permission_for(base: type[DeclarativeBase]) -> None:
  function test_record_type (line 526) | async def test_record_type(

FILE: tests/test_security.py
  function test_no_token (line 17) | async def test_no_token(admin_client: _Client) -> None:
  function test_invalid_token (line 25) | async def test_invalid_token(admin_client: _Client) -> None:
  function test_valid_login_logout (line 33) | async def test_valid_login_logout(admin_client: _Client) -> None:
  function test_missing_token (line 57) | async def test_missing_token(admin_client: _Client) -> None:
  function test_missing_cookie (line 74) | async def test_missing_cookie(admin_client: _Client) -> None:
  function test_login_invalid_payload (line 91) | async def test_login_invalid_payload(admin_client: _Client) -> None:
  function test_list_without_permission (line 106) | async def test_list_without_permission(create_admin_client: _CreateClient,
  function test_get_resource_with_permission (line 126) | async def test_get_resource_with_permission(create_admin_client: _Create...
  function test_get_fk_with_permission (line 142) | async def test_get_fk_with_permission(create_admin_client: _CreateClient...
  function test_get_fk_without_permission (line 158) | async def test_get_fk_without_permission(create_admin_client: _CreateCli...
  function test_get_resource_with_wildcard_permission (line 174) | async def test_get_resource_with_wildcard_permission(create_admin_client...
  function test_get_resource_with_negative_permission (line 190) | async def test_get_resource_with_negative_permission(create_admin_client...
  function test_list_resource_finegrained_permission (line 220) | async def test_list_resource_finegrained_permission(create_admin_client:...
  function test_get_resource_finegrained_permission (line 240) | async def test_get_resource_finegrained_permission(create_admin_client: ...
  function test_get_many_resource_finegrained_permission (line 256) | async def test_get_many_resource_finegrained_permission(create_admin_cli...
  function test_create_resource_finegrained_permission (line 272) | async def test_create_resource_finegrained_permission(create_admin_clien...
  function test_create_resource_filtered_permission (line 294) | async def test_create_resource_filtered_permission(create_admin_client: ...
  function test_update_resource_finegrained_permission (line 316) | async def test_update_resource_finegrained_permission(create_admin_clien...
  function test_update_resource_filtered_permission (line 334) | async def test_update_resource_filtered_permission(create_admin_client: ...
  function test_update_many_resource_finegrained_permission (line 359) | async def test_update_many_resource_finegrained_permission(
  function test_delete_resource_filtered_permission (line 377) | async def test_delete_resource_filtered_permission(create_admin_client: ...
  function test_permissions_cached (line 394) | async def test_permissions_cached(create_admin_client: _CreateClient,
  function test_permission_filter_list (line 413) | async def test_permission_filter_list(create_admin_client: _CreateClient,
  function test_permission_filter_list2 (line 437) | async def test_permission_filter_list2(create_admin_client: _CreateClient,
  function test_permission_filter_get_one (line 456) | async def test_permission_filter_get_one(create_admin_client: _CreateCli...
  function test_permission_filter_get_one2 (line 473) | async def test_permission_filter_get_one2(create_admin_client: _CreateCl...
  function test_permission_filter_get_many (line 490) | async def test_permission_filter_get_many(create_admin_client: _CreateCl...
  function test_permission_filter_get_many2 (line 508) | async def test_permission_filter_get_many2(create_admin_client: _CreateC...
  function test_permission_filter_create (line 526) | async def test_permission_filter_create(create_admin_client: _CreateClient,
  function test_permission_filter_create2 (line 545) | async def test_permission_filter_create2(create_admin_client: _CreateCli...
  function test_permission_filter_update (line 564) | async def test_permission_filter_update(create_admin_client: _CreateClient,
  function test_permission_filter_update2 (line 588) | async def test_permission_filter_update2(create_admin_client: _CreateCli...
  function test_permission_filter_update_many (line 612) | async def test_permission_filter_update_many(
  function test_permission_filter_update_many2 (line 634) | async def test_permission_filter_update_many2(
  function test_permission_filter_delete (line 656) | async def test_permission_filter_delete(create_admin_client: _CreateClient,
  function test_permission_filter_delete2 (line 674) | async def test_permission_filter_delete2(create_admin_client: _CreateCli...
  function test_permission_filter_delete_many (line 692) | async def test_permission_filter_delete_many(create_admin_client: _Creat...
  function test_permission_filter_delete_many2 (line 714) | async def test_permission_filter_delete_many2(create_admin_client: _Crea...
  function test_permission_filter_field_list (line 736) | async def test_permission_filter_field_list(create_admin_client: _Create...
  function test_permission_filter_field_list2 (line 757) | async def test_permission_filter_field_list2(create_admin_client: _Creat...
  function test_permission_filter_field_get_one (line 778) | async def test_permission_filter_field_get_one(create_admin_client: _Cre...
  function test_permission_filter_field_get_one2 (line 796) | async def test_permission_filter_field_get_one2(create_admin_client: _Cr...
  function test_permission_filter_field_get_many (line 814) | async def test_permission_filter_field_get_many(create_admin_client: _Cr...
  function test_permission_filter_field_get_many2 (line 830) | async def test_permission_filter_field_get_many2(create_admin_client: _C...
  function test_permission_filter_field_create (line 846) | async def test_permission_filter_field_create(create_admin_client: _Crea...
  function test_permission_filter_field_create2 (line 868) | async def test_permission_filter_field_create2(create_admin_client: _Cre...
  function test_permission_filter_field_update (line 890) | async def test_permission_filter_field_update(create_admin_client: _Crea...
  function test_permission_filter_field_update2 (line 923) | async def test_permission_filter_field_update2(create_admin_client: _Cre...
  function test_permission_filter_field_update_many (line 956) | async def test_permission_filter_field_update_many(
  function test_permission_filter_field_update_many2 (line 976) | async def test_permission_filter_field_update_many2(

FILE: tests/test_views.py
  function test_admin_view (line 17) | async def test_admin_view(admin_client: _Client) -> None:
  function test_list_pagination (line 50) | async def test_list_pagination(admin_client: _Client, login: _Login) -> ...
  function test_list_filtering_by_pk (line 83) | async def test_list_filtering_by_pk(admin_client: _Client, login: _Login...
  function test_list_text_like_filtering (line 100) | async def test_list_text_like_filtering(admin_client: _Client, login: _L...
  function test_get_one (line 115) | async def test_get_one(admin_client: _Client, login: _Login) -> None:
  function test_get_one_not_exists (line 125) | async def test_get_one_not_exists(admin_client: _Client, login: _Login) ...
  function test_get_many (line 134) | async def test_get_many(admin_client: _Client, login: _Login) -> None:
  function test_get_many_not_exists (line 150) | async def test_get_many_not_exists(admin_client: _Client, login: _Login)...
  function test_get_many_ref (line 169) | async def test_get_many_ref(admin_client: _Client, login: _Login) -> None:
  function test_get_many_ref_orm (line 183) | async def test_get_many_ref_orm(admin_client: _Client, login: _Login) ->...
  function test_create (line 198) | async def test_create(admin_client: _Client, login: _Login) -> None:
  function test_create_duplicate_id (line 213) | async def test_create_duplicate_id(admin_client: _Client, login: _Login)...
  function test_update (line 222) | async def test_update(admin_client: _Client, login: _Login) -> None:
  function test_update_deleted_entity (line 241) | async def test_update_deleted_entity(admin_client: _Client, login: _Logi...
  function test_update_invalid_attributes (line 251) | async def test_update_invalid_attributes(admin_client: _Client, login: _...
  function test_update_many (line 262) | async def test_update_many(admin_client: _Client, login: _Login) -> None:
  function test_update_many_deleted_entity (line 280) | async def test_update_many_deleted_entity(admin_client: _Client, login: ...
  function test_update_many_invalid_attributes (line 289) | async def test_update_many_invalid_attributes(admin_client: _Client, log...
  function test_delete (line 299) | async def test_delete(admin_client: _Client, login: _Login) -> None:
  function test_delete_entity_not_exists (line 314) | async def test_delete_entity_not_exists(admin_client: _Client, login: _L...
  function test_delete_many (line 323) | async def test_delete_many(admin_client: _Client, login: _Login) -> None:
  function test_delete_many_not_exists (line 343) | async def test_delete_many_not_exists(admin_client: _Client, login: _Log...
Condensed preview — 70 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (312K chars).
[
  {
    "path": ".codecov.yml",
    "chars": 41,
    "preview": "codecov:\n  notify:\n    after_n_builds: 6\n"
  },
  {
    "path": ".coveragerc",
    "chars": 57,
    "preview": "# .coveragerc to control coverage.py\n[run]\nbranch = True\n"
  },
  {
    "path": ".flake8",
    "chars": 831,
    "preview": "[flake8]\nenable-extensions = G\nmax-doc-length = 90\nmax-line-length = 90\nselect = A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 22,
    "preview": "github: Dreamsorcerer\n"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 421,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: pip\n    directory: \"/\"\n    schedule:\n      interval: daily\n\n  - package-ecosy"
  },
  {
    "path": ".github/workflows/auto-merge.yml",
    "chars": 608,
    "preview": "name: Dependabot auto-merge\non: pull_request_target\n\npermissions:\n  pull-requests: write\n  contents: write\n\njobs:\n  depe"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 7591,
    "preview": "name: CI\n\non:\n  push:\n    branches:\n      - master\n      - '[0-9].[0-9]+'  # matches to backport branches, e.g. 3.6\n    "
  },
  {
    "path": ".gitignore",
    "chars": 335,
    "preview": "__pycache__/\n\n# From yarn install\nadmin-js/yarn.lock\nadmin-js/node_modules/\nexamples/demo/admin-js/yarn.lock\nexamples/de"
  },
  {
    "path": ".mypy.ini",
    "chars": 1013,
    "preview": "[mypy]\nfiles = aiohttp_admin, examples, tests\ncheck_untyped_defs = True\nfollow_imports_for_stubs = True\ndisallow_any_dec"
  },
  {
    "path": ".pre-commit-config.yaml",
    "chars": 1345,
    "preview": "repos:\n- repo: https://github.com/pre-commit/pre-commit-hooks\n  rev: 'v4.4.0'\n  hooks:\n  - id: check-merge-conflict\n- re"
  },
  {
    "path": "CHANGES.rst",
    "chars": 2081,
    "preview": "=======\nCHANGES\n=======\n\n.. towncrier release notes start\n\n0.1.0a3 (2023-12-03)\n====================\n\n- Used ``AppKey`` "
  },
  {
    "path": "LICENSE",
    "chars": 11310,
    "preview": "Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licens"
  },
  {
    "path": "MANIFEST.in",
    "chars": 96,
    "preview": "include CHANGES.rst\ninclude LICENSE\ninclude README.rst\ngraft aiohttp_admin\nglobal-exclude *.pyc\n"
  },
  {
    "path": "README.rst",
    "chars": 751,
    "preview": "aiohttp-admin\n=============\n.. image:: https://codecov.io/gh/aio-libs/aiohttp-admin/branch/master/graph/badge.svg\n    :t"
  },
  {
    "path": "admin-js/babel.config.js",
    "chars": 118,
    "preview": "module.exports = {\n  presets: [\n    \"@babel/preset-env\",\n    [\"@babel/preset-react\", {runtime: \"automatic\"}],\n  ],\n};\n"
  },
  {
    "path": "admin-js/jest.config.js",
    "chars": 364,
    "preview": "module.exports = {\n  clearMocks: true,\n  collectCoverageFrom: [\"src/**\", \"tests/**\"],\n  errorOnDeprecated: true,\n  maxWo"
  },
  {
    "path": "admin-js/package.json",
    "chars": 1678,
    "preview": "{\n  \"name\": \"admin-js\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"react\": \"18.2.0\",\n    \"react-a"
  },
  {
    "path": "admin-js/src/App.jsx",
    "chars": 18899,
    "preview": "import {useState} from \"react\";\nimport {\n    Admin, AppBar, AutocompleteInput,\n    BooleanField, BooleanInput, BulkDelet"
  },
  {
    "path": "admin-js/src/admin.jsx",
    "chars": 880,
    "preview": "import React from \"react\";\nimport ReactJSXRuntime from \"react/jsx-runtime\";\nimport ReactDOM from \"react-dom\";\nimport Rea"
  },
  {
    "path": "admin-js/tests/permissions.test.js",
    "chars": 2666,
    "preview": "import {within} from \"@testing-library/dom\";\nimport {screen, waitFor} from \"@testing-library/react\";\nimport userEvent fr"
  },
  {
    "path": "admin-js/tests/relationships.test.js",
    "chars": 12351,
    "preview": "import {within} from \"@testing-library/dom\";\nimport {screen, waitFor} from \"@testing-library/react\";\nimport userEvent fr"
  },
  {
    "path": "admin-js/tests/setupTests.js",
    "chars": 3837,
    "preview": "const http = require(\"http\");\nconst {spawn} = require(\"child_process\");\nimport \"whatwg-fetch\";  // https://github.com/js"
  },
  {
    "path": "admin-js/tests/simple.test.js",
    "chars": 11042,
    "preview": "import {within} from \"@testing-library/dom\";\nimport {screen, waitFor} from \"@testing-library/react\";\nimport userEvent fr"
  },
  {
    "path": "admin-js/vite.config.js",
    "chars": 338,
    "preview": "import { defineConfig } from \"vite\";\n\nexport default defineConfig({\n    build: {\n        minify: \"terser\",\n        outDi"
  },
  {
    "path": "aiohttp_admin/__init__.py",
    "chars": 4700,
    "preview": "import re\nimport secrets\nfrom typing import Optional\n\nimport aiohttp_security\nimport aiohttp_session\nfrom aiohttp import"
  },
  {
    "path": "aiohttp_admin/backends/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "aiohttp_admin/backends/abc.py",
    "chars": 18721,
    "preview": "import asyncio\nimport json\nimport sys\nfrom abc import ABC, abstractmethod\nfrom collections.abc import Sequence\nfrom date"
  },
  {
    "path": "aiohttp_admin/backends/sqlalchemy.py",
    "chars": 22120,
    "preview": "import asyncio\nimport json\nimport logging\nimport operator\nimport sys\nfrom collections.abc import Callable, Coroutine, It"
  },
  {
    "path": "aiohttp_admin/py.typed",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "aiohttp_admin/routes.py",
    "chars": 2705,
    "preview": "\"\"\"Setup routes for admin app.\"\"\"\n\nimport copy\nfrom pathlib import Path\n\nfrom aiohttp import web\n\nfrom . import views\nfr"
  },
  {
    "path": "aiohttp_admin/security.py",
    "chars": 7173,
    "preview": "import json\nfrom collections.abc import Collection, Mapping, Sequence\nfrom enum import Enum\nfrom functools import lru_ca"
  },
  {
    "path": "aiohttp_admin/types.py",
    "chars": 5310,
    "preview": "import re\nimport sys\nfrom collections.abc import Callable, Collection, Sequence\nfrom typing import Any, Awaitable, Liter"
  },
  {
    "path": "aiohttp_admin/views.py",
    "chars": 2251,
    "preview": "import __main__\nimport json\nimport sys\n\nfrom aiohttp import web\nfrom aiohttp_security import forget, remember\nfrom pydan"
  },
  {
    "path": "docs/Makefile",
    "chars": 7634,
    "preview": "# Makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS    =\nSPHINXBUILD "
  },
  {
    "path": "docs/README.md",
    "chars": 673,
    "preview": "# The docs for the new admin realization\n\n## Library Installation\n```\npip install aiohttp_django\n```\n\n## ModelAdmin\n```p"
  },
  {
    "path": "docs/api.rst",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "docs/changelog.rst",
    "chars": 55,
    "preview": ".. module:: aiohttp-admin\n\n.. include:: ../CHANGES.txt\n"
  },
  {
    "path": "docs/conf.py",
    "chars": 10086,
    "preview": "#!/usr/bin/env python3\n# aiohttp-admin documentation build configuration file, created by\n# sphinx-quickstart on Sun Nov"
  },
  {
    "path": "docs/contents.rst.inc",
    "chars": 184,
    "preview": "Documentation\n-------------\n\n.. toctree::\n   :maxdepth: 2\n\n   contributing\n   design\n   api\n\n\nAdditional Information\n---"
  },
  {
    "path": "docs/contributing.rst",
    "chars": 33,
    "preview": ".. include:: ../CONTRIBUTING.rst\n"
  },
  {
    "path": "docs/design.rst",
    "chars": 560,
    "preview": "Design\n------\n\n**aiohttp_admin** using following design philosophy:\n\n- backend and frontend of admin views are decoupled"
  },
  {
    "path": "docs/index.rst",
    "chars": 2603,
    "preview": ".. aiohttp-admin documentation master file, created by\n   sphinx-quickstart on Sun Nov 13 21:04:19 2016.\n   You can adap"
  },
  {
    "path": "examples/demo/README",
    "chars": 1830,
    "preview": "To build a custom component:\n\n    First we need to replace some of our dependencies with a shim (ensure shim/ is copied\n"
  },
  {
    "path": "examples/demo/admin-js/craco.config.js",
    "chars": 180,
    "preview": "module.exports = {\n  webpack: {\n    configure: {\n      output: {\n        library: {\n          type: 'module'\n        }\n "
  },
  {
    "path": "examples/demo/admin-js/package.json",
    "chars": 1433,
    "preview": "{\n  \"name\": \"admin-js\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@craco/craco\": \"^7.1.0\",\n    \""
  },
  {
    "path": "examples/demo/admin-js/public/index.html",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "examples/demo/admin-js/shim/query-string/index.js",
    "chars": 37,
    "preview": "module.exports = window.QueryString;\n"
  },
  {
    "path": "examples/demo/admin-js/shim/react/index.js",
    "chars": 31,
    "preview": "module.exports = window.React;\n"
  },
  {
    "path": "examples/demo/admin-js/shim/react/jsx-runtime.js",
    "chars": 41,
    "preview": "module.exports = window.ReactJSXRuntime;\n"
  },
  {
    "path": "examples/demo/admin-js/shim/react-admin/index.js",
    "chars": 36,
    "preview": "module.exports = window.ReactAdmin;\n"
  },
  {
    "path": "examples/demo/admin-js/shim/react-dom/index.js",
    "chars": 34,
    "preview": "module.exports = window.ReactDOM;\n"
  },
  {
    "path": "examples/demo/admin-js/shim/react-router-dom/index.js",
    "chars": 40,
    "preview": "module.exports = window.ReactRouterDOM;\n"
  },
  {
    "path": "examples/demo/admin-js/src/index.js",
    "chars": 1520,
    "preview": "import { memo } from 'react';\nimport Queue from '@mui/icons-material/Queue';\nimport { Link } from 'react-router-dom';\nim"
  },
  {
    "path": "examples/demo/app.py",
    "chars": 2223,
    "preview": "\"\"\"Demo application.\n\nWhen running this file, admin will be accessible at /admin.\n\"\"\"\n\nimport sqlalchemy as sa\nfrom aioh"
  },
  {
    "path": "examples/permissions.py",
    "chars": 5455,
    "preview": "\"\"\"Example to demonstrate usage of permissions.\n\nWhen running this file, admin will be accessible at /admin.\nCheck near "
  },
  {
    "path": "examples/relationships.py",
    "chars": 8190,
    "preview": "\"\"\"Example that demonstrates use of various foreign key relationships.\n\nAn example of each SQLAlchemy relationship is in"
  },
  {
    "path": "examples/simple.py",
    "chars": 2276,
    "preview": "\"\"\"Minimal example with simple database models.\n\nWhen running this file, admin will be accessible at /admin.\n\"\"\"\n\nfrom d"
  },
  {
    "path": "examples/validators.py",
    "chars": 2900,
    "preview": "\"\"\"Minimal example with simple database models.\n\nWhen running this file, admin will be accessible at /admin.\n\"\"\"\n\nimport"
  },
  {
    "path": "pytest.ini",
    "chars": 394,
    "preview": "[pytest]\naddopts =\n    # show 10 slowest invocations:\n    --durations=10\n    # a bit of verbosity doesn't hurt:\n    -v\n "
  },
  {
    "path": "requirements-dev.txt",
    "chars": 163,
    "preview": "-r requirements.txt\n\nflake8==7.3.0\nflake8-bandit==4.1.1\nflake8-bugbear==25.11.29\nflake8-import-order==0.19.2\nflake8-requ"
  },
  {
    "path": "requirements.txt",
    "chars": 255,
    "preview": "-e .\naiohttp==3.13.5\naiohttp-security==0.5.0\naiohttp-session[secure]==2.12.1\naiosqlite==0.21.0\ncryptography==46.0.7\npyda"
  },
  {
    "path": "setup.py",
    "chars": 1659,
    "preview": "import re\nimport sys\nfrom pathlib import Path\n\nfrom setuptools import find_packages, setup\n\nif not sys.version_info >= ("
  },
  {
    "path": "tests/_auth.py",
    "chars": 125,
    "preview": "async def check_credentials(username: str, password: str) -> bool:\n    return username == \"admin\" and password == \"admin"
  },
  {
    "path": "tests/_resources.py",
    "chars": 2087,
    "preview": "from typing import Sequence\n\nfrom aiohttp_admin.backends.abc import (AbstractAdminResource, GetListParams,\n             "
  },
  {
    "path": "tests/conftest.py",
    "chars": 3859,
    "preview": "from collections.abc import Awaitable, Callable\nfrom typing import Optional\nfrom unittest.mock import AsyncMock, create_"
  },
  {
    "path": "tests/test_admin.py",
    "chars": 6717,
    "preview": "import pytest\nfrom aiohttp import web\n\nimport aiohttp_admin\nfrom _auth import check_credentials\nfrom _resources import D"
  },
  {
    "path": "tests/test_backends_abc.py",
    "chars": 1193,
    "preview": "import json\nfrom collections.abc import Awaitable, Callable\n\nfrom aiohttp import web\nfrom aiohttp.test_utils import Test"
  },
  {
    "path": "tests/test_backends_sqlalchemy.py",
    "chars": 25121,
    "preview": "import json\nfrom collections.abc import Awaitable, Callable\nfrom datetime import date, datetime\nfrom typing import Optio"
  },
  {
    "path": "tests/test_security.py",
    "chars": 45511,
    "preview": "import json\nfrom collections.abc import Awaitable, Callable\nfrom typing import Optional\nfrom unittest import mock\n\nfrom "
  },
  {
    "path": "tests/test_views.py",
    "chars": 15428,
    "preview": "import json\nimport re\nfrom collections.abc import Awaitable, Callable\n\nimport pytest\nimport sqlalchemy as sa\nfrom aiohtt"
  }
]

About this extraction

This page contains the full source code of the aio-libs/aiohttp_admin GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 70 files (285.4 KB), approximately 74.2k tokens, and a symbol index with 280 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!