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